Skip to content
Draft
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
112 changes: 111 additions & 1 deletion Backend.Tests/Controllers/UserControllerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ namespace Backend.Tests.Controllers
internal sealed class UserControllerTests : IDisposable
{
private IUserRepository _userRepo = null!;
private IUserRoleRepository _userRoleRepo = null!;
private IUserEditRepository _userEditRepo = null!;
private IProjectRepository _projectRepo = null!;
private UserController _userController = null!;

public void Dispose()
Expand All @@ -24,8 +27,16 @@ public void Dispose()
public void Setup()
{
_userRepo = new UserRepositoryMock();
_userRoleRepo = new UserRoleRepositoryMock();
_userEditRepo = new UserEditRepositoryMock();
_projectRepo = new ProjectRepositoryMock();
_userController = new UserController(
_userRepo, new CaptchaServiceMock(), new PermissionServiceMock(_userRepo));
_userRepo,
new CaptchaServiceMock(),
new PermissionServiceMock(_userRepo),
_userRoleRepo,
_userEditRepo,
_projectRepo);
}

private static User RandomUser()
Expand Down Expand Up @@ -259,6 +270,105 @@ public void TestDeleteUserNoPermission()
Assert.That(result, Is.InstanceOf<ForbidResult>());
}

[Test]
public void TestDeleteUserWithRolesAndEdits()
{
// Create a user, project, user role, and user edit
var user = _userRepo.Create(RandomUser()).Result ?? throw new UserCreationException();
var project = new Project { Id = "proj1", Name = "Test Project" };
_ = _projectRepo.Create(project).Result;

var userRole = new UserRole { Id = "role1", ProjectId = project.Id, Role = Role.Editor };
_ = _userRoleRepo.Create(userRole).Result;

var userEdit = new UserEdit { Id = "edit1", ProjectId = project.Id };
_ = _userEditRepo.Create(userEdit).Result;

// 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<ForbidResult>());

// 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 = new Project { Id = "proj1", Name = "Test Project 1" };
var project2 = new Project { Id = "proj2", Name = "Test Project 2" };
_ = _projectRepo.Create(project1).Result;
_ = _projectRepo.Create(project2).Result;

// Create user roles for both projects
var userRole1 = new UserRole { Id = "role1", ProjectId = project1.Id, Role = Role.Editor };
var userRole2 = new UserRole { Id = "role2", ProjectId = project2.Id, Role = Role.Administrator };
_ = _userRoleRepo.Create(userRole1).Result;
_ = _userRoleRepo.Create(userRole2).Result;

// 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<UserProjectInfo>;

// Verify both projects are returned with correct roles
Assert.That(projects, Is.Not.Null);
Assert.That(projects, Has.Count.EqualTo(2));
Assert.That(projects!.Exists(p => p.ProjectId == project1.Id && p.ProjectName == "Test Project 1" && p.Role == Role.Editor));
Assert.That(projects.Exists(p => p.ProjectId == project2.Id && p.ProjectName == "Test Project 2" && p.Role == Role.Administrator));
}

[Test]
public void TestGetUserProjectsNoPermission()
{
_userController.ControllerContext.HttpContext = PermissionServiceMock.UnauthorizedHttpContext();
var result = _userController.GetUserProjects("anything").Result;
Assert.That(result, Is.InstanceOf<ForbidResult>());
}

[Test]
public void TestGetUserProjectsNoUser()
{
var result = _userController.GetUserProjects("not-a-user").Result;
Assert.That(result, Is.InstanceOf<NotFoundResult>());
}

[Test]
public void TestIsEmailOrUsernameAvailable()
{
Expand Down
77 changes: 76 additions & 1 deletion Backend/Controllers/UserController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,19 @@ namespace BackendFramework.Controllers
[Produces("application/json")]
[Route("v1/users")]
public class UserController(
IUserRepository userRepo, ICaptchaService captchaService, IPermissionService permissionService) : Controller
IUserRepository userRepo,
ICaptchaService captchaService,
IPermissionService permissionService,
IUserRoleRepository userRoleRepo,
IUserEditRepository userEditRepo,
IProjectRepository projectRepo) : Controller
{
private readonly IUserRepository _userRepo = userRepo;
private readonly ICaptchaService _captchaService = captchaService;
private readonly IPermissionService _permissionService = permissionService;
private readonly IUserRoleRepository _userRoleRepo = userRoleRepo;
private readonly IUserEditRepository _userEditRepo = userEditRepo;
private readonly IProjectRepository _projectRepo = projectRepo;

private const string otelTagName = "otel.UserController";

Expand Down Expand Up @@ -208,6 +216,47 @@ public async Task<IActionResult> UpdateUser(string userId, [FromBody, BindRequir
};
}

/// <summary> Gets project information for a user's roles. </summary>
[HttpGet("{userId}/projects", Name = "GetUserProjects")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<UserProjectInfo>))]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> 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<UserProjectInfo>();

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,
ProjectName = project.Name,
Role = userRole.Role
});
}
}

return Ok(userProjects);
}

/// <summary> Deletes <see cref="User"/> with specified id. </summary>
[HttpDelete("{userId}", Name = "DeleteUser")]
[ProducesResponseType(StatusCodes.Status200OK)]
Expand All @@ -222,6 +271,32 @@ public async Task<IActionResult> DeleteUser(string userId)
return Forbid();
}

// Get the user to check if they exist and if they're an admin
var user = await _userRepo.GetUser(userId, sanitize: false);
if (user is null)
{
return NotFound();
}

// Prevent deletion of admin users
if (user.IsAdmin)
{
return Forbid();
}

// Delete all UserRoles for this user
foreach (var (projectId, userRoleId) in user.ProjectRoles)
{
await _userRoleRepo.Delete(projectId, userRoleId);
}

// Delete all UserEdits for this user
foreach (var (projectId, userEditId) in user.WorkedProjects)
{
await _userEditRepo.Delete(projectId, userEditId);
}

// Finally, delete the user
return await _userRepo.Delete(userId) ? Ok() : NotFound();
}
}
Expand Down
13 changes: 13 additions & 0 deletions Backend/Models/User.cs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,19 @@ public class UserStub(User user)
public string? RoleId { get; set; }
}

/// <summary> Contains information about a user's role in a project. </summary>
public class UserProjectInfo
{
[Required]
public string ProjectId { get; set; } = "";

[Required]
public string ProjectName { get; set; } = "";

[Required]
public Role Role { get; set; } = Role.None;
}

/// <summary> Contains email/username and password for authentication. </summary>
/// <remarks>
/// This is used in a [FromBody] serializer, so its attributes cannot be set to readonly.
Expand Down
4 changes: 4 additions & 0 deletions public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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. User may still be deleted.",
"toastSuccess": "User successfully deleted from The Combine.",
"toastFailure": "Failed to delete user from The Combine."
},
Expand Down
17 changes: 17 additions & 0 deletions src/backend/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ import { FileWithSpeakerId } from "types/word";
import { Bcp47Code } from "types/writingSystem";
import { convertGoalToEdit } from "utilities/goalUtilities";

export interface UserProjectInfo {
projectId: string;
projectName: string;
role: Role;
}

export const baseURL = `${RuntimeConfig.getInstance().baseUrl()}`;
const apiBaseURL = `${baseURL}/v1`;
const config_parameters: Api.ConfigurationParameters = { basePath: baseURL };
Expand Down Expand Up @@ -767,6 +773,17 @@ export async function deleteUser(userId: string): Promise<void> {
await userApi.deleteUser({ userId }, defaultOptions());
}

/** Note: Only usable by site admins. */
export async function getUserProjects(
userId: string
): Promise<UserProjectInfo[]> {
const response = await axiosInstance.get<UserProjectInfo[]>(
`/users/${userId}/projects`,
defaultOptions()
);
return response.data;
}

/** Checks whether email address is okay: unchanged or not taken by a different user. */
export async function isEmailOkay(email: string): Promise<boolean> {
const user = await getCurrentUser();
Expand Down
58 changes: 57 additions & 1 deletion src/components/SiteSettings/UserManagement/ConfirmDeletion.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Box, Button, Stack, Typography } from "@mui/material";
import { Fragment, ReactElement } from "react";
import { Fragment, ReactElement, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";

import { User } from "api/models";
import { getUserProjects, UserProjectInfo } from "backend";

interface ConfirmDeletionProps {
user?: User;
Expand All @@ -14,10 +15,36 @@ export default function ConfirmDeletion(
props: ConfirmDeletionProps
): ReactElement {
const { t } = useTranslation();
const [userProjects, setUserProjects] = useState<UserProjectInfo[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
if (props.user) {
setLoading(true);
setError(null);
getUserProjects(props.user.id)
.then((projects) => {
setUserProjects(projects);
})
.catch((err) => {
console.error("Failed to fetch user projects:", err);
setError(t("siteSettings.deleteUser.projectsLoadError"));
setUserProjects([]);
})
.finally(() => {
setLoading(false);
});
} else {
setUserProjects([]);
setError(null);
}
}, [props.user, t]);

if (!props.user) {
return <Fragment />;
}

return (
<Box sx={{ maxWidth: 500 }}>
<Stack spacing={2}>
Expand All @@ -29,6 +56,35 @@ export default function ConfirmDeletion(
{t("siteSettings.deleteUser.confirm")}
</Typography>

{loading ? (
<Typography align="center">
{t("siteSettings.deleteUser.loadingProjects")}
</Typography>
) : error ? (
<Typography align="center" sx={{ color: "warning.main" }}>
{error}
</Typography>
) : userProjects.length > 0 ? (
<>
<Typography align="center" variant="subtitle1">
{t("siteSettings.deleteUser.projectsTitle")}
</Typography>
<Box sx={{ maxHeight: 200, overflowY: "auto" }}>
<Stack spacing={1}>
{userProjects.map((project) => (
<Typography key={project.projectId} variant="body2">
• {project.projectName} ({project.role})
</Typography>
))}
</Stack>
</Box>
</>
) : (
<Typography align="center" variant="body2">
{t("siteSettings.deleteUser.noProjects")}
</Typography>
)}

<Stack direction="row" justifyContent="space-evenly">
<Button
color="secondary"
Expand Down
Loading