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,51 +83,37 @@ 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
{
string uid = requestModel.Uid;
UserModel? user = null; UserModel? user = null;
if (requestModel.Cn is not null) if (requestModel.NewUid is not null && requestModel.NewUid.Length > 0)
{
await _ldap.UpdateUser(uid, "cn", requestModel.Cn);
user ??= await _ldap.GetUserByUidAsync(uid);
string newUid = user.Sn?.ToLower() + requestModel.Cn.ToLower();
await _ldap.UpdateUser(uid, "uid", newUid);
uid = newUid;
}
if (requestModel.Sn is not null)
{
await _ldap.UpdateUser(uid, "sn", requestModel.Sn);
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); await _ldap.UpdateUser(uid, "uid", requestModel.NewUid);
uid = requestModel.NewUid; uid = requestModel.NewUid;
@@ -134,17 +124,45 @@ public class UsersController : Controller
} }
if (requestModel.Description is not null) if (requestModel.Description is not null)
{ {
await _ldap.UpdateUser(uid, "description", requestModel.Description); await _ldap.UpdateUser(uid, "description", JsonSerializer.Serialize(requestModel.Description));
} }
if (requestModel.JpegPhoto is not null) if (requestModel.JpegPhoto is not null && requestModel.JpegPhoto.Length > 0)
{ {
await _ldap.UpdateUser(uid, "jpegPhoto", requestModel.JpegPhoto); await _ldap.UpdateUser(uid, "jpegPhoto", requestModel.JpegPhoto);
} }
if (requestModel.UserPassword is not null) if (requestModel.UserPassword is not null && requestModel.UserPassword.Length > 0)
{ {
await _ldap.UpdateUser(uid, "userPassword", requestModel.UserPassword); 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)
{
return new() { Success = false, Exception = ex.Message };
} }
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>
@@ -85,62 +106,6 @@
<script> <script>
document.addEventListener('DOMContentLoaded', () => {
const deleteModal = document.getElementById('deleteModal');
let currentButton = null; // The delete button that opened the modal
deleteModal.addEventListener('show.bs.modal', event => {
currentButton = event.relatedTarget; // Button that triggered the modal
const userId = currentButton.getAttribute('data-user-id');
const userName = currentButton.getAttribute('data-user-name');
deleteModal.querySelector('#userId').textContent = userId;
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()
const deleteForm = document.getElementById('deleteForm');
deleteForm.addEventListener('submit', async e => {
e.preventDefault();
const url = deleteForm.dataset.url;
const userId = deleteModal.querySelector('#userId').textContent;
try {
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': '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();
if (result.success) {
// Close the modal
const modal = bootstrap.Modal.getInstance(deleteModal);
modal.hide();
// Remove the row from the table
const row = currentButton.closest('tr');
row.classList.add('table-danger');
setTimeout(() => row.remove(), 300);
showToast('User deleted successfully', 'success');
} else {
showToast(`❌ ${result.reason}: ${result.exception || 'Unknown error'}`, 'danger');
}
} catch (error) {
console.error(error);
showToast('Error contacting server', 'danger');
}
});
// Simple toast helper // Simple toast helper
function showToast(message, type) { function showToast(message, type) {
const toastContainer = document.getElementById('toastContainer') || createToastContainer(); const toastContainer = document.getElementById('toastContainer') || createToastContainer();
@@ -166,5 +131,655 @@
document.body.appendChild(container); document.body.appendChild(container);
return container; return container;
} }
</script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const deleteModal = document.getElementById('deleteModal');
let currentButton = null; // The delete button that opened the modal
deleteModal.addEventListener('show.bs.modal', event => {
currentButton = event.relatedTarget; // Button that triggered the modal
const userId = currentButton.getAttribute('data-user-id');
const userName = currentButton.getAttribute('data-user-name');
deleteModal.querySelector('#userId').textContent = userId;
deleteModal.querySelector('#userName').textContent = userName;
});
// Handle submit of deleteForm via fetch()
const deleteForm = document.getElementById('deleteForm');
deleteForm.addEventListener('submit', async e => {
e.preventDefault();
console.log(deleteForm);
const userId = deleteModal.querySelector('#userId').textContent;
const url = `/Users/Delete?uid=${userId}`;
try {
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
}
});
const result = await response.json();
if (result.success) {
// Close the modal
const modal = bootstrap.Modal.getInstance(deleteModal);
modal.hide();
// Remove the row from the table
const row = currentButton.closest('tr');
row.classList.add('table-danger');
setTimeout(() => row.remove(), 300);
showToast('@T["User deleted successfully"]', 'success');
} else {
showToast(`${result.reason}: ${result.exception || '@T["Unknown error"]'}`, 'danger');
}
} catch (error) {
console.error(error);
showToast('@T["Error contacting server"]', 'danger');
}
});
}); });
</script> </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);
});
}
});
async function populateGroupsDropdown(selectElement, preselectedGroups) {
try {
const res = await fetch('/Groups/Get');
const json = await res.json();
if (!json.success) {
console.warn('Failed to fetch groups', json);
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);
if (preselectedGroups != null && preselectedGroups.includes(group.Cn)) {
setTimeout(() => {
opt.selected = true;
}, 200);
}
});
} catch (err) {
console.error('Error fetching groups', err);
}
}
});
document.addEventListener('DOMContentLoaded', () => {
const photoInput = document.getElementById('updatePhotoFile');
const photoPreview = document.getElementById('updatePhotoPreview');
const photoHidden = document.getElementById('updateJpegPhoto');
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>
<!-- 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>