diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f8edfc4..1bd7b8f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Added separate administration area and user navigation menu to switch between delivery, administration and STAC browser. - Added grid to manage mandates in administration area. - Added grid to manage organisations in administration area. +- Added grid to manage users in administration area. ### Changed diff --git a/src/Geopilot.Api/Context.cs b/src/Geopilot.Api/Context.cs index 89130689..68315d66 100644 --- a/src/Geopilot.Api/Context.cs +++ b/src/Geopilot.Api/Context.cs @@ -23,6 +23,19 @@ public Context(DbContextOptions options) /// public DbSet Users { get; set; } + /// + /// Gets the entity with all includes. + /// + public IQueryable UsersWithIncludes + { + get + { + return Users + .Include(u => u.Organisations) + .Include(u => u.Deliveries); + } + } + /// /// Set of all . /// diff --git a/src/Geopilot.Api/Controllers/UserController.cs b/src/Geopilot.Api/Controllers/UserController.cs index 91cc5744..c8b413ba 100644 --- a/src/Geopilot.Api/Controllers/UserController.cs +++ b/src/Geopilot.Api/Controllers/UserController.cs @@ -43,8 +43,7 @@ public List Get() { logger.LogInformation("Getting users."); - return context.Users - .Include(u => u.Organisations) + return context.UsersWithIncludes .AsNoTracking() .ToList(); } @@ -71,6 +70,25 @@ public List Get() return user; } + /// + /// Get a user with the specified . + /// + [HttpGet("{id}")] + [Authorize(Policy = GeopilotPolicies.Admin)] + [SwaggerResponse(StatusCodes.Status200OK, "Returns the user with the specified id.", typeof(User), new[] { "application/json" })] + [SwaggerResponse(StatusCodes.Status404NotFound, "The user could not be found.")] + public async Task GetById(int id) + { + var user = await context.UsersWithIncludes + .AsNoTracking() + .SingleOrDefaultAsync(u => u.Id == id); + + if (user == default) + return NotFound(); + + return Ok(user); + } + /// /// Gets the specified auth options. /// @@ -83,4 +101,54 @@ public BrowserAuthOptions GetAuthOptions() logger.LogInformation("Getting auth options."); return authOptions; } + + /// + /// Asynchronously updates the specified. + /// + /// The user to update. + [HttpPut] + [Authorize(Policy = GeopilotPolicies.Admin)] + [SwaggerResponse(StatusCodes.Status200OK, "Returns the updated user.", typeof(User), new[] { "application/json" })] + [SwaggerResponse(StatusCodes.Status404NotFound, "The user could not be found.")] + [SwaggerResponse(StatusCodes.Status400BadRequest, "The user could not be updated due to invalid input.")] + [SwaggerResponse(StatusCodes.Status401Unauthorized, "The current user is not authorized to update the user.")] + [SwaggerResponse(StatusCodes.Status500InternalServerError, "The user could not be updated due to an internal server error.", typeof(ProblemDetails), new[] { "application/json" })] + public async Task Edit(User user) + { + try + { + if (user == null) + return BadRequest(); + + var existingUser = await context.UsersWithIncludes.SingleOrDefaultAsync(u => u.Id == user.Id); + + if (existingUser == null) + return NotFound(); + + existingUser.IsAdmin = user.IsAdmin; + + var organisationIds = user.Organisations.Select(o => o.Id).ToList(); + var organisations = await context.Organisations + .Where(o => organisationIds.Contains(o.Id)) + .ToListAsync(); + existingUser.Organisations.Clear(); + foreach (var organisation in organisations) + { + existingUser.Organisations.Add(organisation); + } + + await context.SaveChangesAsync().ConfigureAwait(false); + + var result = await context.UsersWithIncludes + .AsNoTracking() + .FirstOrDefaultAsync(u => u.Id == user.Id); + + return Ok(result); + } + catch (Exception e) + { + logger.LogError(e, "An error occurred while updating the user."); + return Problem(e.Message); + } + } } diff --git a/src/Geopilot.Frontend/public/locale/de/common.json b/src/Geopilot.Frontend/public/locale/de/common.json index 38f2db27..da307be9 100644 --- a/src/Geopilot.Frontend/public/locale/de/common.json +++ b/src/Geopilot.Frontend/public/locale/de/common.json @@ -35,6 +35,7 @@ "dropZoneErrorNotSupported": "Der Dateityp wird nicht unterstützt. {{genericError}}", "dropZoneErrorTooManyFiles": "Es kann nur eine Datei aufs Mal geprüft werden.", "edit": "Bearbeiten", + "email": "E-Mail", "errors": "Fehler", "file": "Datei", "fileTypes": "Formate", @@ -43,6 +44,7 @@ "id": "ID", "impressum": "Impressum", "info": "Info", + "isAdmin": "Ist Admin", "latitude": "Breite", "licenses": "Lizenzen", "licenseInformation": "Lizenzinformationen", @@ -77,6 +79,9 @@ "type": "Typ", "uploadFile": "{{fileName}} hochladen...", "uploadNotSuccessful": "Der Upload war nicht erfolgreich. Die Validierung wurde abgebrochen.", + "userDisconnectMessage": "Der Benutzer wird von allen Organisationen getrennt. Diese Aktion kann nicht rückgängig gemacht werden.", + "userDisconnectTitle": "Möchten Sie den Benutzer wirklich inaktiv setzen?", + "userSaveError": "Beim Speichern des Benutzers ist ein Fehler aufgetreten: {{error}}", "users": "Benutzer:innen", "usersLoadingError": "Beim Laden der Benutzer:innen ist ein Fehler aufgetreten: {{error}}", "validate": "Validieren", diff --git a/src/Geopilot.Frontend/public/locale/en/common.json b/src/Geopilot.Frontend/public/locale/en/common.json index e10ffc25..67c8b004 100644 --- a/src/Geopilot.Frontend/public/locale/en/common.json +++ b/src/Geopilot.Frontend/public/locale/en/common.json @@ -35,6 +35,7 @@ "dropZoneErrorNotSupported": "The file type is not supported. {{genericError}}", "dropZoneErrorTooManyFiles": "Only one file can be checked at a time.", "edit": "Edit", + "email": "Email", "errors": "Errors", "file": "File", "fileTypes": "Formats", @@ -43,6 +44,7 @@ "id": "ID", "impressum": "Imprint", "info": "Info", + "isAdmin": "Is Admin", "latitude": "Latitude", "licenses": "Licenses", "licenseInformation": "License Information", @@ -77,6 +79,9 @@ "type": "Type", "uploadFile": "Upload {{fileName}}...", "uploadNotSuccessful": "The upload was not successful. Validation has been aborted.", + "userDisconnectMessage": "This will remove all connections to organisations and cannot be undone.", + "userDisconnectTitle": "Do you really want to disconnect the user?", + "userSaveError": "An error occurred while saving the user: {{error}}", "users": "Users", "usersLoadingError": "An error occurred while loading the users: {{error}}", "validate": "Validate", diff --git a/src/Geopilot.Frontend/public/locale/fr/common.json b/src/Geopilot.Frontend/public/locale/fr/common.json index 0645a786..341102b7 100644 --- a/src/Geopilot.Frontend/public/locale/fr/common.json +++ b/src/Geopilot.Frontend/public/locale/fr/common.json @@ -35,6 +35,7 @@ "dropZoneErrorNotSupported": "Le type de fichier n'est pas pris en charge. {{genericError}}", "dropZoneErrorTooManyFiles": "Seul un fichier peut être vérifié à la fois.", "edit": "Éditer", + "email": "E-mail", "errors": "Erreurs", "file": "Fichier", "fileTypes": "Formats", @@ -43,6 +44,7 @@ "id": "ID", "impressum": "Mentions légales", "info": "Info", + "isAdmin": "Est administrateur", "latitude": "Latitude", "licenses": "Licences", "licenseInformation": "Informations sur la licence", @@ -52,14 +54,14 @@ "logOut": "Se déconnecter", "longitude": "Longitude", "mandate": "Mandat", - "mandateDisconnectMessage": "Cette opération supprime toutes les connexions avec les organisations et ne peut être annulée.", + "mandateDisconnectMessage": "Cette opération supprime toutes les connexions avec les organisations. Cette action ne peut pas être annulée.", "mandateDisconnectTitle": "Voulez-vous vraiment déconnecter le mandat?", "mandateSaveError": "Une erreur s'est produite lors de l'enregistrement du mandat: {{error}}", "mandates": "Mandats", "mandatesLoadingError": "Une erreur s'est produite lors du chargement des mandats: {{error}}", "name": "Nom", "noErrors": "Pas d'erreurs", - "organisationDisconnectMessage": "Cette action supprime toutes les connexions avec les mandats et les utilisateurs, et ne peut être annulée.", + "organisationDisconnectMessage": "Cette action supprime toutes les connexions avec les mandats et les utilisateurs. Cette action ne peut pas être annulée.", "organisationDisconnectTitle": "Voulez-vous vraiment déconnecter l'organisation?", "organisationSaveError": "Une erreur s'est produite lors de l'enregistrement de l'organisation: {{error}}", "organisations": "Organisations", @@ -77,6 +79,9 @@ "type": "Type", "uploadFile": "Télécharger {{fileName}}...", "uploadNotSuccessful": "Le téléchargement n'a pas réussi. La validation a été annulée.", + "userDisconnectMessage": "Cet utilisateur sera déconnecté de toutes les organisations. Cette action ne peut pas être annulée.", + "userDisconnectTitle": "Voulez-vous vraiment déconnecter l'utilisateur?", + "userSaveError": "Une erreur s'est produite lors de l'enregistrement de l'utilisateur: {{error}}", "users": "Utilisateurs", "usersLoadingError": "Une erreur s'est produite lors du chargement des utilisateurs: {{error}}", "validate": "Valider", diff --git a/src/Geopilot.Frontend/public/locale/it/common.json b/src/Geopilot.Frontend/public/locale/it/common.json index 00124dcd..3672332d 100644 --- a/src/Geopilot.Frontend/public/locale/it/common.json +++ b/src/Geopilot.Frontend/public/locale/it/common.json @@ -35,6 +35,7 @@ "dropZoneErrorNotSupported": "Tipo di file non supportato. {{genericError}}", "dropZoneErrorTooManyFiles": "Puoi verificare solo un file alla volta.", "edit": "Modifica", + "email": "E-mail", "errors": "Errori", "file": "File", "fileTypes": "Formati", @@ -43,6 +44,7 @@ "id": "ID", "impressum": "Impressum", "info": "Informazioni", + "isAdmin": "È amministratore", "latitude": "Latitudine", "licenses": "Licenze", "licenseInformation": "Informazioni sulla licenza", @@ -77,6 +79,9 @@ "type": "Tipo", "uploadFile": "Carica {{fileName}}...", "uploadNotSuccessful": "Caricamento non riuscito. La validazione è stata interrotta.", + "userDisconnectMessage": "Questa operazione rimuove tutti i collegamenti alle organizzazioni e non può essere annullata.", + "userDisconnectTitle": "Volete davvero scollegare l'utente?", + "userSaveError": "Si è verificato un errore durante il salvataggio dell'utente: {{error}}", "users": "Utenti", "usersLoadingError": "Si è verificato un errore durante il caricamento degli utenti: {{error}}", "validate": "Valida", diff --git a/src/Geopilot.Frontend/src/AppInterfaces.ts b/src/Geopilot.Frontend/src/AppInterfaces.ts index 076f8721..e88e28cb 100644 --- a/src/Geopilot.Frontend/src/AppInterfaces.ts +++ b/src/Geopilot.Frontend/src/AppInterfaces.ts @@ -68,6 +68,6 @@ export interface User { fullName: string; isAdmin: boolean; email: string; - organisations: Organisation[]; + organisations: Organisation[] | number[]; deliveries: Delivery[]; } diff --git a/src/Geopilot.Frontend/src/components/adminGrid/AdminGrid.tsx b/src/Geopilot.Frontend/src/components/adminGrid/AdminGrid.tsx index a6d70de2..48900fe1 100644 --- a/src/Geopilot.Frontend/src/components/adminGrid/AdminGrid.tsx +++ b/src/Geopilot.Frontend/src/components/adminGrid/AdminGrid.tsx @@ -24,7 +24,7 @@ import { } from "../dataGrid/DataGridMultiSelectColumn.tsx"; import { IsGridSpatialExtentColDef, TransformToSpatialExtentColumn } from "../dataGrid/DataGridSpatialExtentColumn.tsx"; -export const AdminGrid: FC = ({ addLabel, data, columns, onSave, onDisconnect }) => { +export const AdminGrid: FC = ({ addLabel, data, columns, onSave, onDisconnect, disableRow }) => { const { t } = useTranslation(); const [rows, setRows] = useState([]); const [rowModesModel, setRowModesModel] = useState({}); @@ -51,6 +51,10 @@ export const AdminGrid: FC = ({ addLabel, data, columns, onSave, resizable: false, cellClassName: "actions", getActions: ({ id }) => { + if (id === disableRow) { + return []; + } + const isInEditMode = rowModesModel[id]?.mode === GridRowModes.Edit; if (isInEditMode) { diff --git a/src/Geopilot.Frontend/src/components/adminGrid/AdminGridInterfaces.ts b/src/Geopilot.Frontend/src/components/adminGrid/AdminGridInterfaces.ts index 233cb38e..fa9e84bb 100644 --- a/src/Geopilot.Frontend/src/components/adminGrid/AdminGridInterfaces.ts +++ b/src/Geopilot.Frontend/src/components/adminGrid/AdminGridInterfaces.ts @@ -8,6 +8,7 @@ export interface AdminGridProps { columns: GridColDef[]; onSave: (row: DataRow) => void | Promise; onDisconnect: (row: DataRow) => void | Promise; + disableRow?: number; } export interface DataRow { diff --git a/src/Geopilot.Frontend/src/pages/admin/Users.tsx b/src/Geopilot.Frontend/src/pages/admin/Users.tsx index 40548175..635df9b5 100644 --- a/src/Geopilot.Frontend/src/pages/admin/Users.tsx +++ b/src/Geopilot.Frontend/src/pages/admin/Users.tsx @@ -1,9 +1,161 @@ import { useTranslation } from "react-i18next"; +import { CircularProgress, Stack } from "@mui/material"; +import { AdminGrid } from "../../components/adminGrid/AdminGrid.tsx"; +import { DataRow, GridColDef } from "../../components/adminGrid/AdminGridInterfaces.ts"; +import { ErrorResponse, Organisation, User } from "../../AppInterfaces.ts"; +import { useContext, useEffect, useState } from "react"; +import { useAuth } from "../../auth"; +import { AlertContext } from "../../components/alert/AlertContext.tsx"; +import { PromptContext } from "../../components/prompt/PromptContext.tsx"; export const Users = () => { const { t } = useTranslation(); + const { user } = useAuth(); + const [users, setUsers] = useState(); + const [organisations, setOrganisations] = useState(); + const [isLoading, setIsLoading] = useState(true); + const { showAlert } = useContext(AlertContext); + const { showPrompt } = useContext(PromptContext); - return <>{t("users")}; + useEffect(() => { + if (users && organisations) { + setIsLoading(false); + } + }, [users, organisations]); + + async function loadUsers() { + try { + const response = await fetch("/api/v1/user"); + if (response.ok) { + const results = await response.json(); + setUsers(results); + } else { + const errorResponse: ErrorResponse = await response.json(); + showAlert(t("usersLoadingError", { error: errorResponse.detail }), "error"); + } + } catch (error) { + showAlert(t("usersLoadingError", { error: error }), "error"); + } + } + + async function loadOrganisations() { + try { + const response = await fetch("/api/v1/organisation"); + if (response.ok) { + const results = await response.json(); + setOrganisations(results); + } else { + const errorResponse: ErrorResponse = await response.json(); + showAlert(t("organisationsLoadingError", { error: errorResponse.detail }), "error"); + } + } catch (error) { + showAlert(t("organisationsLoadingError", { error: error }), "error"); + } + } + + async function saveUser(user: User) { + try { + user.organisations = user.organisations?.map(organisationId => { + return { id: organisationId as number } as Organisation; + }); + const response = await fetch("/api/v1/user", { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(user), + }); + + if (response.ok) { + loadUsers(); + } else { + const errorResponse: ErrorResponse = await response.json(); + showAlert(t("userSaveError", { error: errorResponse.detail }), "error"); + } + } catch (error) { + showAlert(t("userSaveError", { error: error }), "error"); + } + } + + async function onSave(row: DataRow) { + await saveUser(row as User); + } + + async function onDisconnect(row: DataRow) { + showPrompt(t("userDisconnectTitle"), t("userDisconnectMessage"), [ + { label: t("cancel") }, + { + label: t("disconnect"), + action: () => { + const user = row as unknown as User; + user.organisations = []; + saveUser(user); + }, + color: "error", + variant: "contained", + }, + ]); + } + + useEffect(() => { + if (user?.isAdmin) { + if (users === undefined) { + loadUsers(); + } + if (organisations === undefined) { + loadOrganisations(); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const columns: GridColDef[] = [ + { + field: "fullName", + headerName: t("name"), + type: "string", + editable: false, + flex: 1, + }, + { + field: "email", + headerName: t("email"), + type: "string", + editable: false, + flex: 1, + }, + { + field: "isAdmin", + headerName: t("isAdmin"), + editable: true, + flex: 1, + type: "boolean", + }, + { + field: "organisations", + headerName: t("organisations"), + editable: true, + flex: 1, + type: "custom", + valueOptions: organisations, + getOptionLabel: (value: DataRow | string) => (value as Organisation).name, + getOptionValue: (value: DataRow | string) => (value as Organisation).id, + }, + ]; + + return isLoading ? ( + + + + ) : ( + + ); }; export default Users; diff --git a/tests/Geopilot.Api.Test/Controllers/UserControllerTest.cs b/tests/Geopilot.Api.Test/Controllers/UserControllerTest.cs index 46f52308..a2779bf8 100644 --- a/tests/Geopilot.Api.Test/Controllers/UserControllerTest.cs +++ b/tests/Geopilot.Api.Test/Controllers/UserControllerTest.cs @@ -1,7 +1,7 @@ -using Castle.Core.Logging; -using Geopilot.Api.Contracts; +using Geopilot.Api.Contracts; using Geopilot.Api.Models; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Moq; @@ -44,7 +44,7 @@ public void Cleanup() } [TestMethod] - public async Task GetUserAsync() + public async Task GetCurrentUserAsync() { var authIdentifier = Guid.NewGuid().ToString(); const string fullName = "Full Name"; @@ -76,7 +76,7 @@ public async Task GetUserAsync() } [TestMethod] - public async Task GetUserAsyncNotFound() + public async Task GetCurrentUserAsyncNotFound() { var user = new User { @@ -92,7 +92,7 @@ public async Task GetUserAsyncNotFound() } [TestMethod] - public async Task GetUserAsyncMissingClaims() + public async Task GetCurrentUserAsyncMissingClaims() { var authIdentifier = Guid.NewGuid().ToString(); @@ -118,6 +118,37 @@ public async Task GetUserAsyncMissingClaims() httpContextMock.VerifyAll(); } + [TestMethod] + public async Task GetUserById() + { + var testUser = new User + { + AuthIdentifier = Guid.NewGuid().ToString(), + FullName = "AGILITY MOSES", + Email = "agility@moses.com", + IsAdmin = true, + }; + context.Users.Add(testUser); + context.SaveChanges(); + + var userResult = await userController.GetById(testUser.Id); + ActionResultAssert.IsOk(userResult); + var user = (userResult as OkObjectResult)?.Value as User; + Assert.IsNotNull(user); + Assert.AreEqual(testUser.AuthIdentifier, user.AuthIdentifier); + Assert.AreEqual(testUser.FullName, user.FullName); + Assert.AreEqual(testUser.Email, user.Email); + Assert.AreEqual(testUser.IsAdmin, user.IsAdmin); + } + + [TestMethod] + public async Task GetUserByIdNotFound() + { + var userResult = await userController.GetById(0); + ActionResultAssert.IsNotFound(userResult); + Assert.IsNotNull(userResult); + } + [TestMethod] public void GetUsers() { @@ -153,4 +184,55 @@ public void GetAuthOptions() Assert.AreEqual(browserAuthOptions.PostLogoutRedirectUri, authOptions.PostLogoutRedirectUri); Assert.AreEqual(browserAuthOptions.NavigateToLoginRequestUrl, authOptions.NavigateToLoginRequestUrl); } + + [TestMethod] + public async Task EditUser() + { + var testUser = new User + { + AuthIdentifier = Guid.NewGuid().ToString(), + FullName = "FLEA XI", + Email = "flea@xi.com", + IsAdmin = false, + }; + context.Users.Add(testUser); + context.SaveChanges(); + + var userResult = await userController.GetById(testUser.Id) as OkObjectResult; + var user = userResult?.Value as User; + Assert.IsNotNull(user); + user.FullName = "FLEA XI Updated"; + user.IsAdmin = true; + user.Organisations = new List { new () { Id = 1 }, new () { Id = 2 } }; + + var result = await userController.Edit(user); + ActionResultAssert.IsOk(result); + var resultValue = (result as OkObjectResult)?.Value as User; + Assert.IsNotNull(resultValue); + Assert.AreEqual("FLEA XI", resultValue.FullName); + Assert.IsTrue(resultValue.IsAdmin); + Assert.AreEqual(2, resultValue.Organisations.Count); + for (var i = 0; i < 2; i++) + { + Assert.AreEqual(testUser.Organisations[i].Id, resultValue.Organisations[i].Id); + } + + testUser.Organisations = new List { new () { Id = 2 }, new () { Id = 3 } }; + result = await userController.Edit(testUser); + ActionResultAssert.IsOk(result); + resultValue = (result as OkObjectResult)?.Value as User; + Assert.IsNotNull(resultValue); + Assert.AreEqual(2, resultValue.Organisations.Count); + for (var i = 0; i < 2; i++) + { + Assert.AreEqual(testUser.Organisations[i].Id, resultValue.Organisations[i].Id); + } + } + + [TestMethod] + public async Task EditUserNotFound() + { + var result = await userController.Edit(new User { Id = 0 }); + ActionResultAssert.IsNotFound(result); + } }