Merge pull request #168 from LD-Reborn/156-feature-add-users-front-end-crud

156 feature add users front end crud
This commit is contained in:
LD50
2025-10-26 00:08:51 +02:00
committed by GitHub
9 changed files with 952 additions and 102 deletions

View File

@@ -89,7 +89,7 @@ public class HomeController : Controller
Surname = user.Sn ?? "", Surname = user.Sn ?? "",
Title = user.Title ?? "", Title = user.Title ?? "",
Uid = user.Uid, Uid = user.Uid,
Workplace = user.Description?.Workplace ?? "" Description = user.Description ?? new() {Address = new(), BirthDate = "", Workplace = "", Groups = []}
}); });
} }
return View(new UsersIndexViewModel() { UserTableViewModels = UserTableViewModels }); return View(new UsersIndexViewModel() { UserTableViewModels = UserTableViewModels });

View File

@@ -1,4 +1,3 @@
// Controllers/UsersController.cs
using Berufsschule_HAM.Services; using Berufsschule_HAM.Services;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Novell.Directory.Ldap; using Novell.Directory.Ldap;
@@ -8,6 +7,7 @@ using System.Security.Cryptography;
using System.Text; using System.Text;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using System.Text.Json; using System.Text.Json;
using System.Buffers.Text;
[Authorize(Roles = "CanManageUsers")] [Authorize(Roles = "CanManageUsers")]
[Route("[controller]")] [Route("[controller]")]
@@ -60,15 +60,19 @@ public class UsersController : Controller
} }
} }
[HttpGet("Create")] [HttpPost("Create")]
public async Task<bool> Create(string cn, string sn, string? title, string? uid, string userPassword, string? description, string jpegPhoto) public async Task<UsersCreateResponseModel> Create([FromBody] UsersCreateRequestModel requestModel)
{ {
try try
{ {
string? jpegPhoto = requestModel.JpegPhoto;
string? title = requestModel.Title;
string userPassword = requestModel.UserPassword ?? "";
UserDescription? description = requestModel.Description;
jpegPhoto ??= Convert.ToBase64String(System.IO.File.ReadAllBytes("wwwroot/user_default.jpeg")); // TODO: cleanup - make this a config setting jpegPhoto ??= Convert.ToBase64String(System.IO.File.ReadAllBytes("wwwroot/user_default.jpeg")); // TODO: cleanup - make this a config setting
uid ??= sn.ToLower() + cn.ToLower(); string uid = UsersHelper.CreateUsername(requestModel.Cn ?? "", requestModel.Sn ?? "");
title ??= ""; title ??= "";
description ??= "{}"; description ??= new() {Address = new(), BirthDate = "", Workplace = "", Groups = []};
if (!userPassword.StartsWith('{')) if (!userPassword.StartsWith('{'))
{ {
byte[] passwordBytes = Encoding.UTF8.GetBytes(userPassword); byte[] passwordBytes = Encoding.UTF8.GetBytes(userPassword);
@@ -79,72 +83,86 @@ public class UsersController : Controller
LdapAttributeSet attributeSet = LdapAttributeSet attributeSet =
[ [
new LdapAttribute("objectClass", "inetOrgPerson"), new LdapAttribute("objectClass", "inetOrgPerson"),
new LdapAttribute("cn", cn), new LdapAttribute("cn", requestModel.Cn),
new LdapAttribute("sn", sn), new LdapAttribute("sn", requestModel.Sn),
new LdapAttribute("title", title), new LdapAttribute("title", title),
new LdapAttribute("uid", uid), new LdapAttribute("uid", uid),
new LdapAttribute("jpegPhoto", jpegPhoto), new LdapAttribute("jpegPhoto", jpegPhoto),
new LdapAttribute("description", description), new LdapAttribute("description", JsonSerializer.Serialize(description)),
new LdapAttribute("userPassword", userPassword), new LdapAttribute("userPassword", userPassword),
]; ];
await _ldap.CreateUser(uid, attributeSet); await _ldap.CreateUser(uid, attributeSet);
return true; return new(){Success = true, Uid = uid};
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError("Unable to create user: {ex.Message} - {ex.StackTrace}", [ex.Message, ex.StackTrace]); _logger.LogError("Unable to create user: {ex.Message} - {ex.StackTrace}", [ex.Message, ex.StackTrace]);
return false; return new() {Success = false, Exception = ex.Message};
} }
} }
[HttpPost("Update")] [HttpPost("Update")]
public async Task<bool> Update([FromBody] UsersModifyRequestModel requestModel) public async Task<UsersUpdateResponseModel> Update([FromBody] UsersModifyRequestModel requestModel)
{ {
if (requestModel is null) if (requestModel is null)
{ {
_logger.LogError("Unable to update a user because the UsersModifyRequestModel is null"); _logger.LogError("Unable to update a user because the UsersModifyRequestModel is null");
return false; return new() { Success = false };
} }
string uid = requestModel.uid; try
UserModel? user = null;
if (requestModel.Cn is not null)
{ {
await _ldap.UpdateUser(uid, "cn", requestModel.Cn); string uid = requestModel.Uid;
user ??= await _ldap.GetUserByUidAsync(uid); UserModel? user = null;
string newUid = user.Sn?.ToLower() + requestModel.Cn.ToLower(); if (requestModel.NewUid is not null && requestModel.NewUid.Length > 0)
await _ldap.UpdateUser(uid, "uid", newUid); {
uid = newUid; await _ldap.UpdateUser(uid, "uid", requestModel.NewUid);
} uid = requestModel.NewUid;
if (requestModel.Sn is not null) }
if (requestModel.Title is not null)
{
await _ldap.UpdateUser(uid, "title", requestModel.Title);
}
if (requestModel.Description is not null)
{
await _ldap.UpdateUser(uid, "description", JsonSerializer.Serialize(requestModel.Description));
}
if (requestModel.JpegPhoto is not null && requestModel.JpegPhoto.Length > 0)
{
await _ldap.UpdateUser(uid, "jpegPhoto", requestModel.JpegPhoto);
}
if (requestModel.UserPassword is not null && requestModel.UserPassword.Length > 0)
{
await _ldap.UpdateUser(uid, "userPassword", "{SHA256}" + Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(requestModel.UserPassword))));
}
string newUid = uid;
if (requestModel.Cn is not null)
{
await _ldap.UpdateUser(uid, "cn", requestModel.Cn);
user ??= await _ldap.GetUserByUidAsync(uid);
newUid = UsersHelper.CreateUsername(requestModel.Cn, user.Sn ?? "");
}
if (requestModel.Sn is not null)
{
await _ldap.UpdateUser(uid, "sn", requestModel.Sn);
user ??= await _ldap.GetUserByUidAsync(uid);
newUid = UsersHelper.CreateUsername(user.Cn ?? "", requestModel.Sn);
}
if (newUid.Length == 0)
{
throw new Exception("Username cannot be empty");
}
if (newUid != uid)
{
await _ldap.UpdateUser(uid, "uid", newUid);
uid = newUid;
}
return new() { Success = true, NewUid = uid };
} catch (Exception ex)
{ {
await _ldap.UpdateUser(uid, "sn", requestModel.Sn); return new() { Success = false, Exception = ex.Message };
user ??= await _ldap.GetUserByUidAsync(uid);
string newUid = requestModel.Sn.ToLower() + user.Cn?.ToLower();
await _ldap.UpdateUser(uid, "uid", newUid);
uid = newUid;
} }
if (requestModel.NewUid is not null)
{
await _ldap.UpdateUser(uid, "uid", requestModel.NewUid);
uid = requestModel.NewUid;
}
if (requestModel.Title is not null)
{
await _ldap.UpdateUser(uid, "title", requestModel.Title);
}
if (requestModel.Description is not null)
{
await _ldap.UpdateUser(uid, "description", requestModel.Description);
}
if (requestModel.JpegPhoto is not null)
{
await _ldap.UpdateUser(uid, "jpegPhoto", requestModel.JpegPhoto);
}
if (requestModel.UserPassword is not null)
{
await _ldap.UpdateUser(uid, "userPassword", requestModel.UserPassword);
}
return true;
} }
[HttpPost("AddGroup")] [HttpPost("AddGroup")]

View File

@@ -0,0 +1,44 @@
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
namespace Berufsschule_HAM.Helpers;
public static partial class UsersHelper
{
public static string CreateUsername(string givenName, string surname)
{
if (string.IsNullOrWhiteSpace(givenName) || string.IsNullOrWhiteSpace(surname))
throw new ArgumentException("Given name and surname must not be empty.");
string combined = (surname + givenName).ToLowerInvariant();
// Normalize to decompose accents (e.g., é -> e + ́)
string normalized = combined.Normalize(NormalizationForm.FormD);
// Remove diacritics
var sb = new StringBuilder();
foreach (var c in normalized)
{
var category = CharUnicodeInfo.GetUnicodeCategory(c);
if (category != UnicodeCategory.NonSpacingMark)
sb.Append(c);
}
string cleaned = sb.ToString();
// Replace German ligatures etc.
cleaned = cleaned
.Replace("ß", "ss")
.Replace("æ", "ae")
.Replace("œ", "oe")
.Replace("ø", "o");
// Remove everything not a-z
cleaned = AtoZ().Replace(cleaned, "");
return cleaned;
}
[GeneratedRegex("[^a-z]")]
private static partial Regex AtoZ();
}

View File

@@ -1,4 +1,5 @@
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization;
namespace Berufsschule_HAM.Models; namespace Berufsschule_HAM.Models;
@@ -26,17 +27,24 @@ public class UserModel
public class UserDescription public class UserDescription
{ {
[JsonPropertyName("BirthDate")]
public required string BirthDate { get; set; } public required string BirthDate { get; set; }
[JsonPropertyName("Address")]
public required UserAddress Address { get; set; } public required UserAddress Address { get; set; }
[JsonPropertyName("Workplace")]
public required string Workplace { get; set; } public required string Workplace { get; set; }
[JsonPropertyName("Groups")]
public List<string>? Groups { get; set; } public List<string>? Groups { get; set; }
} }
public class UserAddress public class UserAddress
{ {
[JsonPropertyName("City")]
public string? City { get; set; } public string? City { get; set; }
[JsonPropertyName("Street")]
public string? Street { get; set; } public string? Street { get; set; }
public string? StreetNr { get; set; } [JsonPropertyName("StreetNr")]
public int? StreetNr { get; set; }
} }
public class UserAuthenticationResult public class UserAuthenticationResult
@@ -61,5 +69,5 @@ public class UserTableViewModel
public required string Title { get; set; } public required string Title { get; set; }
public required string Name { get; set; } public required string Name { get; set; }
public required string Surname { get; set; } public required string Surname { get; set; }
public required string Workplace { get; set; } public required UserDescription Description { get; set; }
} }

View File

@@ -1,3 +1,5 @@
using System.Text.Json.Serialization;
namespace Berufsschule_HAM.Models; namespace Berufsschule_HAM.Models;
public class UsersIndexRequestModel public class UsersIndexRequestModel
@@ -11,15 +13,27 @@ public class UsersIndexRequestModel
public bool UserPassword { get; set; } = true; public bool UserPassword { get; set; } = true;
} }
public class UsersModifyRequestModel public class UsersModifyRequestModel : UsersCreateRequestModel
{ {
public required string uid { get; set; } [JsonPropertyName("Uid")]
public required string Uid { get; set; }
[JsonPropertyName("NewUid")]
public string? NewUid { get; set; } = null; public string? NewUid { get; set; } = null;
}
public class UsersCreateRequestModel
{
[JsonPropertyName("Cn")]
public string? Cn { get; set; } = null; public string? Cn { get; set; } = null;
[JsonPropertyName("Sn")]
public string? Sn { get; set; } = null; public string? Sn { get; set; } = null;
[JsonPropertyName("Title")]
public string? Title { get; set; } = null; public string? Title { get; set; } = null;
public string? Description { get; set; } = null; [JsonPropertyName("Description")]
public UserDescription? Description { get; set; } = null;
[JsonPropertyName("JpegPhoto")]
public string? JpegPhoto { get; set; } = null; public string? JpegPhoto { get; set; } = null;
[JsonPropertyName("UserPassword")]
public string? UserPassword { get; set; } = null; public string? UserPassword { get; set; } = null;
} }

View File

@@ -0,0 +1,24 @@
using System.Text.Json.Serialization;
namespace Berufsschule_HAM.Models;
public class UsersUpdateResponseModel
{
[JsonPropertyName("Success")]
public required bool Success { get; set; }
[JsonPropertyName("Exception")]
public string? Exception { get; set; }
[JsonPropertyName("NewUid")]
public string? NewUid { get; set; }
}
public class UsersCreateResponseModel
{
[JsonPropertyName("Success")]
public required bool Success { get; set; }
[JsonPropertyName("Exception")]
public string? Exception { get; set; }
[JsonPropertyName("Uid")]
public string? Uid { get; set; }
}

View File

@@ -19,4 +19,112 @@
<data name="Users" xml:space="preserve"> <data name="Users" xml:space="preserve">
<value>Benutzer</value> <value>Benutzer</value>
</data> </data>
<data name="User deleted successfully" xml:space="preserve">
<value>Benutzer erfolgreich gelöscht</value>
</data>
<data name="Unknown error" xml:space="preserve">
<value>Unbekannter Fehler</value>
</data>
<data name="Error contacting server" xml:space="preserve">
<value>Server konnte nicht erreicht werden</value>
</data>
<data name="User updated successfully" xml:space="preserve">
<value>Benutzer wurde erfolgreich angepasst</value>
</data>
<data name="Update failed" xml:space="preserve">
<value>Anpassung ist fehlgeschlagen</value>
</data>
<data name="Error updating user" xml:space="preserve">
<value>Fehler beim Anpassen des Benutzers</value>
</data>
<data name="User created successfully" xml:space="preserve">
<value>Benutzer wurde erfolgreich erstellt</value>
</data>
<data name="Create failed" xml:space="preserve">
<value>Erstellung des Benutzers ist fehlgeschlagen</value>
</data>
<data name="Error creating user" xml:space="preserve">
<value>Fehler beim Erstellen des Benutzers</value>
</data>
<data name="Username" xml:space="preserve">
<value>Benutzername</value>
</data>
<data name="Title" xml:space="preserve">
<value>Anrede</value>
</data>
<data name="Name" xml:space="preserve">
<value>Name</value>
</data>
<data name="Surname" xml:space="preserve">
<value>Nachname</value>
</data>
<data name="Workplace" xml:space="preserve">
<value>Arbeitsplatz</value>
</data>
<data name="Action" xml:space="preserve">
<value>Aktion</value>
</data>
<data name="Update" xml:space="preserve">
<value>Anpassen</value>
</data>
<data name="Delete" xml:space="preserve">
<value>Löschen</value>
</data>
<data name="DeleteUserConfirmation" xml:space="preserve">
<value>Sind Sie sich sicher, dass Sie den Benutzer {0} löschen möchten?</value>
</data>
<data name="Yes, Delete" xml:space="preserve">
<value>Ja, löschen</value>
</data>
<data name="Cancel" xml:space="preserve">
<value>Abbrechen</value>
</data>
<data name="Update User" xml:space="preserve">
<value>Benutzer anpassen</value>
</data>
<data name="Personal information" xml:space="preserve">
<value>Persönliche Informationen</value>
</data>
<data name="Birth Date" xml:space="preserve">
<value>Geburtsdatum</value>
</data>
<data name="City" xml:space="preserve">
<value>Stadt</value>
</data>
<data name="Street" xml:space="preserve">
<value>Straße</value>
</data>
<data name="Street Nr." xml:space="preserve">
<value>Hausnummer</value>
</data>
<data name="Workplace &amp; account" xml:space="preserve">
<value>Arbeitsplatz und Benutzerkonto</value>
</data>
<data name="Groups" xml:space="preserve">
<value>Gruppen</value>
</data>
<data name="New Password" xml:space="preserve">
<value>Neues Passwort</value>
</data>
<data name="Password" xml:space="preserve">
<value>Passwort</value>
</data>
<data name="Photo" xml:space="preserve">
<value>Foto</value>
</data>
<data name="Save changes" xml:space="preserve">
<value>Änderungen anwenden</value>
</data>
<data name="Create" xml:space="preserve">
<value>Anlegen</value>
</data>
<data name="User Details" xml:space="preserve">
<value>Benutzerdaten</value>
</data>
<data name="Address" xml:space="preserve">
<value>Adresse</value>
</data>
<data name="Confirm Delete" xml:space="preserve">
<value>Löschen bestätigen</value>
</data>
</root> </root>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, ...</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, ...</value>
</resheader>
<data name="DeleteUserConfirmation" xml:space="preserve">
<value>Are you sure you want to delete user {0}?</value>
</data>
</root>

View File

@@ -1,6 +1,9 @@
@using Microsoft.AspNetCore.Html
@using Microsoft.AspNetCore.Mvc.Localization @using Microsoft.AspNetCore.Mvc.Localization
@using Berufsschule_HAM.Models @using Berufsschule_HAM.Models
@using System.Buffers.Text @using System.Buffers.Text
@using System.Text.Json;
@using System.Text.Json.Serialization;
@model UsersIndexViewModel @model UsersIndexViewModel
@inject IViewLocalizer T @inject IViewLocalizer T
@{ @{
@@ -13,7 +16,9 @@
<div class="mb-4 d-flex flex-wrap gap-2"> <div class="mb-4 d-flex flex-wrap gap-2">
<button class="btn btn-primary">@T["Create user"]</button> <button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createModal">
@T["Create user"]
</button>
</div> </div>
@@ -22,19 +27,19 @@
<thead> <thead>
<tr> <tr>
<th style="width: 2rem;"></th> <th style="width: 2rem;"></th>
<th>User ID</th> <th>@T["Username"]</th>
<th>title</th> <th>@T["Title"]</th>
<th>Name</th> <th>@T["Name"]</th>
<th>Surname</th> <th>@T["Surname"]</th>
<th>Workplace</th> <th>@T["Workplace"]</th>
<th>Action</th> <th>@T["Action"]</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@{ @{
foreach (UserTableViewModel userTableViewModel in Model.UserTableViewModels) foreach (UserTableViewModel userTableViewModel in Model.UserTableViewModels)
{ {
<tr> <tr class="user-row">
<td> <td>
<img class="rounded-circle user-icon" src="data:image/jpeg;base64,@userTableViewModel.JpegPhoto" alt="Photo" style="max-width:300px;" /> <img class="rounded-circle user-icon" src="data:image/jpeg;base64,@userTableViewModel.JpegPhoto" alt="Photo" style="max-width:300px;" />
</td> </td>
@@ -42,15 +47,29 @@
<td>@userTableViewModel.Title</td> <td>@userTableViewModel.Title</td>
<td>@userTableViewModel.Name</td> <td>@userTableViewModel.Name</td>
<td>@userTableViewModel.Surname</td> <td>@userTableViewModel.Surname</td>
<td>@userTableViewModel.Workplace</td> <td>@userTableViewModel.Description.Workplace</td>
<td> <td>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button class="btn btn-sm btn-primary">Update</button> <button class="btn btn-sm btn-warning"
data-user-id="@userTableViewModel.Uid"
data-user-title="@userTableViewModel.Title"
data-user-name="@userTableViewModel.Name"
data-user-surname="@userTableViewModel.Surname"
data-user-workplace="@userTableViewModel.Description.Workplace"
data-user-birthdate="@userTableViewModel.Description.BirthDate"
data-user-address-city="@userTableViewModel.Description.Address.City"
data-user-address-street="@userTableViewModel.Description.Address.Street"
data-user-address-streetnr="@userTableViewModel.Description.Address.StreetNr"
data-user-groups="@JsonSerializer.Serialize(userTableViewModel.Description.Groups)"
data-bs-toggle="modal"
data-bs-target="#updateModal">
@T["Update"]
</button>
<button class="btn btn-sm btn-danger btn-delete" <button class="btn btn-sm btn-danger btn-delete"
data-user-id="@userTableViewModel.Uid" data-user-id="@userTableViewModel.Uid"
data-bs-toggle="modal" data-bs-toggle="modal"
data-bs-target="#deleteModal"> data-bs-target="#deleteModal">
🗑️ Delete @T["Delete"]
</button> </button>
</div> </div>
</td> </td>
@@ -67,16 +86,18 @@
<div class="modal-dialog modal-dialog-centered"> <div class="modal-dialog modal-dialog-centered">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header bg-danger text-white"> <div class="modal-header bg-danger text-white">
<h5 class="modal-title" id="deleteModalLabel">Confirm Delete</h5> <h5 class="modal-title" id="deleteModalLabel">@T["Confirm Delete"]</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p>Are you sure you want to delete the user <strong id="userName"></strong> (ID: <span id="userId"></span>)?</p> <p>
@T["DeleteUserConfirmation", new HtmlString("<strong id=\"userId\"></strong>")]
</p>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">@T["Cancel"]</button>
<form id="deleteForm" method="post" action=""> <form id="deleteForm" method="post" asp-controller="Users" asp-action="Delete">
<button type="submit" class="btn btn-danger">Yes, Delete</button> <button type="submit" class="btn btn-danger">@T["Yes, Delete"]</button>
</form> </form>
</div> </div>
</div> </div>
@@ -84,6 +105,34 @@
</div> </div>
<script>
// Simple toast helper
function showToast(message, type) {
const toastContainer = document.getElementById('toastContainer') || createToastContainer();
const toast = document.createElement('div');
toast.className = `toast align-items-center text-white bg-${type} border-0`;
toast.role = 'alert';
toast.innerHTML = `
<div class="d-flex">
<div class="toast-body">${message}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
`;
toastContainer.appendChild(toast);
const bsToast = new bootstrap.Toast(toast, { delay: 3000 });
bsToast.show();
toast.addEventListener('hidden.bs.toast', () => toast.remove());
}
function createToastContainer() {
const container = document.createElement('div');
container.id = 'toastContainer';
container.className = 'toast-container position-fixed bottom-0 end-0 p-3';
document.body.appendChild(container);
return container;
}
</script>
<script> <script>
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const deleteModal = document.getElementById('deleteModal'); const deleteModal = document.getElementById('deleteModal');
@@ -97,17 +146,15 @@
deleteModal.querySelector('#userId').textContent = userId; deleteModal.querySelector('#userId').textContent = userId;
deleteModal.querySelector('#userName').textContent = userName; deleteModal.querySelector('#userName').textContent = userName;
// Store the delete URL for later use
deleteModal.querySelector('#deleteForm').dataset.url = `/Users/Delete?uid=${userId}`;
}); });
// Handle submit of deleteForm via fetch() // Handle submit of deleteForm via fetch()
const deleteForm = document.getElementById('deleteForm'); const deleteForm = document.getElementById('deleteForm');
deleteForm.addEventListener('submit', async e => { deleteForm.addEventListener('submit', async e => {
e.preventDefault(); e.preventDefault();
console.log(deleteForm);
const url = deleteForm.dataset.url;
const userId = deleteModal.querySelector('#userId').textContent; const userId = deleteModal.querySelector('#userId').textContent;
const url = `/Users/Delete?uid=${userId}`;
try { try {
const response = await fetch(url, { const response = await fetch(url, {
@@ -115,8 +162,7 @@
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Accept': 'application/json' 'Accept': 'application/json'
}//, }
//body: JSON.stringify({ id: userId }) // Use this for Post requests with [FromBody] parameters like in /Groups/Update
}); });
const result = await response.json(); const result = await response.json();
@@ -131,40 +177,609 @@
row.classList.add('table-danger'); row.classList.add('table-danger');
setTimeout(() => row.remove(), 300); setTimeout(() => row.remove(), 300);
showToast('User deleted successfully', 'success'); showToast('@T["User deleted successfully"]', 'success');
} else { } else {
showToast(`${result.reason}: ${result.exception || 'Unknown error'}`, 'danger'); showToast(`${result.reason}: ${result.exception || '@T["Unknown error"]'}`, 'danger');
} }
} catch (error) { } catch (error) {
console.error(error); console.error(error);
showToast('Error contacting server', 'danger'); showToast('@T["Error contacting server"]', 'danger');
}
});
});
</script>
<!-- User Update Modal -->
<div class="modal fade" id="updateModal" tabindex="-1" aria-labelledby="updateModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
<div class="modal-header bg-warning text-dark">
<h5 class="modal-title" id="updateModalLabel">@T["Update User"]</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="updateForm">
<input type="hidden" id="updateUid" name="Uid" />
<div class="row g-3">
<h6 class="fw-bold">@T["Personal information"]</h6>
<div class="col-md-6">
<label class="form-label">@T["Title"]</label>
<input type="text" id="updateTitle" name="Title" class="form-control" />
</div>
<div class="col-md-6">
<label class="form-label">@T["Name"]</label>
<input type="text" id="updateName" name="Cn" class="form-control" />
</div>
<div class="col-md-6">
<label class="form-label">@T["Surname"]</label>
<input type="text" id="updateSurname" name="Sn" class="form-control" />
</div>
<div class="col-md-6">
<label class="form-label">@T["Birth Date"]</label>
<input type="text" id="updateBirthdate" name="Description.BirthDate" class="form-control" />
</div>
<div class="col-md-6">
<label class="form-label">@T["City"]</label>
<input type="text" id="updateAddressCity" name="Description.Address.City" class="form-control" />
</div>
<div class="col-md-6">
<label class="form-label">@T["Street"]</label>
<input type="text" id="updateAddressStreet" name="Description.Address.Street" class="form-control" />
</div>
<div class="col-md-6">
<label class="form-label">@T["Street Nr."]</label>
<input type="text" id="updateAddressStreetNr" name="Description.Address.StreetNr" class="form-control" />
</div>
<hr class="my-3">
<h6 class="fw-bold">@T["Workplace & account"]</h6>
<div class="col-md-6">
<label class="form-label">@T["Workplace"]</label>
<input type="text" id="updateWorkplace" name="Description.Workplace" class="form-control" />
</div>
<div class="col-md-6">
<label class="form-label">@T["Groups"]</label>
<select id="updateGroups" name="Description.Groups" class="form-select" multiple></select>
</div>
<div class="col-md-6">
<label class="form-label">@T["New Password"]</label>
<input type="password" id="updatePassword" name="UserPassword" class="form-control" />
</div>
<div class="col-md-6">
<label class="form-label">@T["Photo"]</label>
<input type="file" id="updatePhotoFile" accept="image/*" class="form-control" />
<input type="hidden" id="updateJpegPhoto" name="JpegPhoto" />
<div class="mt-2 text-center">
<img id="updatePhotoPreview" src="" alt="Preview" class="img-thumbnail" style="max-height: 150px; display: none;" />
</div>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">@T["Cancel"]</button>
<button type="submit" form="updateForm" class="btn btn-warning">@T["Save changes"]</button>
</div>
</div>
</div>
</div>
<script>
function unflatten(obj) {
const result = {};
for (const [key, value] of Object.entries(obj)) {
const parts = key.split(".");
let current = result;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (i === parts.length - 1) {
// Last part — assign value
try {
// Try to parse JSON strings like "[...]" if possible
current[part] = JSON.parse(value);
} catch {
current[part] = value;
}
} else {
// Intermediate part — create object if needed
current[part] = current[part] || {};
current = current[part];
}
}
}
return result;
}
document.addEventListener('DOMContentLoaded', () => {
// --- Update user modal ---
const updateModal = document.getElementById('updateModal');
const updateForm = document.getElementById('updateForm');
let updateButton = null;
updateModal.addEventListener('show.bs.modal', event => {
updateButton = event.relatedTarget;
const userId = updateButton.getAttribute('data-user-id');
const title = updateButton.getAttribute('data-user-title');
const name = updateButton.getAttribute('data-user-name');
const surname = updateButton.getAttribute('data-user-surname');
const workplace = updateButton.getAttribute('data-user-workplace');
const birthdate = updateButton.getAttribute('data-user-birthdate');
const addressCity = updateButton.getAttribute('data-user-address-city');
const addressStreet = updateButton.getAttribute('data-user-address-street');
const addressStreetNr = updateButton.getAttribute('data-user-address-streetnr');
// Fill form fields
updateForm.querySelector('#updateUid').value = userId;
updateForm.querySelector('#updateTitle').value = title || '';
updateForm.querySelector('#updateName').value = name || '';
updateForm.querySelector('#updateSurname').value = surname || '';
updateForm.querySelector('#updateWorkplace').value = workplace || '';
updateForm.querySelector('#updateBirthdate').value = birthdate || '';
updateForm.querySelector('#updateAddressCity').value = addressCity || '';
updateForm.querySelector('#updateAddressStreet').value = addressStreet || '';
updateForm.querySelector('#updateAddressStreetNr').value = addressStreetNr || '';
updateForm.querySelector('#updatePassword').value = '';
updateForm.querySelector('#updateJpegPhoto').value = '';
});
updateForm.addEventListener('submit', async e => {
e.preventDefault();
var dataFromEntries = Object.fromEntries(new FormData(updateForm).entries());
var data = unflatten(dataFromEntries);
data.Description.Groups = Array.from(updateForm.querySelector('#updateGroups').selectedOptions).map(option => option.value);
try {
const response = await fetch('/Users/Update', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify(data)
});
const result = await response.json();
if (result.Success) {
// Close modal
bootstrap.Modal.getInstance(updateModal).hide();
// Update table row
const row = updateButton.closest('tr');
row.querySelector('td:nth-child(2)').textContent = result.NewUid || '';
row.querySelector('td:nth-child(3)').textContent = data.Title || '';
row.querySelector('td:nth-child(4)').textContent = data.Cn || '';
row.querySelector('td:nth-child(5)').textContent = data.Sn || '';
row.querySelector('td:nth-child(6)').textContent = data.Description?.Workplace || '';
// Update button data attributes, so the next edit uses the new data
updateButton.setAttribute('data-user-id', result.NewUid || '');
updateButton.setAttribute('data-user-title', data.Title || '');
updateButton.setAttribute('data-user-name', data.Cn || '');
updateButton.setAttribute('data-user-surname', data.Sn || '');
updateButton.setAttribute('data-user-workplace', data.Description?.Workplace || '');
updateButton.setAttribute('data-user-groups', JSON.stringify(data.Description?.Groups || []));
if (data.JpegPhoto && data.JpegPhoto.length > 0) {
const img = row.querySelector('td:first-child img');
if (img) {
img.src = `data:image/jpeg;base64,${data.JpegPhoto}`;
img.classList.add('border', 'border-success'); // optional visual feedback
setTimeout(() => img.classList.remove('border', 'border-success'), 1000);
}
}
updateForm.reset();
document.getElementById('updatePhotoPreview').style.display = "none";
showToast('@T["User updated successfully"]', 'success');
} else {
showToast(`${result.Exception || '@T["Update failed"]'}`, 'danger');
}
} catch (err) {
console.error(err);
showToast('@T["Error updating user"]', 'danger');
}
});
});
// Group select drop-down
document.addEventListener('DOMContentLoaded', () => {
const updateModal = document.getElementById('updateModal');
const updateForm = document.getElementById('updateForm');
const updateGroupsSelect = document.getElementById('updateGroups');
let updateButton = null;
updateModal.addEventListener('show.bs.modal', async event => {
updateButton = event.relatedTarget;
const userId = updateButton.getAttribute('data-user-id');
const title = updateButton.getAttribute('data-user-title');
const name = updateButton.getAttribute('data-user-name');
const surname = updateButton.getAttribute('data-user-surname');
const workplace = updateButton.getAttribute('data-user-workplace');
const groups = JSON.parse(updateButton.getAttribute('data-user-groups'));
// Fill basic fields
updateForm.querySelector('#updateUid').value = userId;
updateForm.querySelector('#updateTitle').value = title || '';
updateForm.querySelector('#updateName').value = name || '';
updateForm.querySelector('#updateSurname').value = surname || '';
updateForm.querySelector('#updateWorkplace').value = workplace || '';
updateForm.querySelector('#updatePassword').value = '';
updateForm.querySelector('#updateJpegPhoto').value = '';
// Fetch available groups
await populateGroupsDropdown(updateGroupsSelect, groups);
// Fetch user's existing groups (you need to provide them via data-attribute or fetch)
const existingGroups = updateButton.getAttribute('data-user-groups');
if (existingGroups) {
const selectedGroups = existingGroups.split(',');
Array.from(updateGroupsSelect.options).forEach(opt => {
opt.selected = selectedGroups.includes(opt.value);
});
} }
}); });
// Simple toast helper async function populateGroupsDropdown(selectElement, preselectedGroups) {
function showToast(message, type) { try {
const toastContainer = document.getElementById('toastContainer') || createToastContainer(); const res = await fetch('/Groups/Get');
const toast = document.createElement('div'); const json = await res.json();
toast.className = `toast align-items-center text-white bg-${type} border-0`; if (!json.success) {
toast.role = 'alert'; console.warn('Failed to fetch groups', json);
toast.innerHTML = ` return;
<div class="d-flex"> }
<div class="toast-body">${message}</div> selectElement.innerHTML = '';
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button> json.GroupModels.forEach(group => {
</div> const opt = document.createElement('option');
`; opt.value = group.Cn;
toastContainer.appendChild(toast); opt.textContent = group.DisplayName || group.Cn;
const bsToast = new bootstrap.Toast(toast, { delay: 3000 }); selectElement.appendChild(opt);
bsToast.show(); if (preselectedGroups != null && preselectedGroups.includes(group.Cn)) {
toast.addEventListener('hidden.bs.toast', () => toast.remove()); setTimeout(() => {
opt.selected = true;
}, 200);
}
});
} catch (err) {
console.error('Error fetching groups', err);
}
} }
});
function createToastContainer() { document.addEventListener('DOMContentLoaded', () => {
const container = document.createElement('div'); const photoInput = document.getElementById('updatePhotoFile');
container.id = 'toastContainer'; const photoPreview = document.getElementById('updatePhotoPreview');
container.className = 'toast-container position-fixed bottom-0 end-0 p-3'; const photoHidden = document.getElementById('updateJpegPhoto');
document.body.appendChild(container);
return container; photoInput.addEventListener('change', () => {
const file = photoInput.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = e => {
const base64 = e.target.result.split(',')[1]; // strip data:image/jpeg;base64,
photoHidden.value = base64;
photoPreview.src = e.target.result;
photoPreview.style.display = 'block';
};
reader.readAsDataURL(file);
});
});
</script>
<!-- User Create Modal -->
<div class="modal fade" id="createModal" tabindex="-1" aria-labelledby="createModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
<div class="modal-header bg-primary text-white">
<h5 class="modal-title" id="createModalLabel">@T["Create User"]</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="createForm">
<div class="row g-3">
<h6 class="fw-bold">@T["Personal information"]</h6>
<div class="col-md-6">
<label class="form-label">@T["Title"]</label>
<input type="text" name="Title" class="form-control" />
</div>
<div class="col-md-6">
<label class="form-label">@T["Name"]</label>
<input type="text" name="Cn" class="form-control" />
</div>
<div class="col-md-6">
<label class="form-label">@T["Surname"]</label>
<input type="text" name="Sn" class="form-control" />
</div>
<div class="col-md-6">
<label class="form-label">@T["Birth Date"]</label>
<input type="text" name="Description.BirthDate" class="form-control" />
</div>
<div class="col-md-6">
<label class="form-label">@T["City"]</label>
<input type="text" name="Description.Address.City" class="form-control" />
</div>
<div class="col-md-6">
<label class="form-label">@T["Street"]</label>
<input type="text" name="Description.Address.Street" class="form-control" />
</div>
<div class="col-md-6">
<label class="form-label">@T["Street Nr."]</label>
<input type="text" name="Description.Address.StreetNr" class="form-control" />
</div>
<hr class="my-3">
<h6 class="fw-bold">@T["Workplace & account"]</h6>
<div class="col-md-6">
<label class="form-label">@T["Workplace"]</label>
<input type="text" name="Description.Workplace" class="form-control" />
</div>
<div class="col-md-6">
<label class="form-label">@T["Groups"]</label>
<select id="createGroups" name="Description.Groups" class="form-select" multiple></select>
</div>
<div class="col-md-6">
<label class="form-label">@T["Password"]</label>
<input type="password" name="UserPassword" class="form-control" />
</div>
<div class="col-md-6">
<label class="form-label">@T["Photo"]</label>
<input type="file" id="createPhotoFile" accept="image/*" class="form-control" />
<input type="hidden" id="createJpegPhoto" name="JpegPhoto" />
<div class="mt-2 text-center">
<img id="createPhotoPreview" src="" alt="Preview" class="img-thumbnail" style="max-height: 150px; display: none;" />
</div>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">@T["Cancel"]</button>
<button type="submit" form="createForm" class="btn btn-primary">@T["Create"]</button>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const createModal = document.getElementById('createModal');
const createForm = document.getElementById('createForm');
const createGroupsSelect = document.getElementById('createGroups');
const photoInput = document.getElementById('createPhotoFile');
const photoPreview = document.getElementById('createPhotoPreview');
const photoHidden = document.getElementById('createJpegPhoto');
// Load available groups when modal is shown
createModal.addEventListener('show.bs.modal', async () => {
await populateGroupsDropdown(createGroupsSelect);
createForm.reset();
photoPreview.style.display = 'none';
});
// Handle photo upload preview
photoInput.addEventListener('change', () => {
const file = photoInput.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = e => {
const base64 = e.target.result.split(',')[1];
photoHidden.value = base64;
photoPreview.src = e.target.result;
photoPreview.style.display = 'block';
};
reader.readAsDataURL(file);
});
// Submit create form
createForm.addEventListener('submit', async e => {
e.preventDefault();
const dataFromEntries = Object.fromEntries(new FormData(createForm).entries());
const data = unflatten(dataFromEntries);
data.Description.Groups = Array.from(createGroupsSelect.selectedOptions).map(o => o.value);
try {
const response = await fetch('/Users/Create', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify(data)
});
const result = await response.json();
if (result.Success) {
bootstrap.Modal.getInstance(createModal).hide();
showToast('@T["User created successfully"]', 'success');
// Add new row to table dynamically
const tbody = document.querySelector('table tbody');
const newRow = document.createElement('tr');
newRow.innerHTML = `
<td><img class="rounded-circle user-icon" src="data:image/jpeg;base64,${data.JpegPhoto || ''}" alt="Photo" style="max-width:300px;" /></td>
<td>${result.NewUid || ''}</td>
<td>${data.Title || ''}</td>
<td>${data.Cn || ''}</td>
<td>${data.Sn || ''}</td>
<td>${data.Description?.Workplace || ''}</td>
<td>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-warning"
data-user-id="${result.Uid || ''}"
data-user-title="${data.Title || ''}"
data-user-name="${data.Cn || ''}"
data-user-surname="${data.Sn || ''}"
data-user-workplace="${data.Description?.Workplace || ''}"
data-user-groups='${JSON.stringify(data.Description?.Groups || [])}'
data-bs-toggle="modal"
data-bs-target="#updateModal">
Update
</button>
<button class="btn btn-sm btn-danger btn-delete"
data-user-id="${result.NewUid || ''}"
data-user-name="${data.Cn || ''}"
data-bs-toggle="modal"
data-bs-target="#deleteModal">
Delete
</button>
</div>
</td>
`;
tbody.appendChild(newRow);
} else {
showToast(`${result.Exception || '@T["Create failed"]'}`, 'danger');
}
} catch (err) {
console.error(err);
showToast('@T["Error creating user"]', 'danger');
}
});
async function populateGroupsDropdown(selectElement) {
try {
const res = await fetch('/Groups/Get');
const json = await res.json();
if (!json.success) return;
selectElement.innerHTML = '';
json.GroupModels.forEach(group => {
const opt = document.createElement('option');
opt.value = group.Cn;
opt.textContent = group.DisplayName || group.Cn;
selectElement.appendChild(opt);
});
} catch (err) {
console.error('Error fetching groups', err);
}
} }
}); });
</script> </script>
<!-- User Detail Modal -->
<div class="modal fade" id="detailModal" tabindex="-1" aria-labelledby="detailModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
<div class="modal-header bg-info text-white">
<h5 class="modal-title" id="detailModalLabel">@T["User Details"]</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row g-3">
<div class="col-md-4 text-center">
<img id="detailPhoto" class="img-thumbnail rounded-circle" style="max-height:150px;" />
</div>
<div class="col-md-6">
<label class="form-label">@T["Username"]</label>
<input type="text" class="form-control" id="detailUid" value="" disabled />
</div>
<div class="col-md-6">
<label class="form-label">@T["Title"]</label>
<input type="text" class="form-control" id="detailTitle" value="" disabled />
</div>
<div class="col-md-6">
<label class="form-label">@T["Name"]</label>
<input type="text" class="form-control" id="detailName" value="" disabled />
</div>
<div class="col-md-6">
<label class="form-label">@T["Surname"]</label>
<input type="text" class="form-control" id="detailSurname" value="" disabled />
</div>
<div class="col-md-6">
<label class="form-label">@T["Birth Date"]</label>
<input type="text" class="form-control" id="detailBirthdate" value="" disabled />
</div>
<hr class="my-3">
<h6 class="fw-bold">@T["Address"]</h6>
<div class="col-md-4">
<label class="form-label">@T["City"]</label>
<input type="text" class="form-control" id="detailCity" value="" disabled />
</div>
<div class="col-md-6">
<label class="form-label">@T["Street"]</label>
<input type="text" class="form-control" id="detailStreet" value="" disabled />
</div>
<div class="col-md-2">
<label class="form-label">@T["Street Nr."]</label>
<input type="text" class="form-control" id="detailStreetNr" value="" disabled />
</div>
<hr class="my-3">
<h6 class="fw-bold">@T["Workplace & account"]</h6>
<div class="col-md-6">
<label class="form-label">@T["Workplace"]</label>
<input type="text" class="form-control" id="detailWorkplace" value="" disabled />
</div>
<div class="col-md-6">
<label class="form-label">@T["Groups"]</label>
<input type="text" class="form-control" id="detailGroups" value="" disabled />
</div>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const rows = document.querySelectorAll('table tbody tr');
const detailModalEl = document.getElementById('detailModal');
const detailModal = new bootstrap.Modal(detailModalEl);
rows.forEach(row => {
row.addEventListener('click', (e) => {
// Dont trigger when clicking inside the action buttons
if (e.target.closest('button')) return;
const updateBtn = row.querySelector('.btn-warning[data-user-id]');
if (!updateBtn) return;
// Extract data from update button
const data = {
uid: updateBtn.dataset.userId,
title: updateBtn.dataset.userTitle,
name: updateBtn.dataset.userName,
surname: updateBtn.dataset.userSurname,
workplace: updateBtn.dataset.userWorkplace,
birthdate: updateBtn.dataset.userBirthdate,
city: updateBtn.dataset.userAddressCity,
street: updateBtn.dataset.userAddressStreet,
streetNr: updateBtn.dataset.userAddressStreetnr,
groups: JSON.parse(updateBtn.dataset.userGroups || '[]'),
};
// Fill modal fields
document.getElementById('detailUid').value = data.uid || '';
document.getElementById('detailTitle').value = data.title || '';
document.getElementById('detailName').value = data.name || '';
document.getElementById('detailSurname').value = data.surname || '';
document.getElementById('detailBirthdate').value = data.birthdate || '';
document.getElementById('detailCity').value = data.city || '';
document.getElementById('detailStreet').value = data.street || '';
document.getElementById('detailStreetNr').value = data.streetNr || '';
document.getElementById('detailWorkplace').value = data.workplace || '';
document.getElementById('detailGroups').value = data.groups.join(', ') || '';
// Photo
const imgEl = row.querySelector('td:first-child img');
const detailPhoto = document.getElementById('detailPhoto');
if (imgEl && imgEl.src.startsWith('data:image')) {
detailPhoto.src = imgEl.src;
detailPhoto.style.display = 'block';
} else {
detailPhoto.style.display = 'none';
}
detailModal.show();
});
});
});
</script>
<style>
.user-row > td:not(:last-child) {
cursor: pointer;
}
.user-row > td {
transition: 0.1s ease;
}
.user-row:has(td:not(:last-child):hover) > td {
background-color: #17a2b8;
}
</style>