Merge pull request #337 from LD-Reborn/157-feature-add-user-profile-settings-page

157 feature add user profile settings page
This commit is contained in:
LD50
2025-11-23 13:57:46 +01:00
committed by GitHub
12 changed files with 300 additions and 25 deletions

View File

@@ -3,11 +3,7 @@ using Microsoft.AspNetCore.Mvc;
using Novell.Directory.Ldap; using Novell.Directory.Ldap;
using Berufsschule_HAM.Models; using Berufsschule_HAM.Models;
using Berufsschule_HAM.Helpers; using Berufsschule_HAM.Helpers;
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using System.Text.Json;
using System.Buffers.Text;
[Authorize] [Authorize]
[Route("[controller]")] [Route("[controller]")]
@@ -23,11 +19,60 @@ public class SettingsController : Controller
} }
[HttpGet("User")] [HttpGet("User")]
public new IActionResult User() public async Task<IActionResult> UserAsync()
{ {
return View(); try
{
var userID = User.Identity?.Name ?? throw new Exception("No name specified");
UserSettingsModel userSettingsModel = new()
{
userModel = await _ldap.GetUserByUidAsync(userID)
};
return View(userSettingsModel);
} catch (Exception ex)
{
_logger.LogWarning("An exception happened when trying to show user settings view: {ex.Message} - {ex.StackTrace}", [ex.Message, ex.StackTrace]);
return Redirect("/Home/Logout");
}
} }
[HttpPut("User")]
public async Task<UserUpdateResponseModel> UpdateUserAsync([FromBody]UserUpdateRequestModel userUpdateRequestModel)
{
if (userUpdateRequestModel is null)
{
return new() {Success = false, ErrorMessage = "userUpdateRequestModel is null"};
}
try
{
var userID = User.Identity?.Name ?? throw new Exception("No name specified");
bool anyUpdated = false;
if (userUpdateRequestModel.Password is not null && userUpdateRequestModel.Password.Length > 0)
{
await _ldap.UpdateUser(userID, "userPassword", await UsersHelper.HashPassword(_ldap, userUpdateRequestModel.Password));
anyUpdated = true;
}
if (userUpdateRequestModel.Image is not null && userUpdateRequestModel.Image.Length > 0)
{
await _ldap.UpdateUser(userID, "jpegPhoto", userUpdateRequestModel.Image);
anyUpdated = true;
}
if (anyUpdated)
{
return new() {Success = true};
}
else
{
return new() {Success = false, ErrorMessage = "Nothing was updated"};
}
} catch (Exception ex)
{
_logger.LogWarning("An exception happened when trying to update a user: {ex.Message} - {ex.StackTrace}", [ex.Message, ex.StackTrace]);
return new() {Success = false, ErrorMessage = ex.Message};
}
}
[Authorize(Roles = "CanManageSettings")] [Authorize(Roles = "CanManageSettings")]
[HttpGet("Admin")] [HttpGet("Admin")]
public async Task<IActionResult> AdminAsync() public async Task<IActionResult> AdminAsync()

View File

@@ -70,7 +70,6 @@ public class UsersController : Controller
} }
try try
{ {
Task<AdminSettingsModel> settingsTask = _ldap.GetAdminSettingsModelAsync();
string? jpegPhoto = requestModel.JpegPhoto; string? jpegPhoto = requestModel.JpegPhoto;
string? title = requestModel.Title; string? title = requestModel.Title;
string userPassword = requestModel.UserPassword ?? ""; string userPassword = requestModel.UserPassword ?? "";
@@ -81,10 +80,7 @@ public class UsersController : Controller
description ??= new() {Address = new(), BirthDate = "", Workplace = "", Groups = []}; description ??= new() {Address = new(), BirthDate = "", Workplace = "", Groups = []};
if (!userPassword.StartsWith('{')) if (!userPassword.StartsWith('{'))
{ {
AdminSettingsModel settings = await settingsTask; userPassword = await UsersHelper.HashPassword(_ldap, userPassword);
byte[] passwordBytes = Encoding.UTF8.GetBytes(userPassword);
byte[] hashedPassword = settings.hashAlgorithm?.ComputeHash(passwordBytes) ?? throw new Exception("Hash algorithm not instantiated yet");
userPassword = $"{{{settings.DefaultHashAlgorithm.ToUpperInvariant()}}}{Convert.ToBase64String(hashedPassword)}";
} }
LdapAttributeSet attributeSet = LdapAttributeSet attributeSet =
@@ -118,7 +114,6 @@ public class UsersController : Controller
} }
try try
{ {
Task<AdminSettingsModel> settingsTask = _ldap.GetAdminSettingsModelAsync();
string uid = requestModel.Uid; string uid = requestModel.Uid;
UserModel? user = null; UserModel? user = null;
if (requestModel.NewUid is not null && requestModel.NewUid.Length > 0) if (requestModel.NewUid is not null && requestModel.NewUid.Length > 0)
@@ -140,11 +135,8 @@ public class UsersController : Controller
await _ldap.UpdateUser(uid, "jpegPhoto", requestModel.JpegPhoto); await _ldap.UpdateUser(uid, "jpegPhoto", requestModel.JpegPhoto);
} }
if (requestModel.UserPassword is not null && requestModel.UserPassword.Length > 0) if (requestModel.UserPassword is not null && requestModel.UserPassword.Length > 0)
{ {
AdminSettingsModel settings = await settingsTask; requestModel.UserPassword = await UsersHelper.HashPassword(_ldap, requestModel.UserPassword);
byte[] passwordBytes = Encoding.UTF8.GetBytes(requestModel.UserPassword);
byte[] hashedPassword = settings.hashAlgorithm?.ComputeHash(passwordBytes) ?? throw new Exception("Hash algorithm not instantiated yet");
requestModel.UserPassword = $"{{{settings.DefaultHashAlgorithm.ToUpperInvariant()}}}{Convert.ToBase64String(hashedPassword)}";
await _ldap.UpdateUser(uid, "userPassword", requestModel.UserPassword); await _ldap.UpdateUser(uid, "userPassword", requestModel.UserPassword);
} }

View File

@@ -1,6 +1,8 @@
using System.Globalization; using System.Globalization;
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Berufsschule_HAM.Models;
using Berufsschule_HAM.Services;
namespace Berufsschule_HAM.Helpers; namespace Berufsschule_HAM.Helpers;
public static partial class UsersHelper public static partial class UsersHelper
@@ -39,6 +41,14 @@ public static partial class UsersHelper
return cleaned; return cleaned;
} }
public static async Task<string> HashPassword(LdapService ldapService, string password)
{
AdminSettingsModel settings = await ldapService.GetAdminSettingsModelAsync();
byte[] passwordBytes = Encoding.UTF8.GetBytes(password);
byte[] hashedPassword = settings.hashAlgorithm?.ComputeHash(passwordBytes) ?? throw new Exception("Hash algorithm not instantiated yet");
return $"{{{settings.DefaultHashAlgorithm.ToUpperInvariant()}}}{Convert.ToBase64String(hashedPassword)}";
}
[GeneratedRegex("[^a-z]")] [GeneratedRegex("[^a-z]")]
private static partial Regex AtoZ(); private static partial Regex AtoZ();
} }

View File

@@ -2,6 +2,15 @@ using System.Text.Json.Serialization;
namespace Berufsschule_HAM.Models; namespace Berufsschule_HAM.Models;
public class UserUpdateRequestModel
{
[JsonPropertyName("Password")]
public string? Password { get; set; }
[JsonPropertyName("Image")]
public string? Image { get; set; }
}
public class AdminUpdateRequestModel public class AdminUpdateRequestModel
{ {
[JsonPropertyName("AdminSettingsModel")] [JsonPropertyName("AdminSettingsModel")]

View File

@@ -2,6 +2,14 @@ using System.Text.Json.Serialization;
namespace Berufsschule_HAM.Models; namespace Berufsschule_HAM.Models;
public class UserUpdateResponseModel
{
[JsonPropertyName("Success")]
public required bool Success { get; set; }
[JsonPropertyName("ErrorMessage")]
public string? ErrorMessage { get; set; }
}
public class AdminUpdateResponseModel public class AdminUpdateResponseModel
{ {
[JsonPropertyName("Success")] [JsonPropertyName("Success")]

View File

@@ -0,0 +1,6 @@
using Berufsschule_HAM.Models;
public class UserSettingsModel
{
public required UserModel userModel;
}

View File

@@ -13,8 +13,8 @@
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, ...</value> <value>System.Resources.ResXResourceWriter, System.Windows.Forms, ...</value>
</resheader> </resheader>
<data name="Users" xml:space="preserve"> <data name="Admin settings" xml:space="preserve">
<value>Benutzer</value> <value>Administration</value>
</data> </data>
<data name="General settings" xml:space="preserve"> <data name="General settings" xml:space="preserve">

View File

@@ -0,0 +1,67 @@
<?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="User settings" xml:space="preserve">
<value>Einstellungen</value>
</data>
<data name="Personal data" xml:space="preserve">
<value>Persönliche Daten</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="Birth date" xml:space="preserve">
<value>Geburtsdatum</value>
</data>
<data name="Address" xml:space="preserve">
<value>Adresse</value>
</data>
<data name="Account" xml:space="preserve">
<value>Konto</value>
</data>
<data name="Workplace" xml:space="preserve">
<value>Arbeitsplatz</value>
</data>
<data name="Password" xml:space="preserve">
<value>Passwort</value>
</data>
<data name="Photo" xml:space="preserve">
<value>Profilbild</value>
</data>
<data name="Apply settings" xml:space="preserve">
<value>Änderungen anwenden</value>
</data>
<data name="Password must be at least 8 characters long and include upper, lower, number, and special character" xml:space="preserve">
<value>Passwort muss mindestens 8 Zeichen lang sein und Groß- und Kleinbuchstaben, sowie Zahlen und Sonderzeichen enthalten</value>
</data>
<data name="Settings updated successfully" xml:space="preserve">
<value>Einstellungen erfolgreich aktualisiert</value>
</data>
<data name="Unknown error" xml:space="preserve">
<value>Unbekannter Fehler</value>
</data>
<data name="Error contacting server" xml:space="preserve">
<value>Fehler bei der Kommunikation mit dem Server</value>
</data>
</root>

View File

@@ -10,7 +10,7 @@
@inject IViewLocalizer T @inject IViewLocalizer T
@inject LdapService ldap @inject LdapService ldap
@{ @{
ViewData["Title"] = T["Users"]; ViewData["Title"] = T["Admin settings"];
List<string> supportedBarcodeTypes = ["code128c", "ean13", "ean8", "upc", "itf14", "itf"]; List<string> supportedBarcodeTypes = ["code128c", "ean13", "ean8", "upc", "itf14", "itf"];
string userImageCacheSize = ImageHelper.ToHumanReadableSize(ImageHelper.GetImageCacheSize()); string userImageCacheSize = ImageHelper.ToHumanReadableSize(ImageHelper.GetImageCacheSize());
} }

View File

@@ -1 +1,132 @@
<h3>Empty view lol</h3> @using Microsoft.AspNetCore.Mvc.Localization
@using Berufsschule_HAM.Models
@model UserSettingsModel
@inject IViewLocalizer T
@inject IConfiguration Configuration
@{
ViewData["Title"] = T["User settings"];
string barcodeType = Configuration["BarcodeType"] ?? "EAN13";
}
<form id="updateSettings" method="post" asp-controller="Settings" asp-action="User">
<h4 class="fw-bold">@T["Account"]</h4>
<div class="row g-3 mb-3">
<div class="col-md-4">
<label class="form-label" for="username">@T["Username"]</label>
<input type="text" id="username" class="form-control" value="@Model.userModel.Uid" readonly/>
</div>
<div class="col-md-4">
<label class="form-label" for="password">@T["Password"]</label>
<input type="password" id="password" name="Password" class="form-control" />
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-4">
<label class="form-label" for="updatePhotoFile">@T["Photo"]</label>
<input type="file" id="updatePhotoFile" accept="image/*" class="form-control" />
<input type="hidden" id="updateJpegPhoto" name="Image" />
<div class="mt-2 text-center">
<img id="updatePhotoPreview" src="" alt="Preview" class="img-thumbnail" style="max-height: 150px; display: none;" />
</div>
</div>
</div>
<h4 class="fw-bold">@T["Personal data"]</h4>
<div class="row g-3 mb-3">
<div class="col-md-1">
<label class="form-label" for="title">@T["Title"]</label>
<input type="text" id="title" class="form-control" value="@Model.userModel.Title" readonly/>
</div>
<div class="col-md-4">
<label class="form-label" for="name">@T["Name"]</label>
<input type="text" id="name" class="form-control" value="@Model.userModel.Cn" readonly/>
</div>
<div class="col-md-4">
<label class="form-label" for="surname">@T["Surname"]</label>
<input type="text" id="surname" class="form-control" value="@Model.userModel.Sn" readonly/>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-2">
<label class="form-label" for="birthdate">@T["Birth date"]</label>
<input type="text" id="birthdate" class="form-control" value="@Model.userModel.Description?.BirthDate" readonly/>
</div>
<div class="col-md-4">
<label class="form-label" for="address">@T["Address"]</label>
<input type="text" id="address" class="form-control" value="@Model.userModel.Description?.BirthDate" readonly/>
</div>
<div class="col-md-3">
<label class="form-label" for="workplace">@T["Workplace"]</label>
<input type="text" id="workplace" class="form-control" value="@Model.userModel.Description?.Workplace" readonly/>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-12">
<button type="submit" class="btn btn-warning float-end mt-3">@T["Apply settings"]</button>
</div>
</div>
</form>
<script defer>
document.addEventListener('DOMContentLoaded', () => {
const photoInput = document.getElementById('updatePhotoFile');
const photoPreview = document.getElementById('updatePhotoPreview');
const photoHidden = document.getElementById('updateJpegPhoto');
const updateForm = document.getElementById('updateSettings');
updateForm.addEventListener('submit', async e => {
e.preventDefault();
const password = updateForm.querySelector('#password').value;
if (password != null && password.length > 0 && !validatePassword(password)) {
showToast('@T["Password must be at least 8 characters long and include upper, lower, number, and special character"]', 'danger');
return;
}
const url = `/Settings/User`;
const dataFromEntries = Object.fromEntries(new FormData(updateForm).entries());
var data = unflatten(dataFromEntries);
try {
const response = await fetch(url, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(data)
});
const result = await response.json();
if (result.Success) {
showToast('@T["Settings updated successfully"]', 'success');
let userDropdown = document.getElementById("userDropdown");
userDropdown.children[0].src = userDropdown.children[0].src + "&cachebuster=" + Date.now(); // Force refresh user image
photoHidden.value = "";
photoPreview.src = "";
photoPreview.style.display = 'none';
updateForm.reset();
} else {
showToast(`${result.reason}: ${result.exception || '@T["Unknown error"]'}`, 'danger');
}
} catch (error) {
console.error(error);
showToast('@T["Error contacting server"]', 'danger');
}
});
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>

View File

@@ -103,12 +103,12 @@
{ {
<ul class="navbar-nav ms-auto"> <ul class="navbar-nav ms-auto">
<li class="nav-item dropdown"> <li class="nav-item dropdown">
<div class="nav-link dropdown-toggle d-flex align-items-center" href="#" id="userDropdown" role="button" <a class="nav-link dropdown-toggle d-flex align-items-center" href="#" id="userDropdown" role="button"
data-bs-toggle="dropdown" aria-expanded="false"> data-bs-toggle="dropdown" aria-expanded="false">
<img src="/Home/UserPhoto?uid=@User.Identity.Name&size=30" alt="Profile" <img src="/Home/UserPhoto?uid=@User.Identity.Name&size=30" alt="Profile"
class="rounded-circle me-2" width="30" height="30" alt="Photo" /> class="rounded-circle me-2" width="30" height="30" alt="Photo" />
<span>@User.Identity.Name</span> <span>@User.Identity.Name</span>
</div> </a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="userDropdown"> <ul class="dropdown-menu dropdown-menu-end" aria-labelledby="userDropdown">
<li><a class="dropdown-item" asp-controller="Settings" asp-action="User">@T["User settings"]</a></li> <li><a class="dropdown-item" asp-controller="Settings" asp-action="User">@T["User settings"]</a></li>
@if (User.HasClaim(ClaimTypes.Role, "CanManageSettings")) @if (User.HasClaim(ClaimTypes.Role, "CanManageSettings"))

View File

@@ -78,7 +78,14 @@ h4.fw-bold, h4.card-title {
} }
.skip-link:focus { .skip-link:focus {
left: 10px; left: 10px !important;
top: 10px; top: 10px;
outline: none; outline: none;
} }
input[readonly] {
background-color: #343a40 !important;
box-shadow: none;
opacity: 1;
border-color: var(--bs-border-color) !important;
}