Implemented users update button in frontend

This commit is contained in:
2025-10-25 22:15:08 +02:00
parent dcb23b76ec
commit 310e05545f
6 changed files with 458 additions and 76 deletions

View File

@@ -1,4 +1,3 @@
// Controllers/UsersController.cs
using Berufsschule_HAM.Services;
using Microsoft.AspNetCore.Mvc;
using Novell.Directory.Ldap;
@@ -8,6 +7,7 @@ using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Authorization;
using System.Text.Json;
using System.Buffers.Text;
[Authorize(Roles = "CanManageUsers")]
[Route("[controller]")]
@@ -66,7 +66,7 @@ public class UsersController : Controller
try
{
jpegPhoto ??= Convert.ToBase64String(System.IO.File.ReadAllBytes("wwwroot/user_default.jpeg")); // TODO: cleanup - make this a config setting
uid ??= sn.ToLower() + cn.ToLower();
uid ??= UsersHelper.CreateUsername(cn, sn);
title ??= "";
description ??= "{}";
if (!userPassword.StartsWith('{'))
@@ -98,32 +98,18 @@ public class UsersController : Controller
}
[HttpPost("Update")]
public async Task<bool> Update([FromBody] UsersModifyRequestModel requestModel)
public async Task<UsersUpdateRequestModel> Update([FromBody] UsersModifyRequestModel requestModel)
{
if (requestModel 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;
if (requestModel.Cn is not null)
{
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)
if (requestModel.NewUid is not null && requestModel.NewUid.Length > 0)
{
await _ldap.UpdateUser(uid, "uid", requestModel.NewUid);
uid = requestModel.NewUid;
@@ -134,17 +120,45 @@ public class UsersController : Controller
}
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);
}
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")]

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.Serialization;
namespace Berufsschule_HAM.Models;
@@ -26,17 +27,24 @@ public class UserModel
public class UserDescription
{
[JsonPropertyName("BirthDate")]
public required string BirthDate { get; set; }
[JsonPropertyName("Address")]
public required UserAddress Address { get; set; }
[JsonPropertyName("Workplace")]
public required string Workplace { get; set; }
[JsonPropertyName("Groups")]
public List<string>? Groups { get; set; }
}
public class UserAddress
{
[JsonPropertyName("City")]
public string? City { get; set; }
[JsonPropertyName("Street")]
public string? Street { get; set; }
public string? StreetNr { get; set; }
[JsonPropertyName("StreetNr")]
public int? StreetNr { get; set; }
}
public class UserAuthenticationResult
@@ -61,5 +69,5 @@ public class UserTableViewModel
public required string Title { get; set; }
public required string Name { 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;
public class UsersIndexRequestModel
@@ -13,13 +15,21 @@ public class UsersIndexRequestModel
public class UsersModifyRequestModel
{
public required string uid { get; set; }
[JsonPropertyName("Uid")]
public required string Uid { get; set; }
[JsonPropertyName("NewUid")]
public string? NewUid { get; set; } = null;
[JsonPropertyName("Cn")]
public string? Cn { get; set; } = null;
[JsonPropertyName("Sn")]
public string? Sn { get; set; } = null;
[JsonPropertyName("Title")]
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;
[JsonPropertyName("UserPassword")]
public string? UserPassword { get; set; } = null;
}

View File

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

View File

@@ -1,6 +1,8 @@
@using Microsoft.AspNetCore.Mvc.Localization
@using Berufsschule_HAM.Models
@using System.Buffers.Text
@using System.Text.Json;
@using System.Text.Json.Serialization;
@model UsersIndexViewModel
@inject IViewLocalizer T
@{
@@ -42,15 +44,29 @@
<td>@userTableViewModel.Title</td>
<td>@userTableViewModel.Name</td>
<td>@userTableViewModel.Surname</td>
<td>@userTableViewModel.Workplace</td>
<td>@userTableViewModel.Description.Workplace</td>
<td>
<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">
Update
</button>
<button class="btn btn-sm btn-danger btn-delete"
data-user-id="@userTableViewModel.Uid"
data-bs-toggle="modal"
data-bs-target="#deleteModal">
🗑️ Delete
Delete
</button>
</div>
</td>
@@ -84,6 +100,34 @@
</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>
document.addEventListener('DOMContentLoaded', () => {
const deleteModal = document.getElementById('deleteModal');
@@ -115,8 +159,7 @@
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();
@@ -140,31 +183,281 @@
showToast('Error contacting server', 'danger');
}
});
// 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>
<!-- 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">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">Personal information</h6>
<div class="col-md-6">
<label class="form-label">Title</label>
<input type="text" id="updateTitle" name="Title" class="form-control" />
</div>
<div class="col-md-6">
<label class="form-label">Name</label>
<input type="text" id="updateName" name="Cn" class="form-control" />
</div>
<div class="col-md-6">
<label class="form-label">Surname</label>
<input type="text" id="updateSurname" name="Sn" class="form-control" />
</div>
<div class="col-md-6">
<label class="form-label">Birth date</label>
<input type="text" id="updateBirthdate" name="Description.BirthDate" class="form-control" />
</div>
<div class="col-md-6">
<label class="form-label">City</label>
<input type="text" id="updateAddressCity" name="Description.Address.City" class="form-control" />
</div>
<div class="col-md-6">
<label class="form-label">Street</label>
<input type="text" id="updateAddressStreet" name="Description.Address.Street" class="form-control" />
</div>
<div class="col-md-6">
<label class="form-label">Street Nr.</label>
<input type="text" id="updateAddressStreetNr" name="Description.Address.StreetNr" class="form-control" />
</div>
<hr class="my-3">
<h6 class="fw-bold">Workplace & account</h6>
<div class="col-md-6">
<label class="form-label">Workplace</label>
<input type="text" id="updateWorkplace" name="Description.Workplace" class="form-control" />
</div>
<div class="col-md-6">
<label class="form-label">Groups</label>
<select id="updateGroups" name="Description.Groups" class="form-select" multiple></select>
</div>
<div class="col-md-6">
<label class="form-label">New Password</label>
<input type="password" id="updatePassword" name="UserPassword" class="form-control" />
</div>
<div class="col-md-6">
<label class="form-label">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">Cancel</button>
<button type="submit" form="updateForm" class="btn btn-warning">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('User updated successfully', 'success');
} else {
showToast(`${result.Exception || 'Update failed'}`, 'danger');
}
} catch (err) {
console.error(err);
showToast('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>