Split Admin module into Status and User Management under one sidemenu dropdown
Playwright Tests / test (push) Canceled after 0s
Playwright Tests / test (push) Canceled after 0s
Splits fn=admin into two independent modules: the original status dashboard stays at fn=admin, user management moves to a new fn=usr (IntranetController.AdminUsers.cs, fis.usr.js). Both are now reached through a single "Administration" sidemenu dropdown (Systemüberwachung / Benutzerverwaltung) gated by one shared fds_sys > 4 check, per ADR 0015 (supersedes 0014's "fold into one module" choice). Also fixes the password-reset validation to check only a plain 6-char minimum instead of silently also requiring ValidatePassword's default 2-uppercase/2-lowercase/2-digit composition, which contradicted the "mind. 6 Zeichen" message shown to the admin. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,8 @@
|
||||
using System.Web;
|
||||
using Fuchs.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Serialization;
|
||||
using OCORE.SQL;
|
||||
using static OCORE.commons;
|
||||
using static OCORE.OCORE_dictionaries;
|
||||
using static OCORE.SQL.sql;
|
||||
using static OCORE.web.mvc_helper_async;
|
||||
|
||||
@@ -19,15 +15,12 @@ namespace Fuchs.Controllers;
|
||||
// but every data endpoint here ALSO enforces the level server-side (defense in depth) — the
|
||||
// passive/probe data is diagnostic and must never be reachable by a lower-privileged session.
|
||||
//
|
||||
// The user-management endpoints ("users"/"userpwdreset"/"useremail"/"usergroups"/"usergroupset")
|
||||
// below share that same fds_sys > 4 gate, but the fis_admin_* stored procs/functions they call
|
||||
// enforce a SEPARATE, independent authorization axis: the per-user "intranet_admin" setting via
|
||||
// fis_admin_getUserAuth(@authaccount) > 1. An account with fds_sys > 4 but no intranet_admin
|
||||
// setting will reach these endpoints yet get empty results / no-op writes from SQL. See
|
||||
// Docs/Decisions for the ADR covering this — accounts doing user management need BOTH.
|
||||
// The separate "usr" module (IntranetController.AdminUsers.cs, fis.usr.js) handles user
|
||||
// management and shares the same fds_sys > 4 gate/AdminJson/GetSystemAdminAuthAsync helpers
|
||||
// defined here — see Docs/Decisions/0015-split-admin-user-management-module.md.
|
||||
public partial class IntranetController
|
||||
{
|
||||
// fds_sys authorization must exceed this to use the Admin module.
|
||||
// fds_sys authorization must exceed this to use the Admin/AdminUsers modules.
|
||||
private const int AdminMinAuthExclusive = 4;
|
||||
|
||||
private async Task<IActionResult> Do_Process_Admin(string fn, string id, string code)
|
||||
@@ -83,183 +76,6 @@ public partial class IntranetController
|
||||
return AdminJson(new { result });
|
||||
}
|
||||
|
||||
case "users":
|
||||
{
|
||||
var res = await getSQLDatatable_async(
|
||||
"SELECT * FROM [dbo].[fis_admin_getUserAccountList](@authaccount, NULL, NULL, NULL) ORDER BY [order];",
|
||||
_intranet.Intranet__SQLConnectionString,
|
||||
StdParamlist(SQL_VarChar("@authaccount", UserAccountID)),
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
return AdminJson(new { users = res.DataTable.toArrayofObjectDictionaries() });
|
||||
}
|
||||
|
||||
case "userpwdreset":
|
||||
{
|
||||
if (!HasForm("useraccount_id")) return BadRequest400();
|
||||
string targetId = Form("useraccount_id");
|
||||
string newPassword = Form("newpassword");
|
||||
if (!string.IsNullOrWhiteSpace(newPassword) && !newPassword.ValidatePassword(minLength: 6, numSpecial: 0))
|
||||
{
|
||||
_logger.LogWarning("Admin userpwdreset: password requirements not met, target={Target} user={User}",
|
||||
targetId, UserAccountID);
|
||||
return StatusCode(409, new { error = "requirements" });
|
||||
}
|
||||
|
||||
bool success;
|
||||
string generatedPassword;
|
||||
try
|
||||
{
|
||||
var pl = StdParamlist(
|
||||
SQL_VarChar("@authaccount", UserAccountID),
|
||||
SQL_VarChar("@useraccount_id", targetId),
|
||||
SQL_NVarChar("@newpassword", newPassword, dbNull_IfEmpty: true));
|
||||
var res = await getSQLDatatable_async(
|
||||
"EXECUTE [dbo].[fis_admin_resetUserPassword] @authaccount, @useraccount_id, @enc_key, @newpassword;",
|
||||
_intranet.Intranet__SQLConnectionString, pl,
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
var row = res.FirstRow;
|
||||
success = row["success"] is true;
|
||||
generatedPassword = row.nz("newpassword");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await NotifyAdminUserActionFailedAsync("Passwort-Reset fehlgeschlagen",
|
||||
$"Der Passwort-Reset für Benutzerkonto {targetId} konnte nicht durchgeführt werden.",
|
||||
targetId, ex);
|
||||
return StatusCode(500, new { error = "reset_failed" });
|
||||
}
|
||||
|
||||
if (!success)
|
||||
{
|
||||
_logger.LogWarning("Admin userpwdreset not successful target={Target} user={User}", targetId, UserAccountID);
|
||||
return StatusCode(500, new { error = "reset_failed" });
|
||||
}
|
||||
|
||||
var userRow = (await getSQLDatatable_async(
|
||||
"SELECT * FROM [dbo].[fis_admin_getUserAccountList](@authaccount, @useraccount_id, NULL, NULL);",
|
||||
_intranet.Intranet__SQLConnectionString,
|
||||
StdParamlist(SQL_VarChar("@authaccount", UserAccountID), SQL_VarChar("@useraccount_id", targetId)),
|
||||
Security: DbSec, options: SqlOpt(fn, id, code))).FirstRow;
|
||||
string targetEmail = userRow.nz("email");
|
||||
string targetName = $"{userRow.nz("firstname")} {userRow.nz("name")}".Trim();
|
||||
|
||||
var (subject, html) = BuildPasswordResetEmail(targetName, generatedPassword);
|
||||
bool emailed = targetEmail.Length > 5 &&
|
||||
await _comService.SendEmailAsync("pwreset_" + targetEmail, subject, html, targetEmail, targetName, null);
|
||||
|
||||
if (!emailed)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Admin userpwdreset: password not delivered target={Target} hasEmail={HasEmail} user={User}",
|
||||
targetId, targetEmail.Length > 5, UserAccountID);
|
||||
await _events.UserIssueAsync("Passwort-Reset: E-Mail-Versand fehlgeschlagen",
|
||||
$"Das neue Passwort für Benutzerkonto {targetId} konnte nicht per E-Mail zugestellt werden.",
|
||||
UserAccountID, new Dictionary<string, object?> { ["target"] = targetId, ["email"] = targetEmail });
|
||||
}
|
||||
|
||||
_logger.LogInformation("Admin userpwdreset succeeded target={Target} emailed={Emailed} user={User}",
|
||||
targetId, emailed, UserAccountID);
|
||||
return AdminJson(new { ok = true, emailed });
|
||||
}
|
||||
|
||||
case "useremail":
|
||||
{
|
||||
if (!HasForm("useraccount_id", "email")) return BadRequest400();
|
||||
string targetId = Form("useraccount_id");
|
||||
string newEmail = Form("email");
|
||||
|
||||
var currentRow = (await getSQLDatatable_async(
|
||||
"SELECT * FROM [dbo].[fis_admin_getUserAccountList](@authaccount, @useraccount_id, NULL, NULL);",
|
||||
_intranet.Intranet__SQLConnectionString,
|
||||
StdParamlist(SQL_VarChar("@authaccount", UserAccountID), SQL_VarChar("@useraccount_id", targetId)),
|
||||
Security: DbSec, options: SqlOpt(fn, id, code))).FirstRow;
|
||||
if (string.IsNullOrEmpty(currentRow.nz("useraccount_id")))
|
||||
return BadRequest400();
|
||||
|
||||
var pl = StdParamlist(
|
||||
SQL_VarChar("@authaccount", UserAccountID),
|
||||
SQL_VarChar("@useraccount_id", targetId),
|
||||
SQL_NVarChar("@name", currentRow.nz("name")),
|
||||
SQL_NVarChar("@firstname", currentRow.nz("firstname"), dbNull_IfEmpty: true),
|
||||
SQL_VarChar("@title", currentRow.nz("title"), dbNull_IfEmpty: true),
|
||||
SQL_VarChar("@gender", currentRow.nz("gender"), dbNull_IfEmpty: true),
|
||||
SQL_VarChar("@email", newEmail),
|
||||
SQL_VarChar("@mobile", currentRow.nz("mobile"), dbNull_IfEmpty: true),
|
||||
SQL_NVarChar("@password", null, dbNull_IfEmpty: true));
|
||||
|
||||
try
|
||||
{
|
||||
await getSQLDatatable_async(
|
||||
"EXECUTE [dbo].[fis_admin_setUserAccount] @authaccount, @useraccount_id, @name, @firstname, " +
|
||||
"@title, @gender, @email, @mobile, @password, @enc_key;",
|
||||
_intranet.Intranet__SQLConnectionString, pl,
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
}
|
||||
catch (SqlException ex) when (ex.Number == 60001)
|
||||
{
|
||||
_logger.LogWarning("Admin useremail: duplicate email rejected target={Target} user={User}",
|
||||
targetId, UserAccountID);
|
||||
return StatusCode(409, new { error = "duplicate_email" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await NotifyAdminUserActionFailedAsync("E-Mail-Änderung fehlgeschlagen",
|
||||
$"Die E-Mail-Adresse für Benutzerkonto {targetId} konnte nicht geändert werden.",
|
||||
targetId, ex);
|
||||
return StatusCode(500, new { error = "change_failed" });
|
||||
}
|
||||
|
||||
_logger.LogInformation("Admin useremail changed target={Target} user={User}", targetId, UserAccountID);
|
||||
return AdminJson(new { ok = true });
|
||||
}
|
||||
|
||||
case "usergroups":
|
||||
{
|
||||
if (!HasForm("useraccount_id")) return BadRequest400();
|
||||
var res = await getSQLDatatable_async(
|
||||
"SELECT * FROM [dbo].[fis_admin_getUserGroupMembership](@authaccount, @useraccount_id) ORDER BY [order];",
|
||||
_intranet.Intranet__SQLConnectionString,
|
||||
StdParamlist(
|
||||
SQL_VarChar("@authaccount", UserAccountID),
|
||||
SQL_VarChar("@useraccount_id", Form("useraccount_id"))),
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
return AdminJson(new { groups = res.DataTable.toArrayofObjectDictionaries() });
|
||||
}
|
||||
|
||||
case "usergroupset":
|
||||
{
|
||||
if (!HasForm("useraccount_id", "usergroup_id", "member")) return BadRequest400();
|
||||
string targetId = Form("useraccount_id");
|
||||
string groupId = Form("usergroup_id");
|
||||
bool member = Form("member").Equals("true", StringComparison.OrdinalIgnoreCase) || Form("member") == "1";
|
||||
|
||||
var pl = StdParamlist(
|
||||
SQL_VarChar("@authaccount", UserAccountID),
|
||||
SQL_VarChar("@usergroup_id", groupId),
|
||||
SQL_VarChar("@useraccount_id", targetId));
|
||||
try
|
||||
{
|
||||
var res = member
|
||||
? await getSQLDatatable_async(
|
||||
"EXECUTE [dbo].[fis_admin_addGroupMember] @authaccount, @usergroup_id, @useraccount_id;",
|
||||
_intranet.Intranet__SQLConnectionString, pl, Security: DbSec, options: SqlOpt(fn, id, code))
|
||||
: await getSQLDatatable_async(
|
||||
"EXECUTE [dbo].[fis_admin_remGroupMember] @authaccount, @usergroup_id, @useraccount_id;",
|
||||
_intranet.Intranet__SQLConnectionString, pl, Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
|
||||
_logger.LogInformation("Admin usergroupset target={Target} group={Group} member={Member} user={User}",
|
||||
targetId, groupId, member, UserAccountID);
|
||||
return AdminJson(new { ok = true, member = res.FirstRow[0] is true });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await NotifyAdminUserActionFailedAsync("Gruppenzuordnung fehlgeschlagen",
|
||||
$"Die Gruppenzuordnung für Benutzerkonto {targetId} konnte nicht geändert werden.",
|
||||
targetId, ex);
|
||||
return StatusCode(500, new { error = "change_failed" });
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
_logger.LogWarning("Admin: no handler for action={Action}, user={User}", id, UserAccountID);
|
||||
return BadRequest400();
|
||||
@@ -268,6 +84,7 @@ public partial class IntranetController
|
||||
|
||||
// The status DTOs (SystemInfoSnapshot / SystemProbeResult) are PascalCase; serialize them
|
||||
// camelCase so the Admin frontend contract matches the lowercase convention used elsewhere.
|
||||
// Shared with IntranetController.AdminUsers.cs.
|
||||
private static readonly JsonSerializerSettings CamelCaseJson = new()
|
||||
{
|
||||
ContractResolver = new CamelCasePropertyNamesContractResolver(),
|
||||
@@ -275,36 +92,15 @@ public partial class IntranetController
|
||||
|
||||
// Mirror OCORE's getJSONResult exactly (application/json; charset=utf-8) so the response
|
||||
// parses identically to every other endpoint the frontend's postXT talks to.
|
||||
// Shared with IntranetController.AdminUsers.cs.
|
||||
private ContentResult AdminJson(object payload) =>
|
||||
Content(JsonConvert.SerializeObject(payload, CamelCaseJson), "application/json; charset=utf-8");
|
||||
|
||||
/// <summary>
|
||||
/// Builds the subject/HTML body for the "your password was reset by an admin" notification.
|
||||
/// Extracted as pure logic (no DB/HTTP) so the content — including HTML-encoding of the
|
||||
/// password — is unit-testable without instantiating the controller.
|
||||
/// </summary>
|
||||
internal static (string Subject, string Html) BuildPasswordResetEmail(string targetName, string newPassword) => (
|
||||
"sanitaerfuchs.de Intranet Passwort zurückgesetzt",
|
||||
$"<p>Guten Tag {targetName},<br/>Ihr Passwort wurde von einem Administrator zurückgesetzt.<br/>" +
|
||||
$"Ihr neues Passwort: {HttpUtility.HtmlEncode(newPassword)}</p>");
|
||||
|
||||
/// <summary>
|
||||
/// Logs and raises a user-facing notification for an admin user-management action that
|
||||
/// threw unexpectedly, per the "interrupting exceptions notify the user" rule
|
||||
/// (<see href="../Docs/Decisions/0003-interrupting-exceptions-notify-the-user.md"/>) —
|
||||
/// the admin performing the action must never be left believing it silently succeeded.
|
||||
/// </summary>
|
||||
private async Task NotifyAdminUserActionFailedAsync(string title, string message, string targetId, Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "{Title} target={Target} user={User}", title, targetId, UserAccountID);
|
||||
await _events.UserIssueAsync(title, message, UserAccountID,
|
||||
new Dictionary<string, object?> { ["target"] = targetId });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the calling user's <c>fds_sys</c> module authorization level via the
|
||||
/// <c>fis_getModuleAuth</c> SQL function (same mechanism as <see cref="HandleAuth"/>).
|
||||
/// Returns -3 when it cannot be determined (fail-closed).
|
||||
/// Returns -3 when it cannot be determined (fail-closed). Shared with
|
||||
/// IntranetController.AdminUsers.cs.
|
||||
/// </summary>
|
||||
private async Task<int> GetSystemAdminAuthAsync(string fn, string id, string code)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
using System.Web;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using OCORE.SQL;
|
||||
using static OCORE.commons;
|
||||
using static OCORE.OCORE_dictionaries;
|
||||
using static OCORE.SQL.sql;
|
||||
using static OCORE.web.mvc_helper_async;
|
||||
|
||||
namespace Fuchs.Controllers;
|
||||
|
||||
// Partial class: Administration user-management module (fn=usr).
|
||||
//
|
||||
// Split out of the Admin module (see Docs/Decisions/0015-split-admin-user-management-module.md,
|
||||
// which superseded 0014) so status diagnostics and user management have separate sidemenu
|
||||
// buttons. Shares the Admin module's fds_sys > 4 gate (AdminMinAuthExclusive,
|
||||
// GetSystemAdminAuthAsync, AdminJson — all in IntranetController.Admin.cs).
|
||||
//
|
||||
// The fis_admin_* stored procs/functions called below enforce a SEPARATE, independent
|
||||
// authorization axis: the per-user "intranet_admin" setting via
|
||||
// fis_admin_getUserAuth(@authaccount) > 1. An account with fds_sys > 4 but no intranet_admin
|
||||
// setting will reach these endpoints yet get empty results / no-op writes from SQL — accounts
|
||||
// doing user management need BOTH.
|
||||
public partial class IntranetController
|
||||
{
|
||||
private async Task<IActionResult> Do_Process_AdminUsers(string fn, string id, string code)
|
||||
{
|
||||
_logger.LogDebug("Do_Process_AdminUsers action={Action} code={Code} user={User}", id, code, UserAccountID);
|
||||
|
||||
int authLevel = await GetSystemAdminAuthAsync(fn, id, code);
|
||||
bool authorized = authLevel > AdminMinAuthExclusive;
|
||||
|
||||
if (id.Equals("auth", StringComparison.OrdinalIgnoreCase))
|
||||
return await JSONAsync(new { manage = authorized ? 1 : 0, level = authLevel });
|
||||
|
||||
if (!authorized)
|
||||
{
|
||||
_logger.LogWarning("AdminUsers access denied for user={User} (fds_sys={Level}) action={Action}",
|
||||
UserAccountID, authLevel, id);
|
||||
return Unauthorized401();
|
||||
}
|
||||
|
||||
switch (id.ToLowerInvariant())
|
||||
{
|
||||
case "list":
|
||||
{
|
||||
var res = await getSQLDatatable_async(
|
||||
"SELECT * FROM [dbo].[fis_admin_getUserAccountList](@authaccount, NULL, NULL, NULL) ORDER BY [order];",
|
||||
_intranet.Intranet__SQLConnectionString,
|
||||
StdParamlist(SQL_VarChar("@authaccount", UserAccountID)),
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
return AdminJson(new { users = res.DataTable.toArrayofObjectDictionaries() });
|
||||
}
|
||||
|
||||
case "pwdreset":
|
||||
{
|
||||
if (!HasForm("useraccount_id")) return BadRequest400();
|
||||
string targetId = Form("useraccount_id");
|
||||
string newPassword = Form("newpassword");
|
||||
// Only a plain minimum length is advertised to the admin (see u_pwd_hint) — don't
|
||||
// silently also demand ValidatePassword's default upper/lower/digit composition.
|
||||
if (!string.IsNullOrWhiteSpace(newPassword) &&
|
||||
!newPassword.ValidatePassword(minLength: 6, numUpper: 0, numLower: 0, numNumbers: 0, numSpecial: 0))
|
||||
{
|
||||
_logger.LogWarning("AdminUsers pwdreset: password requirements not met, target={Target} user={User}",
|
||||
targetId, UserAccountID);
|
||||
return StatusCode(409, new { error = "requirements" });
|
||||
}
|
||||
|
||||
bool success;
|
||||
string generatedPassword;
|
||||
try
|
||||
{
|
||||
var pl = StdParamlist(
|
||||
SQL_VarChar("@authaccount", UserAccountID),
|
||||
SQL_VarChar("@useraccount_id", targetId),
|
||||
SQL_NVarChar("@newpassword", newPassword, dbNull_IfEmpty: true));
|
||||
var res = await getSQLDatatable_async(
|
||||
"EXECUTE [dbo].[fis_admin_resetUserPassword] @authaccount, @useraccount_id, @enc_key, @newpassword;",
|
||||
_intranet.Intranet__SQLConnectionString, pl,
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
var row = res.FirstRow;
|
||||
success = row["success"] is true;
|
||||
generatedPassword = row.nz("newpassword");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await NotifyAdminUserActionFailedAsync("Passwort-Reset fehlgeschlagen",
|
||||
$"Der Passwort-Reset für Benutzerkonto {targetId} konnte nicht durchgeführt werden.",
|
||||
targetId, ex);
|
||||
return StatusCode(500, new { error = "reset_failed" });
|
||||
}
|
||||
|
||||
if (!success)
|
||||
{
|
||||
_logger.LogWarning("AdminUsers pwdreset not successful target={Target} user={User}", targetId, UserAccountID);
|
||||
return StatusCode(500, new { error = "reset_failed" });
|
||||
}
|
||||
|
||||
var userRow = (await getSQLDatatable_async(
|
||||
"SELECT * FROM [dbo].[fis_admin_getUserAccountList](@authaccount, @useraccount_id, NULL, NULL);",
|
||||
_intranet.Intranet__SQLConnectionString,
|
||||
StdParamlist(SQL_VarChar("@authaccount", UserAccountID), SQL_VarChar("@useraccount_id", targetId)),
|
||||
Security: DbSec, options: SqlOpt(fn, id, code))).FirstRow;
|
||||
string targetEmail = userRow.nz("email");
|
||||
string targetName = $"{userRow.nz("firstname")} {userRow.nz("name")}".Trim();
|
||||
|
||||
var (subject, html) = BuildPasswordResetEmail(targetName, generatedPassword);
|
||||
bool emailed = targetEmail.Length > 5 &&
|
||||
await _comService.SendEmailAsync("pwreset_" + targetEmail, subject, html, targetEmail, targetName, null);
|
||||
|
||||
if (!emailed)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"AdminUsers pwdreset: password not delivered target={Target} hasEmail={HasEmail} user={User}",
|
||||
targetId, targetEmail.Length > 5, UserAccountID);
|
||||
await _events.UserIssueAsync("Passwort-Reset: E-Mail-Versand fehlgeschlagen",
|
||||
$"Das neue Passwort für Benutzerkonto {targetId} konnte nicht per E-Mail zugestellt werden.",
|
||||
UserAccountID, new Dictionary<string, object?> { ["target"] = targetId, ["email"] = targetEmail });
|
||||
}
|
||||
|
||||
_logger.LogInformation("AdminUsers pwdreset succeeded target={Target} emailed={Emailed} user={User}",
|
||||
targetId, emailed, UserAccountID);
|
||||
return AdminJson(new { ok = true, emailed });
|
||||
}
|
||||
|
||||
case "email":
|
||||
{
|
||||
if (!HasForm("useraccount_id", "email")) return BadRequest400();
|
||||
string targetId = Form("useraccount_id");
|
||||
string newEmail = Form("email");
|
||||
|
||||
var currentRow = (await getSQLDatatable_async(
|
||||
"SELECT * FROM [dbo].[fis_admin_getUserAccountList](@authaccount, @useraccount_id, NULL, NULL);",
|
||||
_intranet.Intranet__SQLConnectionString,
|
||||
StdParamlist(SQL_VarChar("@authaccount", UserAccountID), SQL_VarChar("@useraccount_id", targetId)),
|
||||
Security: DbSec, options: SqlOpt(fn, id, code))).FirstRow;
|
||||
if (string.IsNullOrEmpty(currentRow.nz("useraccount_id")))
|
||||
return BadRequest400();
|
||||
|
||||
var pl = StdParamlist(
|
||||
SQL_VarChar("@authaccount", UserAccountID),
|
||||
SQL_VarChar("@useraccount_id", targetId),
|
||||
SQL_NVarChar("@name", currentRow.nz("name")),
|
||||
SQL_NVarChar("@firstname", currentRow.nz("firstname"), dbNull_IfEmpty: true),
|
||||
SQL_VarChar("@title", currentRow.nz("title"), dbNull_IfEmpty: true),
|
||||
SQL_VarChar("@gender", currentRow.nz("gender"), dbNull_IfEmpty: true),
|
||||
SQL_VarChar("@email", newEmail),
|
||||
SQL_VarChar("@mobile", currentRow.nz("mobile"), dbNull_IfEmpty: true),
|
||||
SQL_NVarChar("@password", null, dbNull_IfEmpty: true));
|
||||
|
||||
try
|
||||
{
|
||||
await getSQLDatatable_async(
|
||||
"EXECUTE [dbo].[fis_admin_setUserAccount] @authaccount, @useraccount_id, @name, @firstname, " +
|
||||
"@title, @gender, @email, @mobile, @password, @enc_key;",
|
||||
_intranet.Intranet__SQLConnectionString, pl,
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
}
|
||||
catch (SqlException ex) when (ex.Number == 60001)
|
||||
{
|
||||
_logger.LogWarning("AdminUsers email: duplicate email rejected target={Target} user={User}",
|
||||
targetId, UserAccountID);
|
||||
return StatusCode(409, new { error = "duplicate_email" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await NotifyAdminUserActionFailedAsync("E-Mail-Änderung fehlgeschlagen",
|
||||
$"Die E-Mail-Adresse für Benutzerkonto {targetId} konnte nicht geändert werden.",
|
||||
targetId, ex);
|
||||
return StatusCode(500, new { error = "change_failed" });
|
||||
}
|
||||
|
||||
_logger.LogInformation("AdminUsers email changed target={Target} user={User}", targetId, UserAccountID);
|
||||
return AdminJson(new { ok = true });
|
||||
}
|
||||
|
||||
case "groups":
|
||||
{
|
||||
if (!HasForm("useraccount_id")) return BadRequest400();
|
||||
var res = await getSQLDatatable_async(
|
||||
"SELECT * FROM [dbo].[fis_admin_getUserGroupMembership](@authaccount, @useraccount_id) ORDER BY [order];",
|
||||
_intranet.Intranet__SQLConnectionString,
|
||||
StdParamlist(
|
||||
SQL_VarChar("@authaccount", UserAccountID),
|
||||
SQL_VarChar("@useraccount_id", Form("useraccount_id"))),
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
return AdminJson(new { groups = res.DataTable.toArrayofObjectDictionaries() });
|
||||
}
|
||||
|
||||
case "groupset":
|
||||
{
|
||||
if (!HasForm("useraccount_id", "usergroup_id", "member")) return BadRequest400();
|
||||
string targetId = Form("useraccount_id");
|
||||
string groupId = Form("usergroup_id");
|
||||
bool member = Form("member").Equals("true", StringComparison.OrdinalIgnoreCase) || Form("member") == "1";
|
||||
|
||||
var pl = StdParamlist(
|
||||
SQL_VarChar("@authaccount", UserAccountID),
|
||||
SQL_VarChar("@usergroup_id", groupId),
|
||||
SQL_VarChar("@useraccount_id", targetId));
|
||||
try
|
||||
{
|
||||
var res = member
|
||||
? await getSQLDatatable_async(
|
||||
"EXECUTE [dbo].[fis_admin_addGroupMember] @authaccount, @usergroup_id, @useraccount_id;",
|
||||
_intranet.Intranet__SQLConnectionString, pl, Security: DbSec, options: SqlOpt(fn, id, code))
|
||||
: await getSQLDatatable_async(
|
||||
"EXECUTE [dbo].[fis_admin_remGroupMember] @authaccount, @usergroup_id, @useraccount_id;",
|
||||
_intranet.Intranet__SQLConnectionString, pl, Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
|
||||
_logger.LogInformation("AdminUsers groupset target={Target} group={Group} member={Member} user={User}",
|
||||
targetId, groupId, member, UserAccountID);
|
||||
return AdminJson(new { ok = true, member = res.FirstRow[0] is true });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await NotifyAdminUserActionFailedAsync("Gruppenzuordnung fehlgeschlagen",
|
||||
$"Die Gruppenzuordnung für Benutzerkonto {targetId} konnte nicht geändert werden.",
|
||||
targetId, ex);
|
||||
return StatusCode(500, new { error = "change_failed" });
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
_logger.LogWarning("AdminUsers: no handler for action={Action}, user={User}", id, UserAccountID);
|
||||
return BadRequest400();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the subject/HTML body for the "your password was reset by an admin" notification.
|
||||
/// Extracted as pure logic (no DB/HTTP) so the content — including HTML-encoding of the
|
||||
/// password — is unit-testable without instantiating the controller.
|
||||
/// </summary>
|
||||
internal static (string Subject, string Html) BuildPasswordResetEmail(string targetName, string newPassword) => (
|
||||
"sanitaerfuchs.de Intranet Passwort zurückgesetzt",
|
||||
$"<p>Guten Tag {targetName},<br/>Ihr Passwort wurde von einem Administrator zurückgesetzt.<br/>" +
|
||||
$"Ihr neues Passwort: {HttpUtility.HtmlEncode(newPassword)}</p>");
|
||||
|
||||
/// <summary>
|
||||
/// Logs and raises a user-facing notification for an admin user-management action that
|
||||
/// threw unexpectedly, per the "interrupting exceptions notify the user" rule
|
||||
/// (<see href="../Docs/Decisions/0003-interrupting-exceptions-notify-the-user.md"/>) —
|
||||
/// the admin performing the action must never be left believing it silently succeeded.
|
||||
/// </summary>
|
||||
private async Task NotifyAdminUserActionFailedAsync(string title, string message, string targetId, Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "{Title} target={Target} user={User}", title, targetId, UserAccountID);
|
||||
await _events.UserIssueAsync(title, message, UserAccountID,
|
||||
new Dictionary<string, object?> { ["target"] = targetId });
|
||||
}
|
||||
}
|
||||
@@ -167,6 +167,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
"rep" => await Do_Process_Reports(fn, id, code),
|
||||
"bam" => await Do_Process_Bankings(fn, id, code),
|
||||
"admin" => await Do_Process_Admin(fn, id, code),
|
||||
"usr" => await Do_Process_AdminUsers(fn, id, code),
|
||||
"auth" => await HandleAuth(fn, id, code),
|
||||
"spwc" => await HandleSendPasswordCode(fn, id, code),
|
||||
"spw" => await HandleSendPassword(fn, id, code),
|
||||
|
||||
Reference in New Issue
Block a user