diff --git a/Backend.Tests/Controllers/UserControllerTests.cs b/Backend.Tests/Controllers/UserControllerTests.cs index 66fa30e625..f7ce892a59 100644 --- a/Backend.Tests/Controllers/UserControllerTests.cs +++ b/Backend.Tests/Controllers/UserControllerTests.cs @@ -11,7 +11,10 @@ namespace Backend.Tests.Controllers { internal sealed class UserControllerTests : IDisposable { + private IProjectRepository _projectRepo = null!; private IUserRepository _userRepo = null!; + private IUserEditRepository _userEditRepo = null!; + private IUserRoleRepository _userRoleRepo = null!; private UserController _userController = null!; public void Dispose() @@ -23,9 +26,12 @@ public void Dispose() [SetUp] public void Setup() { + _projectRepo = new ProjectRepositoryMock(); _userRepo = new UserRepositoryMock(); - _userController = new UserController( - _userRepo, new CaptchaServiceMock(), new PermissionServiceMock(_userRepo)); + _userEditRepo = new UserEditRepositoryMock(); + _userRoleRepo = new UserRoleRepositoryMock(); + _userController = new UserController(_projectRepo, _userRepo, _userEditRepo, _userRoleRepo, + new CaptchaServiceMock(), new PermissionServiceMock(_userRepo)); } private static User RandomUser() @@ -171,8 +177,7 @@ public void TestCreateUser() [Test] public void TestCreateUserBadUsername() { - var user = RandomUser(); - _userRepo.Create(user); + var user = _userRepo.Create(RandomUser()).Result ?? throw new UserCreationException(); var user2 = RandomUser(); user2.Username = " "; @@ -186,8 +191,7 @@ public void TestCreateUserBadUsername() [Test] public void TestCreateUserBadEmail() { - var user = RandomUser(); - _userRepo.Create(user); + var user = _userRepo.Create(RandomUser()).Result ?? throw new UserCreationException(); var user2 = RandomUser(); user2.Email = " "; @@ -259,15 +263,109 @@ public void TestDeleteUserNoPermission() Assert.That(result, Is.InstanceOf()); } + [Test] + public void TestDeleteUserWithRolesAndEdits() + { + // Create a user, project, user role, and user edit + var user = _userRepo.Create(RandomUser()).Result ?? throw new UserCreationException(); + var project = _projectRepo.Create(new() { Name = "Test Project" }).Result + ?? throw new ProjectCreationException(); + var userRole = _userRoleRepo.Create(new() { ProjectId = project.Id, Role = Role.Editor }).Result + ?? throw new UserRoleCreationException(); + var userEdit = _userEditRepo.Create(new() { ProjectId = project.Id }).Result + ?? throw new UserEditCreationException(); + + // Add role and edit to user + user.ProjectRoles[project.Id] = userRole.Id; + user.WorkedProjects[project.Id] = userEdit.Id; + _ = _userRepo.Update(user.Id, user).Result; + + // Verify they exist + Assert.That(_userRoleRepo.GetUserRole(project.Id, userRole.Id).Result, Is.Not.Null); + Assert.That(_userEditRepo.GetUserEdit(project.Id, userEdit.Id).Result, Is.Not.Null); + + // Delete the user + _ = _userController.DeleteUser(user.Id).Result; + + // Verify user is deleted + Assert.That(_userRepo.GetAllUsers().Result, Is.Empty); + + // Verify user role and edit are deleted + Assert.That(_userRoleRepo.GetUserRole(project.Id, userRole.Id).Result, Is.Null); + Assert.That(_userEditRepo.GetUserEdit(project.Id, userEdit.Id).Result, Is.Null); + } + + [Test] + public void TestDeleteAdminUser() + { + // Create an admin user + var user = _userRepo.Create(RandomUser()).Result ?? throw new UserCreationException(); + user.IsAdmin = true; + _ = _userRepo.Update(user.Id, user, updateIsAdmin: true).Result; + + // Try to delete admin user + var result = _userController.DeleteUser(user.Id).Result; + + // Should be forbidden + Assert.That(result, Is.InstanceOf()); + + // Verify user is not deleted + Assert.That(_userRepo.GetAllUsers().Result, Has.Count.EqualTo(1)); + } + + [Test] + public void TestGetUserProjects() + { + // Create a user and two projects + var user = _userRepo.Create(RandomUser()).Result ?? throw new UserCreationException(); + var project1 = _projectRepo.Create(new() { IsActive = false, Name = "Test Project 1" }).Result + ?? throw new ProjectCreationException(); + var project2 = _projectRepo.Create(new() { IsActive = true, Name = "Test Project 2" }).Result + ?? throw new ProjectCreationException(); + + // Create user roles for both projects + var userRole1 = _userRoleRepo.Create(new() { ProjectId = project1.Id, Role = Role.Editor }).Result + ?? throw new UserRoleCreationException(); + var userRole2 = _userRoleRepo.Create(new() { ProjectId = project2.Id, Role = Role.Administrator }).Result + ?? throw new UserRoleCreationException(); + + // Add roles to user + user.ProjectRoles[project1.Id] = userRole1.Id; + user.ProjectRoles[project2.Id] = userRole2.Id; + _ = _userRepo.Update(user.Id, user).Result; + + // Get user projects + var result = (ObjectResult)_userController.GetUserProjects(user.Id).Result; + var projects = result.Value as List; + + // Verify both projects are returned with correct roles + Assert.That(projects, Has.Count.EqualTo(2)); + Assert.That(projects!.Exists(p => p.ProjectId == project1.Id && p.ProjectIsActive == project1.IsActive + && p.ProjectName == project1.Name && p.Role == userRole1.Role)); + Assert.That(projects.Exists(p => p.ProjectId == project2.Id && p.ProjectIsActive == project2.IsActive + && p.ProjectName == project2.Name && p.Role == userRole2.Role)); + } + + [Test] + public void TestGetUserProjectsNoPermission() + { + _userController.ControllerContext.HttpContext = PermissionServiceMock.UnauthorizedHttpContext(); + var result = _userController.GetUserProjects("anything").Result; + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public void TestGetUserProjectsNoUser() + { + var result = _userController.GetUserProjects("not-a-user").Result; + Assert.That(result, Is.InstanceOf()); + } + [Test] public void TestIsEmailOrUsernameAvailable() { - var user1 = RandomUser(); - var user2 = RandomUser(); - var email1 = user1.Email; - var email2 = user2.Email; - _userRepo.Create(user1); - _userRepo.Create(user2); + var email1 = _userRepo.Create(RandomUser()).Result?.Email ?? throw new UserCreationException(); + var email2 = _userRepo.Create(RandomUser()).Result?.Email ?? throw new UserCreationException(); var result1 = (ObjectResult)_userController.IsEmailOrUsernameAvailable(email1.ToLowerInvariant()).Result; Assert.That(result1.Value, Is.False); diff --git a/Backend.Tests/Mocks/ProjectRepositoryMock.cs b/Backend.Tests/Mocks/ProjectRepositoryMock.cs index 7176dcb4e5..4eeac67198 100644 --- a/Backend.Tests/Mocks/ProjectRepositoryMock.cs +++ b/Backend.Tests/Mocks/ProjectRepositoryMock.cs @@ -97,4 +97,6 @@ public Task CanImportLift(string projectId) return Task.FromResult(project?.LiftImported != true); } } + + internal sealed class ProjectCreationException : Exception; } diff --git a/Backend.Tests/Mocks/UserEditRepositoryMock.cs b/Backend.Tests/Mocks/UserEditRepositoryMock.cs index ef1a98202f..fdc57fe656 100644 --- a/Backend.Tests/Mocks/UserEditRepositoryMock.cs +++ b/Backend.Tests/Mocks/UserEditRepositoryMock.cs @@ -61,4 +61,6 @@ public Task Replace(string projectId, string userEditId, UserEdit userEdit return Task.FromResult(rmCount > 0); } } + + internal sealed class UserEditCreationException : Exception; } diff --git a/Backend.Tests/Mocks/UserRoleRepositoryMock.cs b/Backend.Tests/Mocks/UserRoleRepositoryMock.cs index 653cf93145..52a5c8f1f0 100644 --- a/Backend.Tests/Mocks/UserRoleRepositoryMock.cs +++ b/Backend.Tests/Mocks/UserRoleRepositoryMock.cs @@ -68,4 +68,6 @@ public Task Update(string userRoleId, UserRole userRole) return Task.FromResult(ResultOfUpdate.Updated); } } + + internal sealed class UserRoleCreationException : Exception; } diff --git a/Backend/Controllers/BannerController.cs b/Backend/Controllers/BannerController.cs index ec8178aca0..f0560bf09e 100644 --- a/Backend/Controllers/BannerController.cs +++ b/Backend/Controllers/BannerController.cs @@ -42,6 +42,7 @@ public async Task GetBanner(BannerType type) /// /// Update the with same as the given . /// + /// Can only be used by a site admin. [HttpPut("", Name = "UpdateBanner")] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(bool))] [ProducesResponseType(StatusCodes.Status403Forbidden)] diff --git a/Backend/Controllers/ProjectController.cs b/Backend/Controllers/ProjectController.cs index d8169d0b74..221eaab6a1 100644 --- a/Backend/Controllers/ProjectController.cs +++ b/Backend/Controllers/ProjectController.cs @@ -34,6 +34,7 @@ public ProjectController(IProjectRepository projRepo, IUserRoleRepository userRo } /// Returns all s + /// Can only be used by a site admin. [HttpGet(Name = "GetAllProjects")] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List))] [ProducesResponseType(StatusCodes.Status403Forbidden)] diff --git a/Backend/Controllers/UserController.cs b/Backend/Controllers/UserController.cs index 1b962ecd7d..a57496ada1 100644 --- a/Backend/Controllers/UserController.cs +++ b/Backend/Controllers/UserController.cs @@ -15,10 +15,14 @@ namespace BackendFramework.Controllers [Authorize] [Produces("application/json")] [Route("v1/users")] - public class UserController( - IUserRepository userRepo, ICaptchaService captchaService, IPermissionService permissionService) : Controller + public class UserController(IProjectRepository projectRepo, IUserRepository userRepo, + IUserEditRepository userEditRepo, IUserRoleRepository userRoleRepo, ICaptchaService captchaService, + IPermissionService permissionService) : Controller { + private readonly IProjectRepository _projectRepo = projectRepo; private readonly IUserRepository _userRepo = userRepo; + private readonly IUserEditRepository _userEditRepo = userEditRepo; + private readonly IUserRoleRepository _userRoleRepo = userRoleRepo; private readonly ICaptchaService _captchaService = captchaService; private readonly IPermissionService _permissionService = permissionService; @@ -37,6 +41,7 @@ public async Task VerifyCaptchaToken(string token) } /// Returns all s + /// Can only be used by a site admin. [HttpGet(Name = "GetAllUsers")] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List))] [ProducesResponseType(StatusCodes.Status403Forbidden)] @@ -208,7 +213,51 @@ public async Task UpdateUser(string userId, [FromBody, BindRequir }; } + /// Gets project information for a user's roles. + /// Can only be used by a site admin. + [HttpGet("{userId}/projects", Name = "GetUserProjects")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List))] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetUserProjects(string userId) + { + using var activity = OtelService.StartActivityWithTag(otelTagName, "getting user projects"); + + if (!await _permissionService.IsSiteAdmin(HttpContext)) + { + return Forbid(); + } + + var user = await _userRepo.GetUser(userId, sanitize: false); + if (user is null) + { + return NotFound(); + } + + var userProjects = new List(); + + foreach (var (projectId, userRoleId) in user.ProjectRoles) + { + var project = await _projectRepo.GetProject(projectId); + var userRole = await _userRoleRepo.GetUserRole(projectId, userRoleId); + + if (project is not null && userRole is not null) + { + userProjects.Add(new UserProjectInfo + { + ProjectId = projectId, + ProjectIsActive = project.IsActive, + ProjectName = project.Name, + Role = userRole.Role + }); + } + } + + return Ok(userProjects); + } + /// Deletes with specified id. + /// Can only be used by a site admin. [HttpDelete("{userId}", Name = "DeleteUser")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status403Forbidden)] @@ -222,6 +271,28 @@ public async Task DeleteUser(string userId) return Forbid(); } + // Ensure user exists and is not an admin + var user = await _userRepo.GetUser(userId); + if (user is null) + { + return NotFound(); + } + if (user.IsAdmin) + { + return Forbid(); + } + + // Delete all UserEdits and UserRoles for this user + foreach (var (projectId, userEditId) in user.WorkedProjects) + { + await _userEditRepo.Delete(projectId, userEditId); + } + foreach (var (projectId, userRoleId) in user.ProjectRoles) + { + await _userRoleRepo.Delete(projectId, userRoleId); + } + + // Finally, delete the user return await _userRepo.Delete(userId) ? Ok() : NotFound(); } } diff --git a/Backend/Models/User.cs b/Backend/Models/User.cs index 114974de75..7525adc590 100644 --- a/Backend/Models/User.cs +++ b/Backend/Models/User.cs @@ -138,6 +138,22 @@ public class UserStub(User user) public string? RoleId { get; set; } } + /// Contains information about a user's role in a project. + public class UserProjectInfo + { + [Required] + public string ProjectId { get; set; } = ""; + + [Required] + public bool ProjectIsActive { get; set; } = true; + + [Required] + public string ProjectName { get; set; } = ""; + + [Required] + public Role Role { get; set; } = Role.None; + } + /// Contains email/username and password for authentication. /// /// This is used in a [FromBody] serializer, so its attributes cannot be set to readonly. diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 909612a9db..dec8041f3b 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -133,6 +133,10 @@ "userList": "Users", "deleteUser": { "confirm": "Confirm deleting user from The Combine database.", + "loadingProjects": "Loading user projects...", + "projectsTitle": "This user has roles in the following projects:", + "noProjects": "This user has no project roles.", + "projectsLoadError": "Failed to load user projects.", "toastSuccess": "User successfully deleted from The Combine.", "toastFailure": "Failed to delete user from The Combine." }, diff --git a/src/api/.openapi-generator/FILES b/src/api/.openapi-generator/FILES index 32f662cc95..d838240e16 100644 --- a/src/api/.openapi-generator/FILES +++ b/src/api/.openapi-generator/FILES @@ -63,6 +63,7 @@ models/status.ts models/user-created-project.ts models/user-edit-step-wrapper.ts models/user-edit.ts +models/user-project-info.ts models/user-role.ts models/user-stub.ts models/user.ts diff --git a/src/api/api/user-api.ts b/src/api/api/user-api.ts index 12a1142771..8911aeb6ee 100644 --- a/src/api/api/user-api.ts +++ b/src/api/api/user-api.ts @@ -41,6 +41,8 @@ import { Credentials } from "../models"; // @ts-ignore import { User } from "../models"; // @ts-ignore +import { UserProjectInfo } from "../models"; +// @ts-ignore import { UserStub } from "../models"; /** * UserApi - axios parameter creator @@ -356,6 +358,51 @@ export const UserApiAxiosParamCreator = function ( options: localVarRequestOptions, }; }, + /** + * + * @param {string} userId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getUserProjects: async ( + userId: string, + options: any = {} + ): Promise => { + // verify required parameter 'userId' is not null or undefined + assertParamExists("getUserProjects", "userId", userId); + const localVarPath = `/v1/users/{userId}/projects`.replace( + `{${"userId"}}`, + encodeURIComponent(String(userId)) + ); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { + method: "GET", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {string} filter @@ -715,6 +762,32 @@ export const UserApiFp = function (configuration?: Configuration) { configuration ); }, + /** + * + * @param {string} userId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getUserProjects( + userId: string, + options?: any + ): Promise< + ( + axios?: AxiosInstance, + basePath?: string + ) => AxiosPromise> + > { + const localVarAxiosArgs = await localVarAxiosParamCreator.getUserProjects( + userId, + options + ); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, /** * * @param {string} filter @@ -902,6 +975,20 @@ export const UserApiFactory = function ( .getUserIdByEmailOrUsername(body, options) .then((request) => request(axios, basePath)); }, + /** + * + * @param {string} userId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getUserProjects( + userId: string, + options?: any + ): AxiosPromise> { + return localVarFp + .getUserProjects(userId, options) + .then((request) => request(axios, basePath)); + }, /** * * @param {string} filter @@ -1026,6 +1113,20 @@ export interface UserApiGetUserIdByEmailOrUsernameRequest { readonly body: string; } +/** + * Request parameters for getUserProjects operation in UserApi. + * @export + * @interface UserApiGetUserProjectsRequest + */ +export interface UserApiGetUserProjectsRequest { + /** + * + * @type {string} + * @memberof UserApiGetUserProjects + */ + readonly userId: string; +} + /** * Request parameters for getUsersByFilter operation in UserApi. * @export @@ -1197,6 +1298,22 @@ export class UserApi extends BaseAPI { .then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {UserApiGetUserProjectsRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof UserApi + */ + public getUserProjects( + requestParameters: UserApiGetUserProjectsRequest, + options?: any + ) { + return UserApiFp(this.configuration) + .getUserProjects(requestParameters.userId, options) + .then((request) => request(this.axios, this.basePath)); + } + /** * * @param {UserApiGetUsersByFilterRequest} requestParameters Request parameters. diff --git a/src/api/models/index.ts b/src/api/models/index.ts index b1f0d32a20..343ad523ca 100644 --- a/src/api/models/index.ts +++ b/src/api/models/index.ts @@ -38,6 +38,7 @@ export * from "./user"; export * from "./user-created-project"; export * from "./user-edit"; export * from "./user-edit-step-wrapper"; +export * from "./user-project-info"; export * from "./user-role"; export * from "./user-stub"; export * from "./word"; diff --git a/src/api/models/user-project-info.ts b/src/api/models/user-project-info.ts new file mode 100644 index 0000000000..e486867f0b --- /dev/null +++ b/src/api/models/user-project-info.ts @@ -0,0 +1,47 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * BackendFramework + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { Role } from "./role"; + +/** + * + * @export + * @interface UserProjectInfo + */ +export interface UserProjectInfo { + /** + * + * @type {string} + * @memberof UserProjectInfo + */ + projectId: string; + /** + * + * @type {boolean} + * @memberof UserProjectInfo + */ + projectIsActive: boolean; + /** + * + * @type {string} + * @memberof UserProjectInfo + */ + projectName: string; + /** + * + * @type {Role} + * @memberof UserProjectInfo + */ + role: Role; +} diff --git a/src/backend/index.ts b/src/backend/index.ts index 5e2985c07a..20a226062b 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -22,6 +22,7 @@ import { Speaker, User, UserEdit, + UserProjectInfo, UserRole, UserStub, Word, @@ -766,6 +767,13 @@ export async function deleteUser(userId: string): Promise { await userApi.deleteUser({ userId }, defaultOptions()); } +/** Note: Only usable by site admins. */ +export async function getUserProjects( + userId: string +): Promise { + return (await userApi.getUserProjects({ userId }, defaultOptions())).data; +} + /** Checks whether email address is okay: unchanged or not taken by a different user. */ export async function isEmailOkay(email: string): Promise { const user = await getCurrentUser(); diff --git a/src/components/SiteSettings/UserManagement/ConfirmDeletion.tsx b/src/components/SiteSettings/UserManagement/ConfirmDeletion.tsx index 639e9657b4..e235e67f1a 100644 --- a/src/components/SiteSettings/UserManagement/ConfirmDeletion.tsx +++ b/src/components/SiteSettings/UserManagement/ConfirmDeletion.tsx @@ -1,8 +1,18 @@ -import { Box, Button, Stack, Typography } from "@mui/material"; -import { Fragment, ReactElement } from "react"; +import { Box, Button, List, ListItem, Stack, Typography } from "@mui/material"; +import { Fragment, ReactElement, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; +import { toast } from "react-toastify"; -import { User } from "api/models"; +import { User, UserProjectInfo } from "api/models"; +import { getUserProjects } from "backend"; + +function compareProjectInfo(a: UserProjectInfo, b: UserProjectInfo): number { + return a.projectIsActive && !b.projectIsActive + ? -1 + : !a.projectIsActive && b.projectIsActive + ? 1 + : a.projectName.localeCompare(b.projectName); +} interface ConfirmDeletionProps { user?: User; @@ -13,15 +23,30 @@ interface ConfirmDeletionProps { export default function ConfirmDeletion( props: ConfirmDeletionProps ): ReactElement { + const [projInfo, setProjInfo] = useState(); + const { t } = useTranslation(); + useEffect(() => { + setProjInfo(undefined); + if (props.user?.id) { + getUserProjects(props.user.id) + .then((pi) => setProjInfo(pi.sort(compareProjectInfo))) + .catch((err) => { + console.error("Failed to fetch user projects:", err); + toast.warning(t("siteSettings.deleteUser.projectsLoadError")); + }); + } + }, [props.user?.id, t]); + if (!props.user) { return ; } + return ( - + {props.user.username} @@ -29,18 +54,48 @@ export default function ConfirmDeletion( {t("siteSettings.deleteUser.confirm")} + {projInfo === undefined ? ( + + {t("siteSettings.deleteUser.loadingProjects")} + + ) : projInfo.length ? ( + <> + + {t("siteSettings.deleteUser.projectsTitle")} + + + {projInfo.map((info) => ( + + + • {info.projectName} ( + {t(`projectSettings.roles.${`${info.role}`.toLowerCase()}`)} + ) + + + ))} + + + ) : ( + + {t("siteSettings.deleteUser.noProjects")} + + )} + @@ -51,9 +106,7 @@ export default function ConfirmDeletion( onClick={() => props.handleCloseModal()} variant="contained" > - - {t("buttons.cancel")} - + {t("buttons.cancel")}