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),
|
||||
|
||||
@@ -9,11 +9,9 @@ applyTo:
|
||||
- "Fuchs/js/intranet/modules/fis.admin*.js"
|
||||
- "Fuchs/js/intranet/modules/fis.admin.scss"
|
||||
- "Fuchs/js/intranet/fis_main_menu.js"
|
||||
- "Fuchs_Database/dbo/Functions/fis_admin_*.sql"
|
||||
- "Fuchs_Database/dbo/Stored Procedures/fis_admin_*.sql"
|
||||
relatedDecisions:
|
||||
- "0011-admin-module-system-status.md"
|
||||
- "0014-admin-user-management.md"
|
||||
- "0015-split-admin-user-management-module.md"
|
||||
---
|
||||
|
||||
# Admin / System-Status module
|
||||
@@ -28,10 +26,13 @@ Access is restricted to users whose `fds_sys` module authorization is greater th
|
||||
## How it works
|
||||
|
||||
### Authorization (two layers)
|
||||
1. **Menu visibility** — at page load `fis_main_menu.js#addAdminMenuIfAuthorized` calls
|
||||
`$fis.getAuth('fds_sys')`; only when the level is > 4 does it push the `init:admin`
|
||||
button into `$ocms.ocmsmenu` and re-render `#mainmenu`. Users below the threshold never
|
||||
see the button and never fetch the module script.
|
||||
1. **Menu visibility** — at page load `fis_main_menu.js#addAdminMenuButtonsIfAuthorized` calls
|
||||
`$fis.getAuth('fds_sys')` once (shared with the separate `usr` user-management module, see
|
||||
[0015](../Decisions/0015-split-admin-user-management-module.md)); only when the level is > 4
|
||||
does it push a single "Administration" dropdown entry into `$ocms.ocmsmenu` — containing this
|
||||
module's "Systemüberwachung" (`init:admin`) item alongside `usr`'s "Benutzerverwaltung"
|
||||
(`init:usr`) item — and re-render `#mainmenu`. Users below the threshold never see the
|
||||
dropdown and never fetch either module's script.
|
||||
2. **Server-side gate** — every `/do/admin/*` endpoint resolves
|
||||
`fis_getModuleAuth('fds_sys', authuser)` and returns 401 unless it is > 4. The only
|
||||
exception is `admin/auth`, which returns `{ manage: 0 }` for unauthorized users so the
|
||||
@@ -87,46 +88,18 @@ refresh button, the email card carries the test-email dialog. The topbar has an
|
||||
button that reloads the whole snapshot. Admin responses are serialized **camelCase** (the DTOs are
|
||||
PascalCase) so the JS reads `probe.status`, `info.database.server`, etc.
|
||||
|
||||
## User management (`users`/`userpwdreset`/`useremail`/`usergroups`/`usergroupset`)
|
||||
See [0014](../Decisions/0014-admin-user-management.md) for why this lives here and for the
|
||||
dual-authorization caveat (`fds_sys > 4` gates the endpoints; the SQL layer separately requires
|
||||
the caller's `intranet_admin` setting to be `> 1`).
|
||||
|
||||
A card below the status grid (`$adm.usersCard`) renders a table of every non-deleted user
|
||||
account (name, email, active/disabled) via `POST admin/users` →
|
||||
`fis_admin_getUserAccountList(@authaccount, NULL, NULL, NULL)`. Clicking a row toggles a
|
||||
per-row ribbon (`$adm.uMn`, replacing the topbar exactly like Banking's `iMn`/`eM` swap) with
|
||||
three actions:
|
||||
|
||||
- **Passwort zurücksetzen** — a dialog with one optional password field ("leave blank to
|
||||
auto-generate"). `POST admin/userpwdreset` calls `fis_admin_resetUserPassword` (now takes an
|
||||
optional `@newpassword`; `NULL` ⇒ the proc auto-generates one as before). On success the
|
||||
controller re-fetches the target's row (server-trusted email — never client-supplied) and
|
||||
emails the plaintext password via `IComService.SendEmailAsync` — the admin never sees it. The
|
||||
email subject/body is built by `IntranetController.BuildPasswordResetEmail` (pure, unit-tested
|
||||
in `Fuchs.Tests/AdminUserManagementTests.cs`). Failure to reset, or to deliver the email, calls
|
||||
`IEventService.UserIssueAsync` so the admin gets a visible notification either way.
|
||||
- **E-Mail ändern** — a dialog with the email field prefilled. `POST admin/useremail` fetches
|
||||
the current row and calls `fis_admin_setUserAccount` with only the email changed (other fields
|
||||
passed through unchanged, `@password = NULL` so the update path leaves the password alone). A
|
||||
duplicate email (`THROW 60001` in the proc) surfaces as an HTTP 409 `{ error: "duplicate_email" }`.
|
||||
- **Gruppenzugehörigkeit** — a dialog (`$adm.groupsDlg`) listing every group as a checkbox,
|
||||
populated via `POST admin/usergroups` → the new `fis_admin_getUserGroupMembership` function.
|
||||
Each checkbox toggle immediately posts `admin/usergroupset`, which calls
|
||||
`fis_admin_addGroupMember`/`remGroupMember` depending on the new state — no separate save step.
|
||||
|
||||
## Key files
|
||||
- `Fuchs/Controllers/IntranetController.Admin.cs` — `Do_Process_Admin` dispatch + auth gate + camelCase JSON helper + user-management endpoints.
|
||||
- `Fuchs/Controllers/IntranetController.Admin.cs` — `Do_Process_Admin` dispatch + shared auth gate/camelCase JSON helper (also used by the separate `usr` module, see [0015](../Decisions/0015-split-admin-user-management-module.md)).
|
||||
- `Fuchs/Services/ISystemStatusService.cs`, `SystemStatusService.cs`, `SystemStatusModels.cs` — diagnostics service + DTOs.
|
||||
- `Fuchs/Services/StartupCheckReport.cs` (`StartupCheckReporter` singleton) + `StartupSelfTestService.cs` — boot self-test result captured for the non-refreshable widget.
|
||||
- `Fuchs/Services/IBlobStorageService.cs` / `AzureBlobStorageService.cs` — `CheckConnectivityAsync` + `BlobConnectivity`.
|
||||
- `Fuchs/js/intranet/modules/fis.admin*.js`, `fis.admin.scss` — the module, texts, styles (bundled via `bdlconfig.json`).
|
||||
- `Fuchs/js/intranet/fis_main_menu.js` (`addAdminMenuIfAuthorized`) + `fis_main_go.js` — conditional menu button.
|
||||
- `Fuchs_Database/dbo/Functions/fis_admin_getUserAccountList.sql`, `fis_admin_getUserGroupList.sql`, `fis_admin_getUserGroupMembership.sql` — read-side user/group queries.
|
||||
- `Fuchs_Database/dbo/Stored Procedures/fis_admin_resetUserPassword.sql`, `fis_admin_setUserAccount.sql`, `fis_admin_addGroupMember.sql`, `fis_admin_remGroupMember.sql` — write-side user/group procs.
|
||||
- `Fuchs/js/intranet/fis_main_menu.js` (`addAdminMenuButtonsIfAuthorized`) + `fis_main_go.js` — conditional menu buttons (this module and `usr`).
|
||||
- `Fuchs.Tests/SystemStatusServiceTests.cs` — service tests.
|
||||
- `Fuchs.Tests/AdminUserManagementTests.cs` — password-reset email content tests.
|
||||
|
||||
User account/group management moved to its own module and concept doc — see
|
||||
[admin-user-management.md](admin-user-management.md).
|
||||
|
||||
## Related decisions
|
||||
- [0011 — Admin module gated on `fds_sys` > 4](../Decisions/0011-admin-module-system-status.md)
|
||||
- [0014 — Admin user management: folded into the `admin` module, dual auth axis, password always emailed](../Decisions/0014-admin-user-management.md)
|
||||
- [0015 — Split Admin into separate Status/User-Management modules](../Decisions/0015-split-admin-user-management-module.md)
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
---
|
||||
status: Active
|
||||
lastUpdated: 2026-09-09
|
||||
applyTo:
|
||||
- "Fuchs/Controllers/IntranetController.AdminUsers.cs"
|
||||
- "Fuchs/js/intranet/modules/fis.usr*.js"
|
||||
- "Fuchs/js/intranet/modules/fis.usr.scss"
|
||||
- "Fuchs/js/intranet/fis_main_menu.js"
|
||||
- "Fuchs_Database/dbo/Functions/fis_admin_*.sql"
|
||||
- "Fuchs_Database/dbo/Stored Procedures/fis_admin_*.sql"
|
||||
relatedDecisions:
|
||||
- "0014-admin-user-management.md"
|
||||
- "0015-split-admin-user-management-module.md"
|
||||
---
|
||||
|
||||
# User Management module (`usr`)
|
||||
|
||||
## Summary
|
||||
The **Benutzerverwaltung** module lets a privileged operator see every non-deleted intranet
|
||||
user account (name + email + active/disabled), reset a password, change an email address, and
|
||||
view/change group assignments. It shares its sidemenu-visibility and server-side authorization
|
||||
gate with the separate Admin/status-dashboard module (`fds_sys > 4`) but is otherwise fully
|
||||
independent — own `fn=usr`, own frontend bundle, own controller partial. See
|
||||
[0015](../Decisions/0015-split-admin-user-management-module.md) for why these were split from
|
||||
one module into two, and [0014](../Decisions/0014-admin-user-management.md) for the original
|
||||
decisions this one carries forward (password always emailed, dual auth axis, reuse of
|
||||
`fis_admin_setUserAccount` for email changes).
|
||||
|
||||
## How it works
|
||||
|
||||
### Authorization (two independent axes — read this before granting access)
|
||||
1. **Menu visibility + `fds_sys` gate** — identical mechanism to the Admin module:
|
||||
`fis_main_menu.js#addAdminMenuButtonsIfAuthorized` checks `fds_sys > 4` once and, if
|
||||
authorized, adds a single "Administration" sidemenu dropdown containing this module's
|
||||
"Benutzerverwaltung" (`init:usr`) entry alongside Admin's "Systemüberwachung" (`init:admin`)
|
||||
entry. Every `/do/usr/*` endpoint re-checks `fds_sys > 4` server-side
|
||||
(`Do_Process_AdminUsers`, reusing `AdminMinAuthExclusive`/`GetSystemAdminAuthAsync` from
|
||||
`IntranetController.Admin.cs`) and 401s otherwise — defense in depth, same as Admin.
|
||||
2. **Separate SQL-layer gate** — every `fis_admin_*` proc/function called below independently
|
||||
checks the caller's `intranet_admin` setting via `fis_admin_getUserAuth(@authaccount) > 1`
|
||||
(a `fis_useraccounts_settings` row, unrelated to `fis_getModuleAuth`/`fis_module_auth`). An
|
||||
account with `fds_sys > 4` but no `intranet_admin` setting reaches every `usr` endpoint but
|
||||
gets **empty results / silent no-op writes** from SQL — not an error. **Any account meant to
|
||||
do user management needs both `fds_sys > 4` and `intranet_admin >= 2`.**
|
||||
|
||||
### Request flow
|
||||
```
|
||||
click "Benutzerverwaltung" → $ocms.init('usr')
|
||||
POST /do/usr/auth → { manage, level } (manage>0 ⇒ authorized)
|
||||
load /web/fis.usr.de.js + /web/fis.usr.css
|
||||
$ocms.usr.init2() → init3() → renderLst()
|
||||
POST /do/usr/list → { users: [...] } grid of every non-deleted account
|
||||
row click → per-row ribbon (iMn): Passwort zurücksetzen / E-Mail ändern / Gruppenzugehörigkeit
|
||||
POST /do/usr/pwdreset {useraccount_id, newpassword?} → { ok, emailed }
|
||||
POST /do/usr/email {useraccount_id, email} → { ok } | 409 duplicate_email
|
||||
POST /do/usr/groups {useraccount_id} → { groups: [...] }
|
||||
POST /do/usr/groupset {useraccount_id, usergroup_id, member} → { ok, member }
|
||||
```
|
||||
|
||||
### Backend (`IntranetController.AdminUsers.cs`)
|
||||
- `list` → `fis_admin_getUserAccountList(@authaccount, NULL, NULL, NULL)`, all non-deleted
|
||||
accounts with decrypted name/email/disabled flag.
|
||||
- `pwdreset` → the admin may type a specific password or leave it blank; validated with a plain
|
||||
`ValidatePassword(minLength: 6, numUpper: 0, numLower: 0, numNumbers: 0, numSpecial: 0)` — no
|
||||
hidden composition requirements, matching what the UI actually tells the admin (a prior bug
|
||||
had this call still requiring `OCORE.commons.ValidatePassword`'s default 2-upper/2-lower/
|
||||
2-digit minimums despite the "mind. 6 Zeichen" message). Calls
|
||||
`fis_admin_resetUserPassword` (takes an optional `@newpassword`; `NULL` ⇒ the proc
|
||||
auto-generates one). On success, re-fetches the target's row (server-trusted email — never
|
||||
client-supplied) and emails the plaintext password via `IComService.SendEmailAsync` — **the
|
||||
admin never sees it**. Email subject/body built by `IntranetController.BuildPasswordResetEmail`
|
||||
(pure, unit-tested in `Fuchs.Tests/AdminUserManagementTests.cs`). Any unexpected failure (SQL
|
||||
throw, email non-delivery, no usable email on file) calls `IEventService.UserIssueAsync` so the
|
||||
admin gets a visible notification either way, per
|
||||
[0003](../Decisions/0003-interrupting-exceptions-notify-the-user.md).
|
||||
- `email` → fetches the current row and calls `fis_admin_setUserAccount` with only the email
|
||||
changed (`@password = NULL`, so the `WHEN MATCHED` update path leaves `password_enc` alone). A
|
||||
duplicate email (`THROW 60001` in the proc) surfaces as HTTP 409 `{ error: "duplicate_email" }`.
|
||||
- `groups` → the new `fis_admin_getUserGroupMembership(@authaccount, @useraccount_id)` function
|
||||
returns every group plus a `member` bit for that one user.
|
||||
- `groupset` → calls `fis_admin_addGroupMember`/`remGroupMember` depending on the posted
|
||||
`member` flag — applied immediately per checkbox toggle, no separate save step.
|
||||
|
||||
### Frontend (`fis.usr.js` + `fis.usr_txt_de.js` + `fis.usr.scss`)
|
||||
A self-contained list+ribbon module modeled on Banking (`fis.bam.js`)'s `eM`/`iMn` pattern
|
||||
rather than the Admin dashboard's card layout: `renderLst` builds the table via
|
||||
`$fis.frm_list`/`$$.tblset`; a row click toggles `.selected` and swaps the `#topbar` ribbon
|
||||
between the base "Neu Laden" button (`eM`) and the three per-row actions (`iMn`). Each action is
|
||||
a dialog (`pwdResetDlg`/`emailDlg`/`groupsDlg`) using `$ocms.dlgform`/`$ocms.dlg` with a manual
|
||||
`submit`/`postXT` (not the built-in `success`-URL wiring) so a 409's `error` code can be mapped
|
||||
to a specific German message via `$uct.err`.
|
||||
|
||||
## Key files
|
||||
- `Fuchs/Controllers/IntranetController.AdminUsers.cs` — `Do_Process_AdminUsers` dispatch + `BuildPasswordResetEmail` + `NotifyAdminUserActionFailedAsync`.
|
||||
- `Fuchs/Controllers/IntranetController.Admin.cs` — shared `AdminMinAuthExclusive`/`GetSystemAdminAuthAsync`/`AdminJson` this module reuses.
|
||||
- `Fuchs/js/intranet/modules/fis.usr.js`, `fis.usr_txt_de.js`, `fis.usr.scss` — the module, texts, styles (bundled via `bdlconfig.json`, `intranet:usr` context).
|
||||
- `Fuchs/js/intranet/fis_main_menu.js` (`addAdminMenuButtonsIfAuthorized`) + `fis_main_go.js` — conditional menu buttons (this module and `admin`).
|
||||
- `Fuchs_Database/dbo/Functions/fis_admin_getUserAccountList.sql`, `fis_admin_getUserGroupList.sql`, `fis_admin_getUserGroupMembership.sql` — read-side user/group queries.
|
||||
- `Fuchs_Database/dbo/Stored Procedures/fis_admin_resetUserPassword.sql`, `fis_admin_setUserAccount.sql`, `fis_admin_addGroupMember.sql`, `fis_admin_remGroupMember.sql` — write-side user/group procs.
|
||||
- `Fuchs.Tests/AdminUserManagementTests.cs` — password-reset email content tests.
|
||||
|
||||
## Related decisions
|
||||
- [0014 — Admin user management (original decisions, still in force)](../Decisions/0014-admin-user-management.md)
|
||||
- [0015 — Split Admin into separate Status/User-Management modules](../Decisions/0015-split-admin-user-management-module.md)
|
||||
- [0003 — Interrupting exceptions notify the user](../Decisions/0003-interrupting-exceptions-notify-the-user.md)
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
status: Accepted
|
||||
status: Superseded
|
||||
date: 2026-09-09
|
||||
applyTo:
|
||||
- "Fuchs/Controllers/IntranetController.Admin.cs"
|
||||
@@ -7,7 +7,7 @@ applyTo:
|
||||
- "Fuchs/js/intranet/modules/fis.admin.scss"
|
||||
- "Fuchs_Database/dbo/Functions/fis_admin_*.sql"
|
||||
- "Fuchs_Database/dbo/Stored Procedures/fis_admin_*.sql"
|
||||
supersededBy: ""
|
||||
supersededBy: "0015-split-admin-user-management-module.md"
|
||||
---
|
||||
|
||||
# 0014 — Admin user management: folded into the `admin` module, dual auth axis, password always emailed
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-09-09
|
||||
applyTo:
|
||||
- "Fuchs/Controllers/IntranetController.Admin.cs"
|
||||
- "Fuchs/Controllers/IntranetController.AdminUsers.cs"
|
||||
- "Fuchs/js/intranet/modules/fis.admin*.js"
|
||||
- "Fuchs/js/intranet/modules/fis.usr*.js"
|
||||
- "Fuchs/js/intranet/modules/fis.usr.scss"
|
||||
- "Fuchs/js/intranet/fis_main_menu.js"
|
||||
- "Fuchs_Database/dbo/Functions/fis_admin_*.sql"
|
||||
- "Fuchs_Database/dbo/Stored Procedures/fis_admin_*.sql"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0015 — Split Admin into separate "Status" (`admin`) and "User Management" (`usr`) modules, under one "Administration" sidemenu dropdown
|
||||
|
||||
## Context
|
||||
[0014](0014-admin-user-management.md) folded user management into the existing `admin` module
|
||||
(one sidemenu button, one `fn`) to avoid provisioning a second admin-ish permission. In practice
|
||||
this meant one button opened two unrelated things — infrastructure diagnostics and user
|
||||
account administration. The user asked for these to be split into two distinct buttons, grouped
|
||||
under a single "Administration" sidemenu entry (an `ocmsmenu` dropdown, not two flat top-level
|
||||
buttons). This decision supersedes 0014's "fold into admin" choice; the parts of 0014 that are
|
||||
about SQL-layer behavior (dual auth axis, password-reset email, group-toggle semantics) are
|
||||
unaffected and still apply, just under `fn=usr` instead of `fn=admin`.
|
||||
|
||||
## Decision
|
||||
- **Two independent modules, exposed as a single "Administration" dropdown in the sidemenu**
|
||||
with two entries — Benutzerverwaltung and Systemüberwachung — using `ocmsmenu`'s existing
|
||||
nested-item support (`itm: [...]` on a menu entry renders it as a Bootstrap dropdown; see
|
||||
`oci_main.js#$ocms.menu`, `subm`/`subi`). Both modules stay gated by the same `fds_sys > 4`
|
||||
server-side check (`AdminMinAuthExclusive`/`GetSystemAdminAuthAsync`, both defined once in
|
||||
`IntranetController.Admin.cs` and shared by both dispatchers):
|
||||
- `fn=admin` — unchanged status/diagnostics dashboard (`Do_Process_Admin`, `fis.admin.js`),
|
||||
reached via the "Systemüberwachung" dropdown entry.
|
||||
- `fn=usr` — new user-management module (`Do_Process_AdminUsers` in the new
|
||||
`IntranetController.AdminUsers.cs`, `fis.usr.js`/`fis.usr_txt_de.js`/`fis.usr.scss`), built
|
||||
as a self-contained list+ribbon module (Banking's `iMn`/`eM` pattern) rather than a card
|
||||
embedded in the dashboard, reached via the "Benutzerverwaltung" dropdown entry.
|
||||
- **The still-true dual-authorization caveat from 0014 carries over unchanged, just renamed**:
|
||||
`fds_sys > 4` gates the `usr` module's endpoints; the `fis_admin_*` SQL objects they call
|
||||
separately require the caller's `intranet_admin` setting to be `> 1`
|
||||
(`fis_admin_getUserAuth`). An account needs both to actually manage users.
|
||||
`Fuchs/Docs/Concepts/admin-user-management.md` now carries this note (split out of
|
||||
`admin-system-status.md`).
|
||||
- `id` values shortened now that the module name itself says "usr" (no more redundant `user`
|
||||
prefix): `users`→`list`, `userpwdreset`→`pwdreset`, `useremail`→`email`,
|
||||
`usergroups`→`groups`, `usergroupset`→`groupset`. Endpoints move from `/do/admin/*` to
|
||||
`/do/usr/*` accordingly.
|
||||
- One shared client-side auth check gates the whole dropdown:
|
||||
`fis_main_menu.js#addAdminMenuButtonsIfAuthorized` (renamed from
|
||||
`addAdminMenuIfAuthorized`) calls `$fis.getAuth('fds_sys')` once and, if authorized, pushes a
|
||||
**single** parent menu entry (`id: 'm_admin_grp'`, label `$t.m_admin` = "Administration", no
|
||||
`fnc` of its own) whose `itm` array holds the two real entries (`fnc: 'init:usr'` /
|
||||
`fnc: 'init:admin'`). The parent has no navigation behavior — clicking it only opens/closes the
|
||||
dropdown; the two children navigate. Idempotency is checked on the parent id alone.
|
||||
|
||||
## Consequences
|
||||
- Two bundle pairs now exist for what was one (`fis.admin.de.js`/`.min.css` and
|
||||
`fis.usr.de.js`/`.min.css`, both registered in `bdlconfig.json`) — a small duplication of the
|
||||
lazy-load/auth-check plumbing, acceptable for the clarity of separate concerns.
|
||||
- The in-module header text for the dashboard module changed from "Administration" to
|
||||
"Systemüberwachung" (`fis.admin_txt_de.js#$adt.mdl`) so it no longer collides with the parent
|
||||
dropdown's "Administration" label once both are visible on screen at once.
|
||||
- Any future admin-only feature should default to its own module, added as a new entry under the
|
||||
same "Administration" dropdown (or its own dropdown, if unrelated), unless it's genuinely part
|
||||
of the status dashboard's diagnostics — the original "fold everything into one Admin button"
|
||||
instinct from 0014 didn't hold up once a second real feature existed.
|
||||
- `IntranetController.BuildPasswordResetEmail`/`NotifyAdminUserActionFailedAsync` moved to
|
||||
`IntranetController.AdminUsers.cs`; `Fuchs.Tests/AdminUserManagementTests.cs` needed no change
|
||||
since it calls them through the (unchanged) `IntranetController` type.
|
||||
|
||||
## Alternatives considered
|
||||
- **Two flat top-level sidemenu buttons (no shared "Administration" parent).** Rejected per
|
||||
explicit user direction — the request was specifically for both to live "within
|
||||
Administration" in the sidemenu, i.e. a single grouping entry with two children.
|
||||
- **Keep one module, add an internal tab/sub-nav for user management.** Rejected — a client-side
|
||||
tab within one `fn` would still route through one `fn` auth check per the frontend's
|
||||
per-`fn` auth-caching design (`fis_main.js#$ocms.init`), the same limitation 0014 already ran
|
||||
into when this was one module with two unrelated concerns; splitting into two real modules
|
||||
under a shared menu dropdown gets a clean navigation grouping without that limitation.
|
||||
@@ -170,5 +170,24 @@
|
||||
"js/intranet/modules/fis.admin.scss"
|
||||
],
|
||||
"minify": { "enabled": true }
|
||||
},
|
||||
{
|
||||
"context": "intranet:usr",
|
||||
"outputFileName": "wwwroot/web/fis.usr.de.js",
|
||||
"inputFiles": [
|
||||
"js/intranet/modules/fis.usr_txt_de.js",
|
||||
"js/intranet/modules/fis.usr.js"
|
||||
],
|
||||
"minify": { "enabled": true }
|
||||
},
|
||||
{
|
||||
"context": "intranet:usr",
|
||||
"outputFileName": "wwwroot/web/fis.usr.min.css",
|
||||
"inputFiles": [
|
||||
"css/intranet/oci_variables.scss",
|
||||
"css/intranet/fis_variables.scss",
|
||||
"js/intranet/modules/fis.usr.scss"
|
||||
],
|
||||
"minify": { "enabled": true }
|
||||
}
|
||||
]
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
$fis.notifications.init();
|
||||
$fis.draft.init();
|
||||
$fis.ov();
|
||||
$fis.addAdminMenuIfAuthorized();
|
||||
$fis.addAdminMenuButtonsIfAuthorized();
|
||||
});
|
||||
|
||||
@@ -11,15 +11,23 @@
|
||||
]);
|
||||
})();
|
||||
|
||||
/* The Administration module is restricted to users whose fds_sys authorization exceeds 4.
|
||||
The button is added — and only then does clicking it load the module script — after the
|
||||
asynchronous auth check resolves, so lower-privileged users never see it or fetch the code.
|
||||
/* "Administration" is a single sidemenu dropdown (ocmsmenu supports nested items via `itm`)
|
||||
containing two buttons — Benutzerverwaltung (fn=usr) and Systemüberwachung (fn=admin) — both
|
||||
restricted to users whose fds_sys authorization exceeds 4. One auth check gates the whole
|
||||
dropdown; it (and therefore both module scripts) is only added after that check resolves, so
|
||||
lower-privileged users never see it or fetch either module's code.
|
||||
Called from fis_main_go.js once the DOM (and the initial main menu) is ready. */
|
||||
$fis.addAdminMenuIfAuthorized = function () {
|
||||
if ($('#m_admin').length > 0) { return; } // idempotent
|
||||
$fis.addAdminMenuButtonsIfAuthorized = function () {
|
||||
if ($('#m_admin_grp').length > 0) { return; } // idempotent
|
||||
$fis.getAuth('fds_sys').then(function (auth) {
|
||||
if ((auth || -1) > 4 && $('#m_admin').length === 0) {
|
||||
$ocms.ocmsmenu.push({ lbl: $t.m_admin, id: 'm_admin', fnc: 'init:admin', ico: 'glyphicon glyphicon-cog' });
|
||||
if ((auth || -1) > 4 && $('#m_admin_grp').length === 0) {
|
||||
$ocms.ocmsmenu.push({
|
||||
lbl: $t.m_admin, id: 'm_admin_grp', ico: 'glyphicon glyphicon-cog',
|
||||
itm: [
|
||||
{ lbl: $t.m_usr, id: 'm_usr', fnc: 'init:usr', ico: 'glyphicon glyphicon-user' },
|
||||
{ lbl: $t.m_admin_status, id: 'm_admin', fnc: 'init:admin', ico: 'glyphicon glyphicon-dashboard' }
|
||||
]
|
||||
});
|
||||
/* Re-render the main menu cleanly: drop the previously generated menu list
|
||||
(never the .nav-right settings dropdown) and rebuild it from the array. */
|
||||
$('#mainmenu').children('ul:not(.nav-right)').remove();
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
, m_todo: 'ToDos'
|
||||
, m_bcd: 'BankBuchungen'
|
||||
, m_admin: 'Administration'
|
||||
, m_admin_status: 'Systemüberwachung'
|
||||
, m_usr: 'Benutzerverwaltung'
|
||||
, rsp: 'Passwort ändern'
|
||||
, pnm: 'Die Passwörter stimmen nicht überein'
|
||||
, cps: 'Das neue Passwort wurde gespeichert.'
|
||||
|
||||
@@ -16,14 +16,6 @@ let $adm = {
|
||||
{ lbl: $adt.refreshAll, id: 'adm_refresh_all', ico: 'glyphicon glyphicon-refresh', fnc: function () { $adm.init3(); } }
|
||||
]);
|
||||
},
|
||||
/* Per-row ribbon shown while a user row is selected (mirrors the Banking module's iMn/eM swap). */
|
||||
uMn: function (rw) {
|
||||
return $('#topbar').ocmsmenu([
|
||||
{ lbl: $adt.u_pwdreset, fnc: function () { $adm.pwdResetDlg(rw); } },
|
||||
{ lbl: $adt.u_email, fnc: function () { $adm.emailDlg(rw); } },
|
||||
{ lbl: $adt.u_groups, fnc: function () { $adm.groupsDlg(rw); } }
|
||||
]);
|
||||
},
|
||||
load: function (frm) {
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('admin/status'), success: function (r) {
|
||||
@@ -50,109 +42,6 @@ let $adm = {
|
||||
$adm.blobCard(grid, info.blob || {}, pmap.blob);
|
||||
$adm.mfrCard(grid, info.mfr || {}, pmap.mfr);
|
||||
$adm.kvCard(grid, info.keyVault || {}, pmap.keyvault);
|
||||
|
||||
$$.dc('adm_sec', frm).text($adt.sub_users);
|
||||
$adm.usersCard(frm);
|
||||
},
|
||||
|
||||
/* ── user management ─────────────────────────────────────────────────── */
|
||||
usersCard: function (frm) {
|
||||
let card = $$.dc('adm_card adm_card_wide', frm);
|
||||
let hd = $$.dc('adm_card_hd', card);
|
||||
$$.s($adt.u_title).appendTo(hd);
|
||||
let bd = $$.dc('adm_card_bd adm_users_bd', card);
|
||||
$adm.renderUsers(bd);
|
||||
},
|
||||
renderUsers: function (bd) {
|
||||
bd.empty();
|
||||
let ts = $$.tblset({}, bd);
|
||||
let thr = $$.tr(ts.hd);
|
||||
$.each([$adt.u_col_name, $adt.u_col_email, $adt.u_col_status], function (i, lbl) {
|
||||
$$.th(thr).text(lbl);
|
||||
});
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('admin/users'), success: function (r) {
|
||||
$.each(r.users || [], function (ri, rw) {
|
||||
let tr = $$.tr(ts.bdy, { id: 'adu' + rw.useraccount_id });
|
||||
$$.td(tr).text(`${rw.firstname || ''} ${rw.name || ''}`.trim());
|
||||
$$.td(tr).text(rw.email || '');
|
||||
$$.td(tr).text(bool(rw.disabled, false) === true ? $adt.u_disabled : $adt.u_active);
|
||||
tr.click(function () {
|
||||
tr.toggleClass('selected').siblings().rC('selected');
|
||||
if (tr.is('.selected') === true) { $adm.uMn(rw); } else { $adm.topbar(); }
|
||||
});
|
||||
});
|
||||
}, error: function () {
|
||||
$$.dc('adm_err', bd).text($adt.loadfail);
|
||||
}
|
||||
});
|
||||
},
|
||||
pwdResetDlg: function (rw) {
|
||||
$ocms.dlgform([
|
||||
{ name: 'useraccount_id', type: 'hidden', value: rw.useraccount_id },
|
||||
{ name: 'newpassword', label: $adt.u_pwd_field, type: 'string', note: $adt.u_pwd_hint }
|
||||
], {
|
||||
title: $adt.u_pwdreset,
|
||||
button: $adt.u_pwdreset_btn,
|
||||
submit: function (e) {
|
||||
let c = $(this).ldng(1);
|
||||
let qs = c.serializeObject(true, { typedvalues: true });
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('admin/userpwdreset'), data: qs, success: function (response) {
|
||||
alert(response.emailed === true ? $adt.u_pwdreset_sent : $adt.u_pwdreset_noemail);
|
||||
c.trigger('modal_close');
|
||||
}, error: function (jqXHR) {
|
||||
let code = ((jqXHR || {}).responseJSON || {}).error;
|
||||
alert($adt.u_err[code] || $adt.u_err_generic);
|
||||
}, complete: function () { c.ldng(0); $adm.renderUsers($('.adm_users_bd')); $adm.topbar(); }
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
emailDlg: function (rw) {
|
||||
$ocms.dlgform([
|
||||
{ name: 'useraccount_id', type: 'hidden', value: rw.useraccount_id },
|
||||
{ name: 'email', label: $adt.u_email_field, type: 'email', required: true, value: rw.email }
|
||||
], {
|
||||
title: $adt.u_email,
|
||||
submit: function (e) {
|
||||
let c = $(this).ldng(1);
|
||||
let qs = c.serializeObject(true, { typedvalues: true });
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('admin/useremail'), data: qs, success: function () {
|
||||
c.trigger('modal_close');
|
||||
}, error: function (jqXHR) {
|
||||
let code = ((jqXHR || {}).responseJSON || {}).error;
|
||||
alert($adt.u_err[code] || $adt.u_err_generic);
|
||||
}, complete: function () { c.ldng(0); $adm.renderUsers($('.adm_users_bd')); $adm.topbar(); }
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
groupsDlg: function (rw) {
|
||||
let f = $$.dc('adm_groups_dlg');
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('admin/usergroups'), data: { useraccount_id: rw.useraccount_id }, success: function (r) {
|
||||
$.each(r.groups || [], function (gi, g) {
|
||||
let row = $$.dc('adm_group_row', f);
|
||||
let cid = 'adg' + g.usergroup_id;
|
||||
let cb = $$.i({ type: 'checkbox', id: cid }).prop('checked', bool(g.member, false)).appendTo(row);
|
||||
$$.lbl(g.name, { for: cid }).appendTo(row);
|
||||
cb.change(function () {
|
||||
let checked = cb.prop('checked');
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('admin/usergroupset'),
|
||||
data: { useraccount_id: rw.useraccount_id, usergroup_id: g.usergroup_id, member: checked },
|
||||
error: function () {
|
||||
cb.prop('checked', !checked);
|
||||
alert($adt.u_err_generic);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
$ocms.dlg(f, { title: string($adt.u_groups_title, [`${rw.firstname || ''} ${rw.name || ''}`.trim()]), size: [420, 380] });
|
||||
},
|
||||
|
||||
/* ── generic building blocks ─────────────────────────────────────────── */
|
||||
|
||||
@@ -188,29 +188,6 @@
|
||||
color: #fff;
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.adm_users_bd table tbody tr {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover td { background-color: #f3f6fb; }
|
||||
}
|
||||
}
|
||||
|
||||
.adm_groups_dlg {
|
||||
padding: 0.25rem 0;
|
||||
|
||||
.adm_group_row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
|
||||
input[type="checkbox"] { margin: 0; }
|
||||
label { margin: 0; font-weight: normal; }
|
||||
}
|
||||
}
|
||||
|
||||
.adm_note {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
let $adt = {
|
||||
mdl: 'Administration'
|
||||
mdl: 'Systemüberwachung'
|
||||
, sub_info: 'System'
|
||||
, sub_status: 'Status & Konnektivität'
|
||||
, sub_tests: 'Tests'
|
||||
@@ -81,29 +81,4 @@ let $adt = {
|
||||
, tm_default_subject: 'Fuchs Intranet – Test-E-Mail'
|
||||
, tm_default_body: 'Dies ist eine Test-E-Mail aus dem Administrationsbereich des Fuchs Intranet.'
|
||||
, loadfail: 'Der Systemstatus konnte nicht geladen werden.'
|
||||
// user management
|
||||
, sub_users: 'Benutzerverwaltung'
|
||||
, u_title: 'Benutzerkonten'
|
||||
, u_col_name: 'Name'
|
||||
, u_col_email: 'E-Mail'
|
||||
, u_col_status: 'Status'
|
||||
, u_active: 'Aktiv'
|
||||
, u_disabled: 'Deaktiviert'
|
||||
, u_pwdreset: 'Passwort zurücksetzen'
|
||||
, u_pwdreset_btn: 'Zurücksetzen'
|
||||
, u_pwd_field: 'Neues Passwort'
|
||||
, u_pwd_hint: 'Leer lassen, um automatisch ein Passwort zu generieren. In jedem Fall wird das neue Passwort per E-Mail an den Benutzer gesendet.'
|
||||
, u_pwdreset_sent: 'Das neue Passwort wurde per E-Mail an den Benutzer gesendet.'
|
||||
, u_pwdreset_noemail: 'Das Passwort wurde zurückgesetzt, konnte aber nicht per E-Mail zugestellt werden.'
|
||||
, u_email: 'E-Mail-Adresse ändern'
|
||||
, u_email_field: 'E-Mail-Adresse'
|
||||
, u_groups: 'Gruppenzugehörigkeit'
|
||||
, u_groups_title: 'Gruppen – {0}'
|
||||
, u_err: {
|
||||
requirements: 'Das Passwort erfüllt nicht die Mindestanforderungen (mind. 6 Zeichen).',
|
||||
duplicate_email: 'Diese E-Mail-Adresse ist bereits einem anderen Benutzerkonto zugeordnet.',
|
||||
reset_failed: 'Das Passwort konnte nicht zurückgesetzt werden.',
|
||||
change_failed: 'Die Änderung konnte nicht gespeichert werden.'
|
||||
}
|
||||
, u_err_generic: 'Die Änderung konnte nicht gespeichert werden.'
|
||||
};
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
let $usr = {
|
||||
init2: function () {
|
||||
// No external libraries needed — go straight to render.
|
||||
$ocms.getScript([], function () { $usr.init3(); });
|
||||
},
|
||||
init3: function () {
|
||||
$fis.cf(true);
|
||||
$fis.lf(true);
|
||||
$('#activemodule').text($uct.mdl);
|
||||
$usr.eM();
|
||||
$usr.renderLst();
|
||||
},
|
||||
/* Default ribbon (nothing selected). */
|
||||
eM: function (r) {
|
||||
let m = [];
|
||||
if (bool(r, false) === true) { m.push({ lbl: $uct.rel, fnc: $usr.renderLst }); }
|
||||
return $('#topbar').ocmsmenu(m);
|
||||
},
|
||||
/* Per-row ribbon shown while a user row is selected (mirrors the Banking module's iMn/eM swap). */
|
||||
iMn: function (rw) {
|
||||
return $('#topbar').ocmsmenu([
|
||||
{ lbl: $uct.pwdreset, fnc: function () { $usr.pwdResetDlg(rw); } },
|
||||
{ lbl: $uct.email, fnc: function () { $usr.emailDlg(rw); } },
|
||||
{ lbl: $uct.groups, fnc: function () { $usr.groupsDlg(rw); } }
|
||||
]);
|
||||
},
|
||||
renderLst: function () {
|
||||
let usrlst = $fis.frm_list(true, true).ldng(1), usrfrm = $$.dc('usrfrm', usrlst);
|
||||
$usr.eM(true);
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('usr/list'), success: function (r) {
|
||||
usrfrm.empty();
|
||||
let ts = $$.tblset({}, usrfrm);
|
||||
let thr = $$.tr(ts.hd);
|
||||
$.each([$uct.col_name, $uct.col_email, $uct.col_status], function (i, lbl) {
|
||||
$$.th(thr).text(lbl);
|
||||
});
|
||||
$.each(r.users || [], function (ri, rw) {
|
||||
let tr = $$.tr(ts.bdy, { id: 'usr' + rw.useraccount_id });
|
||||
$$.td(tr).text(`${rw.firstname || ''} ${rw.name || ''}`.trim());
|
||||
$$.td(tr).text(rw.email || '');
|
||||
$$.td(tr).text(bool(rw.disabled, false) === true ? $uct.disabled : $uct.active);
|
||||
tr.click(function () {
|
||||
tr.toggleClass('selected').siblings().rC('selected');
|
||||
if (tr.is('.selected') === true) { $usr.iMn(rw); } else { $usr.eM(true); }
|
||||
});
|
||||
});
|
||||
}, error: function () {
|
||||
usrfrm.empty();
|
||||
$$.dc('usr_err', usrfrm).text($uct.loadfail);
|
||||
}, complete: function () { usrlst.ldng(0); }
|
||||
});
|
||||
},
|
||||
pwdResetDlg: function (rw) {
|
||||
$ocms.dlgform([
|
||||
{ name: 'useraccount_id', type: 'hidden', value: rw.useraccount_id },
|
||||
{ name: 'newpassword', label: $uct.pwd_field, type: 'string', note: $uct.pwd_hint }
|
||||
], {
|
||||
title: $uct.pwdreset,
|
||||
button: $uct.pwdreset_btn,
|
||||
submit: function (e) {
|
||||
let c = $(this).ldng(1);
|
||||
let qs = c.serializeObject(true, { typedvalues: true });
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('usr/pwdreset'), data: qs, success: function (response) {
|
||||
alert(response.emailed === true ? $uct.pwdreset_sent : $uct.pwdreset_noemail);
|
||||
c.trigger('modal_close');
|
||||
}, error: function (jqXHR) {
|
||||
let code = ((jqXHR || {}).responseJSON || {}).error;
|
||||
alert($uct.err[code] || $uct.err_generic);
|
||||
}, complete: function () { c.ldng(0); $usr.renderLst(); }
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
emailDlg: function (rw) {
|
||||
$ocms.dlgform([
|
||||
{ name: 'useraccount_id', type: 'hidden', value: rw.useraccount_id },
|
||||
{ name: 'email', label: $uct.email_field, type: 'email', required: true, value: rw.email }
|
||||
], {
|
||||
title: $uct.email,
|
||||
submit: function (e) {
|
||||
let c = $(this).ldng(1);
|
||||
let qs = c.serializeObject(true, { typedvalues: true });
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('usr/email'), data: qs, success: function () {
|
||||
c.trigger('modal_close');
|
||||
}, error: function (jqXHR) {
|
||||
let code = ((jqXHR || {}).responseJSON || {}).error;
|
||||
alert($uct.err[code] || $uct.err_generic);
|
||||
}, complete: function () { c.ldng(0); $usr.renderLst(); }
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
groupsDlg: function (rw) {
|
||||
let f = $$.dc('usr_groups_dlg');
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('usr/groups'), data: { useraccount_id: rw.useraccount_id }, success: function (r) {
|
||||
$.each(r.groups || [], function (gi, g) {
|
||||
let row = $$.dc('usr_group_row', f);
|
||||
let cid = 'usrg' + g.usergroup_id;
|
||||
let cb = $$.i({ type: 'checkbox', id: cid }).prop('checked', bool(g.member, false)).appendTo(row);
|
||||
$$.lbl(g.name, { for: cid }).appendTo(row);
|
||||
cb.change(function () {
|
||||
let checked = cb.prop('checked');
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('usr/groupset'),
|
||||
data: { useraccount_id: rw.useraccount_id, usergroup_id: g.usergroup_id, member: checked },
|
||||
error: function () {
|
||||
cb.prop('checked', !checked);
|
||||
alert($uct.err_generic);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
$ocms.dlg(f, { title: string($uct.groups_title, [`${rw.firstname || ''} ${rw.name || ''}`.trim()]), size: [420, 380] });
|
||||
}
|
||||
};
|
||||
let $$usr = { init2: $usr.init2, auth: {} };
|
||||
export default $$usr;
|
||||
@@ -0,0 +1,55 @@
|
||||
.usrfrm {
|
||||
> table {
|
||||
border-collapse: collapse;
|
||||
margin: 2rem;
|
||||
|
||||
th {
|
||||
padding: 0.2rem 0.35rem;
|
||||
border: 1px solid #CCC;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 0.2rem 0.35rem;
|
||||
border: 1px solid #CCC;
|
||||
}
|
||||
|
||||
tr {
|
||||
cursor: pointer;
|
||||
|
||||
&:nth-child(2n+1) td {
|
||||
background-color: #EEE;
|
||||
}
|
||||
|
||||
&:hover td {
|
||||
background-color: #f3f6fb;
|
||||
}
|
||||
|
||||
&.selected td {
|
||||
background-color: lightblue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.usr_err {
|
||||
padding: 1rem 2rem;
|
||||
color: #b00020;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.usr_groups_dlg {
|
||||
padding: 0.25rem 0;
|
||||
|
||||
.usr_group_row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
|
||||
input[type="checkbox"] { margin: 0; }
|
||||
label { margin: 0; font-weight: normal; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
let $uct = {
|
||||
mdl: 'Benutzerverwaltung'
|
||||
, rel: 'Neu Laden'
|
||||
, col_name: 'Name'
|
||||
, col_email: 'E-Mail'
|
||||
, col_status: 'Status'
|
||||
, active: 'Aktiv'
|
||||
, disabled: 'Deaktiviert'
|
||||
, loadfail: 'Die Benutzerliste konnte nicht geladen werden.'
|
||||
, pwdreset: 'Passwort zurücksetzen'
|
||||
, pwdreset_btn: 'Zurücksetzen'
|
||||
, pwd_field: 'Neues Passwort'
|
||||
, pwd_hint: 'Leer lassen, um automatisch ein Passwort zu generieren. In jedem Fall wird das neue Passwort per E-Mail an den Benutzer gesendet.'
|
||||
, pwdreset_sent: 'Das neue Passwort wurde per E-Mail an den Benutzer gesendet.'
|
||||
, pwdreset_noemail: 'Das Passwort wurde zurückgesetzt, konnte aber nicht per E-Mail zugestellt werden.'
|
||||
, email: 'E-Mail-Adresse ändern'
|
||||
, email_field: 'E-Mail-Adresse'
|
||||
, groups: 'Gruppenzugehörigkeit'
|
||||
, groups_title: 'Gruppen – {0}'
|
||||
, err: {
|
||||
requirements: 'Das Passwort erfüllt nicht die Mindestanforderungen (mind. 6 Zeichen).',
|
||||
duplicate_email: 'Diese E-Mail-Adresse ist bereits einem anderen Benutzerkonto zugeordnet.',
|
||||
reset_failed: 'Das Passwort konnte nicht zurückgesetzt werden.',
|
||||
change_failed: 'Die Änderung konnte nicht gespeichert werden.'
|
||||
}
|
||||
, err_generic: 'Die Änderung konnte nicht gespeichert werden.'
|
||||
};
|
||||
@@ -245,33 +245,6 @@
|
||||
color: #fff;
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
.admfrm .adm_users_bd table tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
.admfrm .adm_users_bd table tbody tr:hover td {
|
||||
background-color: #f3f6fb;
|
||||
}
|
||||
|
||||
.adm_groups_dlg {
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
.adm_groups_dlg .adm_group_row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
.adm_groups_dlg .adm_group_row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.adm_groups_dlg .adm_group_row input[type=checkbox] {
|
||||
margin: 0;
|
||||
}
|
||||
.adm_groups_dlg .adm_group_row label {
|
||||
margin: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.adm_note {
|
||||
margin-top: 0.75rem;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
let $adt = {
|
||||
mdl: 'Administration'
|
||||
mdl: 'Systemüberwachung'
|
||||
, sub_info: 'System'
|
||||
, sub_status: 'Status & Konnektivität'
|
||||
, sub_tests: 'Tests'
|
||||
@@ -81,31 +81,6 @@ let $adt = {
|
||||
, tm_default_subject: 'Fuchs Intranet – Test-E-Mail'
|
||||
, tm_default_body: 'Dies ist eine Test-E-Mail aus dem Administrationsbereich des Fuchs Intranet.'
|
||||
, loadfail: 'Der Systemstatus konnte nicht geladen werden.'
|
||||
// user management
|
||||
, sub_users: 'Benutzerverwaltung'
|
||||
, u_title: 'Benutzerkonten'
|
||||
, u_col_name: 'Name'
|
||||
, u_col_email: 'E-Mail'
|
||||
, u_col_status: 'Status'
|
||||
, u_active: 'Aktiv'
|
||||
, u_disabled: 'Deaktiviert'
|
||||
, u_pwdreset: 'Passwort zurücksetzen'
|
||||
, u_pwdreset_btn: 'Zurücksetzen'
|
||||
, u_pwd_field: 'Neues Passwort'
|
||||
, u_pwd_hint: 'Leer lassen, um automatisch ein Passwort zu generieren. In jedem Fall wird das neue Passwort per E-Mail an den Benutzer gesendet.'
|
||||
, u_pwdreset_sent: 'Das neue Passwort wurde per E-Mail an den Benutzer gesendet.'
|
||||
, u_pwdreset_noemail: 'Das Passwort wurde zurückgesetzt, konnte aber nicht per E-Mail zugestellt werden.'
|
||||
, u_email: 'E-Mail-Adresse ändern'
|
||||
, u_email_field: 'E-Mail-Adresse'
|
||||
, u_groups: 'Gruppenzugehörigkeit'
|
||||
, u_groups_title: 'Gruppen – {0}'
|
||||
, u_err: {
|
||||
requirements: 'Das Passwort erfüllt nicht die Mindestanforderungen (mind. 6 Zeichen).',
|
||||
duplicate_email: 'Diese E-Mail-Adresse ist bereits einem anderen Benutzerkonto zugeordnet.',
|
||||
reset_failed: 'Das Passwort konnte nicht zurückgesetzt werden.',
|
||||
change_failed: 'Die Änderung konnte nicht gespeichert werden.'
|
||||
}
|
||||
, u_err_generic: 'Die Änderung konnte nicht gespeichert werden.'
|
||||
};
|
||||
|
||||
let $adm = {
|
||||
@@ -126,14 +101,6 @@ let $adm = {
|
||||
{ lbl: $adt.refreshAll, id: 'adm_refresh_all', ico: 'glyphicon glyphicon-refresh', fnc: function () { $adm.init3(); } }
|
||||
]);
|
||||
},
|
||||
/* Per-row ribbon shown while a user row is selected (mirrors the Banking module's iMn/eM swap). */
|
||||
uMn: function (rw) {
|
||||
return $('#topbar').ocmsmenu([
|
||||
{ lbl: $adt.u_pwdreset, fnc: function () { $adm.pwdResetDlg(rw); } },
|
||||
{ lbl: $adt.u_email, fnc: function () { $adm.emailDlg(rw); } },
|
||||
{ lbl: $adt.u_groups, fnc: function () { $adm.groupsDlg(rw); } }
|
||||
]);
|
||||
},
|
||||
load: function (frm) {
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('admin/status'), success: function (r) {
|
||||
@@ -160,109 +127,6 @@ let $adm = {
|
||||
$adm.blobCard(grid, info.blob || {}, pmap.blob);
|
||||
$adm.mfrCard(grid, info.mfr || {}, pmap.mfr);
|
||||
$adm.kvCard(grid, info.keyVault || {}, pmap.keyvault);
|
||||
|
||||
$$.dc('adm_sec', frm).text($adt.sub_users);
|
||||
$adm.usersCard(frm);
|
||||
},
|
||||
|
||||
/* ── user management ─────────────────────────────────────────────────── */
|
||||
usersCard: function (frm) {
|
||||
let card = $$.dc('adm_card adm_card_wide', frm);
|
||||
let hd = $$.dc('adm_card_hd', card);
|
||||
$$.s($adt.u_title).appendTo(hd);
|
||||
let bd = $$.dc('adm_card_bd adm_users_bd', card);
|
||||
$adm.renderUsers(bd);
|
||||
},
|
||||
renderUsers: function (bd) {
|
||||
bd.empty();
|
||||
let ts = $$.tblset({}, bd);
|
||||
let thr = $$.tr(ts.hd);
|
||||
$.each([$adt.u_col_name, $adt.u_col_email, $adt.u_col_status], function (i, lbl) {
|
||||
$$.th(thr).text(lbl);
|
||||
});
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('admin/users'), success: function (r) {
|
||||
$.each(r.users || [], function (ri, rw) {
|
||||
let tr = $$.tr(ts.bdy, { id: 'adu' + rw.useraccount_id });
|
||||
$$.td(tr).text(`${rw.firstname || ''} ${rw.name || ''}`.trim());
|
||||
$$.td(tr).text(rw.email || '');
|
||||
$$.td(tr).text(bool(rw.disabled, false) === true ? $adt.u_disabled : $adt.u_active);
|
||||
tr.click(function () {
|
||||
tr.toggleClass('selected').siblings().rC('selected');
|
||||
if (tr.is('.selected') === true) { $adm.uMn(rw); } else { $adm.topbar(); }
|
||||
});
|
||||
});
|
||||
}, error: function () {
|
||||
$$.dc('adm_err', bd).text($adt.loadfail);
|
||||
}
|
||||
});
|
||||
},
|
||||
pwdResetDlg: function (rw) {
|
||||
$ocms.dlgform([
|
||||
{ name: 'useraccount_id', type: 'hidden', value: rw.useraccount_id },
|
||||
{ name: 'newpassword', label: $adt.u_pwd_field, type: 'string', note: $adt.u_pwd_hint }
|
||||
], {
|
||||
title: $adt.u_pwdreset,
|
||||
button: $adt.u_pwdreset_btn,
|
||||
submit: function (e) {
|
||||
let c = $(this).ldng(1);
|
||||
let qs = c.serializeObject(true, { typedvalues: true });
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('admin/userpwdreset'), data: qs, success: function (response) {
|
||||
alert(response.emailed === true ? $adt.u_pwdreset_sent : $adt.u_pwdreset_noemail);
|
||||
c.trigger('modal_close');
|
||||
}, error: function (jqXHR) {
|
||||
let code = ((jqXHR || {}).responseJSON || {}).error;
|
||||
alert($adt.u_err[code] || $adt.u_err_generic);
|
||||
}, complete: function () { c.ldng(0); $adm.renderUsers($('.adm_users_bd')); $adm.topbar(); }
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
emailDlg: function (rw) {
|
||||
$ocms.dlgform([
|
||||
{ name: 'useraccount_id', type: 'hidden', value: rw.useraccount_id },
|
||||
{ name: 'email', label: $adt.u_email_field, type: 'email', required: true, value: rw.email }
|
||||
], {
|
||||
title: $adt.u_email,
|
||||
submit: function (e) {
|
||||
let c = $(this).ldng(1);
|
||||
let qs = c.serializeObject(true, { typedvalues: true });
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('admin/useremail'), data: qs, success: function () {
|
||||
c.trigger('modal_close');
|
||||
}, error: function (jqXHR) {
|
||||
let code = ((jqXHR || {}).responseJSON || {}).error;
|
||||
alert($adt.u_err[code] || $adt.u_err_generic);
|
||||
}, complete: function () { c.ldng(0); $adm.renderUsers($('.adm_users_bd')); $adm.topbar(); }
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
groupsDlg: function (rw) {
|
||||
let f = $$.dc('adm_groups_dlg');
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('admin/usergroups'), data: { useraccount_id: rw.useraccount_id }, success: function (r) {
|
||||
$.each(r.groups || [], function (gi, g) {
|
||||
let row = $$.dc('adm_group_row', f);
|
||||
let cid = 'adg' + g.usergroup_id;
|
||||
let cb = $$.i({ type: 'checkbox', id: cid }).prop('checked', bool(g.member, false)).appendTo(row);
|
||||
$$.lbl(g.name, { for: cid }).appendTo(row);
|
||||
cb.change(function () {
|
||||
let checked = cb.prop('checked');
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('admin/usergroupset'),
|
||||
data: { useraccount_id: rw.useraccount_id, usergroup_id: g.usergroup_id, member: checked },
|
||||
error: function () {
|
||||
cb.prop('checked', !checked);
|
||||
alert($adt.u_err_generic);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
$ocms.dlg(f, { title: string($adt.u_groups_title, [`${rw.firstname || ''} ${rw.name || ''}`.trim()]), size: [420, 380] });
|
||||
},
|
||||
|
||||
/* ── generic building blocks ─────────────────────────────────────────── */
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -1 +1 @@
|
||||
.admfrm{padding:1.5rem}.admfrm .adm_sec{font-size:1.2rem;font-weight:700;color:#1b4379;margin:1.5rem 0 .75rem;border-bottom:2px solid #1b4379;padding-bottom:.25rem}.admfrm .adm_sec:first-child{margin-top:0}.admfrm .adm_err{padding:1rem;color:#b00020;font-weight:700}.admfrm .adm_grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(22rem,1fr));gap:1rem;align-items:start}.admfrm .adm_card{border:1px solid #d5d5d5;border-radius:.4rem;background:#fff;box-shadow:0 1px 3px rgba(50,50,50,.12);overflow:hidden}.admfrm .adm_card.adm_card_wide{grid-column:1/-1}.admfrm .adm_card_hd{display:flex;align-items:center;gap:.5rem;padding:.6rem .85rem;background:#1b4379;color:#fff;font-weight:700}.admfrm .adm_card_hd>span:first-child{flex:1 1 auto}.admfrm .adm_card_bd{padding:.6rem .85rem}.admfrm .adm_card_ft{padding:.5rem .85rem .75rem;text-align:right}.admfrm table.adm_kv{border-collapse:collapse;width:100%}.admfrm table.adm_kv td{padding:.25rem .35rem;border-bottom:1px solid #eee;vertical-align:top;font-size:.92rem}.admfrm table.adm_kv td.k{color:#666;white-space:nowrap;width:40%}.admfrm table.adm_kv td.v{word-break:break-word}.admfrm table.adm_kv tr:last-child td{border-bottom:none}.admfrm .adm_probe{margin-top:.6rem;padding-top:.5rem;border-top:1px dashed #ddd;font-size:.88rem}.admfrm .adm_probe .adm_probe_msg{font-weight:600}.admfrm .adm_probe .adm_probe_det{color:#555;word-break:break-word;margin-top:.15rem}.admfrm .adm_probe .adm_probe_dur{color:#999;margin-top:.15rem;font-size:.8rem}.admfrm .adm_probe .adm_probe_metrics{margin-top:.35rem}.admfrm .adm_probe .adm_probe_metrics .adm_metric{display:flex;justify-content:space-between;gap:.5rem;padding:.1rem 0;border-bottom:1px dotted #eee}.admfrm .adm_probe .adm_probe_metrics .adm_metric:last-child{border-bottom:none}.admfrm .adm_probe .adm_probe_metrics .adm_metric .adm_metric_k{color:#555;word-break:break-word}.admfrm .adm_probe .adm_probe_metrics .adm_metric .adm_metric_v{font-weight:600;white-space:nowrap}.admfrm .adm_checks{margin-top:.5rem}.admfrm .adm_checks .adm_check{display:flex;justify-content:space-between;align-items:center;gap:.5rem;padding:.25rem 0;border-bottom:1px solid #eee}.admfrm .adm_checks .adm_check:last-child{border-bottom:none}.admfrm .adm_checks .adm_check .adm_check_k{color:#333}.admfrm .adm_checks .adm_check .adm_check_v{font-weight:700;font-size:.8rem;padding:.05rem .5rem;border-radius:.25rem;white-space:nowrap}.admfrm .adm_checks .adm_check .adm_check_ok{background:#e2f3da;color:#2f6d18}.admfrm .adm_checks .adm_check .adm_check_fail{background:#f3ded9;color:#c0341d}.admfrm .adm_checks .adm_check .adm_check_skipped{background:#ececec;color:#777}.admfrm .adm_pill{display:inline-block;padding:.1rem .55rem;border-radius:1rem;font-size:.78rem;font-weight:700;color:#fff;background:#999;white-space:nowrap}.admfrm .adm_pill_ok{background:#56a532}.admfrm .adm_pill_error{background:#c0341d}.admfrm .adm_pill_disabled{background:#888}.admfrm .adm_pill_unconfigured{background:#d69100}.admfrm .adm_pill_checking{background:#2a72c4}.admfrm .adm_bool{display:inline-block;padding:0 .4rem;border-radius:.25rem;font-size:.8rem;font-weight:700}.admfrm .adm_bool_y{background:#e2f3da;color:#2f6d18}.admfrm .adm_bool_n{background:#f3e0dd;color:#9c2a17}.admfrm .adm_env{font-size:.8rem;font-weight:400;padding:.1rem .5rem;border-radius:.25rem;background:rgba(255,255,255,.25)}.admfrm .adm_env.adm_env_test{background:#13a143}.admfrm .adm_btn{cursor:pointer;border-radius:.28rem;padding:.2rem .6rem;font-size:.85rem;border:1px solid #ababab;background-color:#fff;color:#333}.admfrm .adm_btn.adm_btn_primary{background-color:#56a532;border-color:rgb(69.68,133.688372093,40.511627907);color:#fff}.admfrm .adm_card_hd .adm_btn{background-color:rgba(255,255,255,.15);color:#fff;border-color:rgba(255,255,255,.4)}.admfrm .adm_users_bd table tbody tr{cursor:pointer}.admfrm .adm_users_bd table tbody tr:hover td{background-color:#f3f6fb}.adm_groups_dlg{padding:.25rem 0}.adm_groups_dlg .adm_group_row{display:flex;align-items:center;gap:.5rem;padding:.35rem 0;border-bottom:1px solid #eee}.adm_groups_dlg .adm_group_row:last-child{border-bottom:none}.adm_groups_dlg .adm_group_row input[type=checkbox]{margin:0}.adm_groups_dlg .adm_group_row label{margin:0;font-weight:400}.adm_note{margin-top:.75rem;padding:.5rem .6rem;background:#f3f6fb;border-left:3px solid #1b4379;font-size:.85rem;color:#444}
|
||||
.admfrm{padding:1.5rem}.admfrm .adm_sec{font-size:1.2rem;font-weight:700;color:#1b4379;margin:1.5rem 0 .75rem;border-bottom:2px solid #1b4379;padding-bottom:.25rem}.admfrm .adm_sec:first-child{margin-top:0}.admfrm .adm_err{padding:1rem;color:#b00020;font-weight:700}.admfrm .adm_grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(22rem,1fr));gap:1rem;align-items:start}.admfrm .adm_card{border:1px solid #d5d5d5;border-radius:.4rem;background:#fff;box-shadow:0 1px 3px rgba(50,50,50,.12);overflow:hidden}.admfrm .adm_card.adm_card_wide{grid-column:1/-1}.admfrm .adm_card_hd{display:flex;align-items:center;gap:.5rem;padding:.6rem .85rem;background:#1b4379;color:#fff;font-weight:700}.admfrm .adm_card_hd>span:first-child{flex:1 1 auto}.admfrm .adm_card_bd{padding:.6rem .85rem}.admfrm .adm_card_ft{padding:.5rem .85rem .75rem;text-align:right}.admfrm table.adm_kv{border-collapse:collapse;width:100%}.admfrm table.adm_kv td{padding:.25rem .35rem;border-bottom:1px solid #eee;vertical-align:top;font-size:.92rem}.admfrm table.adm_kv td.k{color:#666;white-space:nowrap;width:40%}.admfrm table.adm_kv td.v{word-break:break-word}.admfrm table.adm_kv tr:last-child td{border-bottom:none}.admfrm .adm_probe{margin-top:.6rem;padding-top:.5rem;border-top:1px dashed #ddd;font-size:.88rem}.admfrm .adm_probe .adm_probe_msg{font-weight:600}.admfrm .adm_probe .adm_probe_det{color:#555;word-break:break-word;margin-top:.15rem}.admfrm .adm_probe .adm_probe_dur{color:#999;margin-top:.15rem;font-size:.8rem}.admfrm .adm_probe .adm_probe_metrics{margin-top:.35rem}.admfrm .adm_probe .adm_probe_metrics .adm_metric{display:flex;justify-content:space-between;gap:.5rem;padding:.1rem 0;border-bottom:1px dotted #eee}.admfrm .adm_probe .adm_probe_metrics .adm_metric:last-child{border-bottom:none}.admfrm .adm_probe .adm_probe_metrics .adm_metric .adm_metric_k{color:#555;word-break:break-word}.admfrm .adm_probe .adm_probe_metrics .adm_metric .adm_metric_v{font-weight:600;white-space:nowrap}.admfrm .adm_checks{margin-top:.5rem}.admfrm .adm_checks .adm_check{display:flex;justify-content:space-between;align-items:center;gap:.5rem;padding:.25rem 0;border-bottom:1px solid #eee}.admfrm .adm_checks .adm_check:last-child{border-bottom:none}.admfrm .adm_checks .adm_check .adm_check_k{color:#333}.admfrm .adm_checks .adm_check .adm_check_v{font-weight:700;font-size:.8rem;padding:.05rem .5rem;border-radius:.25rem;white-space:nowrap}.admfrm .adm_checks .adm_check .adm_check_ok{background:#e2f3da;color:#2f6d18}.admfrm .adm_checks .adm_check .adm_check_fail{background:#f3ded9;color:#c0341d}.admfrm .adm_checks .adm_check .adm_check_skipped{background:#ececec;color:#777}.admfrm .adm_pill{display:inline-block;padding:.1rem .55rem;border-radius:1rem;font-size:.78rem;font-weight:700;color:#fff;background:#999;white-space:nowrap}.admfrm .adm_pill_ok{background:#56a532}.admfrm .adm_pill_error{background:#c0341d}.admfrm .adm_pill_disabled{background:#888}.admfrm .adm_pill_unconfigured{background:#d69100}.admfrm .adm_pill_checking{background:#2a72c4}.admfrm .adm_bool{display:inline-block;padding:0 .4rem;border-radius:.25rem;font-size:.8rem;font-weight:700}.admfrm .adm_bool_y{background:#e2f3da;color:#2f6d18}.admfrm .adm_bool_n{background:#f3e0dd;color:#9c2a17}.admfrm .adm_env{font-size:.8rem;font-weight:400;padding:.1rem .5rem;border-radius:.25rem;background:rgba(255,255,255,.25)}.admfrm .adm_env.adm_env_test{background:#13a143}.admfrm .adm_btn{cursor:pointer;border-radius:.28rem;padding:.2rem .6rem;font-size:.85rem;border:1px solid #ababab;background-color:#fff;color:#333}.admfrm .adm_btn.adm_btn_primary{background-color:#56a532;border-color:rgb(69.68,133.688372093,40.511627907);color:#fff}.admfrm .adm_card_hd .adm_btn{background-color:rgba(255,255,255,.15);color:#fff;border-color:rgba(255,255,255,.4)}.adm_note{margin-top:.75rem;padding:.5rem .6rem;background:#f3f6fb;border-left:3px solid #1b4379;font-size:.85rem;color:#444}
|
||||
@@ -2843,6 +2843,8 @@ $.extend($t, {
|
||||
, m_todo: 'ToDos'
|
||||
, m_bcd: 'BankBuchungen'
|
||||
, m_admin: 'Administration'
|
||||
, m_admin_status: 'Systemüberwachung'
|
||||
, m_usr: 'Benutzerverwaltung'
|
||||
, rsp: 'Passwort ändern'
|
||||
, pnm: 'Die Passwörter stimmen nicht überein'
|
||||
, cps: 'Das neue Passwort wurde gespeichert.'
|
||||
@@ -3223,15 +3225,23 @@ $fis.draft = {
|
||||
]);
|
||||
})();
|
||||
|
||||
/* The Administration module is restricted to users whose fds_sys authorization exceeds 4.
|
||||
The button is added — and only then does clicking it load the module script — after the
|
||||
asynchronous auth check resolves, so lower-privileged users never see it or fetch the code.
|
||||
/* "Administration" is a single sidemenu dropdown (ocmsmenu supports nested items via `itm`)
|
||||
containing two buttons — Benutzerverwaltung (fn=usr) and Systemüberwachung (fn=admin) — both
|
||||
restricted to users whose fds_sys authorization exceeds 4. One auth check gates the whole
|
||||
dropdown; it (and therefore both module scripts) is only added after that check resolves, so
|
||||
lower-privileged users never see it or fetch either module's code.
|
||||
Called from fis_main_go.js once the DOM (and the initial main menu) is ready. */
|
||||
$fis.addAdminMenuIfAuthorized = function () {
|
||||
if ($('#m_admin').length > 0) { return; } // idempotent
|
||||
$fis.addAdminMenuButtonsIfAuthorized = function () {
|
||||
if ($('#m_admin_grp').length > 0) { return; } // idempotent
|
||||
$fis.getAuth('fds_sys').then(function (auth) {
|
||||
if ((auth || -1) > 4 && $('#m_admin').length === 0) {
|
||||
$ocms.ocmsmenu.push({ lbl: $t.m_admin, id: 'm_admin', fnc: 'init:admin', ico: 'glyphicon glyphicon-cog' });
|
||||
if ((auth || -1) > 4 && $('#m_admin_grp').length === 0) {
|
||||
$ocms.ocmsmenu.push({
|
||||
lbl: $t.m_admin, id: 'm_admin_grp', ico: 'glyphicon glyphicon-cog',
|
||||
itm: [
|
||||
{ lbl: $t.m_usr, id: 'm_usr', fnc: 'init:usr', ico: 'glyphicon glyphicon-user' },
|
||||
{ lbl: $t.m_admin_status, id: 'm_admin', fnc: 'init:admin', ico: 'glyphicon glyphicon-dashboard' }
|
||||
]
|
||||
});
|
||||
/* Re-render the main menu cleanly: drop the previously generated menu list
|
||||
(never the .nav-right settings dropdown) and rebuild it from the array. */
|
||||
$('#mainmenu').children('ul:not(.nav-right)').remove();
|
||||
@@ -3243,5 +3253,5 @@ $(document).ready(function () {
|
||||
$fis.notifications.init();
|
||||
$fis.draft.init();
|
||||
$fis.ov();
|
||||
$fis.addAdminMenuIfAuthorized();
|
||||
$fis.addAdminMenuButtonsIfAuthorized();
|
||||
});
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,68 @@
|
||||
/* basics */
|
||||
/* Color SCHEME */
|
||||
/* #8396bd */
|
||||
/* #e3e6e6 */
|
||||
/* #ffffff */
|
||||
/* #262626 */
|
||||
/* #262626 */
|
||||
/* #e3e6e6 */
|
||||
/* #FFC801 */
|
||||
/* #ffff4a */
|
||||
/* media breaks */
|
||||
/* other */
|
||||
/* mixin */
|
||||
/* basics */
|
||||
/* Color SCHEME */
|
||||
/* #1b4379 #0033b3 */
|
||||
/*neu: #56a532 ; alt: rgb(32,144,119);*/
|
||||
/* 10% sw */
|
||||
.usrfrm > table {
|
||||
border-collapse: collapse;
|
||||
margin: 2rem;
|
||||
}
|
||||
.usrfrm > table th {
|
||||
padding: 0.2rem 0.35rem;
|
||||
border: 1px solid #CCC;
|
||||
}
|
||||
.usrfrm > table td {
|
||||
padding: 0.2rem 0.35rem;
|
||||
border: 1px solid #CCC;
|
||||
}
|
||||
.usrfrm > table tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
.usrfrm > table tr:nth-child(2n+1) td {
|
||||
background-color: #EEE;
|
||||
}
|
||||
.usrfrm > table tr:hover td {
|
||||
background-color: #f3f6fb;
|
||||
}
|
||||
.usrfrm > table tr.selected td {
|
||||
background-color: lightblue;
|
||||
}
|
||||
.usrfrm .usr_err {
|
||||
padding: 1rem 2rem;
|
||||
color: #b00020;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.usr_groups_dlg {
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
.usr_groups_dlg .usr_group_row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
.usr_groups_dlg .usr_group_row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.usr_groups_dlg .usr_group_row input[type=checkbox] {
|
||||
margin: 0;
|
||||
}
|
||||
.usr_groups_dlg .usr_group_row label {
|
||||
margin: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
let $uct = {
|
||||
mdl: 'Benutzerverwaltung'
|
||||
, rel: 'Neu Laden'
|
||||
, col_name: 'Name'
|
||||
, col_email: 'E-Mail'
|
||||
, col_status: 'Status'
|
||||
, active: 'Aktiv'
|
||||
, disabled: 'Deaktiviert'
|
||||
, loadfail: 'Die Benutzerliste konnte nicht geladen werden.'
|
||||
, pwdreset: 'Passwort zurücksetzen'
|
||||
, pwdreset_btn: 'Zurücksetzen'
|
||||
, pwd_field: 'Neues Passwort'
|
||||
, pwd_hint: 'Leer lassen, um automatisch ein Passwort zu generieren. In jedem Fall wird das neue Passwort per E-Mail an den Benutzer gesendet.'
|
||||
, pwdreset_sent: 'Das neue Passwort wurde per E-Mail an den Benutzer gesendet.'
|
||||
, pwdreset_noemail: 'Das Passwort wurde zurückgesetzt, konnte aber nicht per E-Mail zugestellt werden.'
|
||||
, email: 'E-Mail-Adresse ändern'
|
||||
, email_field: 'E-Mail-Adresse'
|
||||
, groups: 'Gruppenzugehörigkeit'
|
||||
, groups_title: 'Gruppen – {0}'
|
||||
, err: {
|
||||
requirements: 'Das Passwort erfüllt nicht die Mindestanforderungen (mind. 6 Zeichen).',
|
||||
duplicate_email: 'Diese E-Mail-Adresse ist bereits einem anderen Benutzerkonto zugeordnet.',
|
||||
reset_failed: 'Das Passwort konnte nicht zurückgesetzt werden.',
|
||||
change_failed: 'Die Änderung konnte nicht gespeichert werden.'
|
||||
}
|
||||
, err_generic: 'Die Änderung konnte nicht gespeichert werden.'
|
||||
};
|
||||
|
||||
let $usr = {
|
||||
init2: function () {
|
||||
// No external libraries needed — go straight to render.
|
||||
$ocms.getScript([], function () { $usr.init3(); });
|
||||
},
|
||||
init3: function () {
|
||||
$fis.cf(true);
|
||||
$fis.lf(true);
|
||||
$('#activemodule').text($uct.mdl);
|
||||
$usr.eM();
|
||||
$usr.renderLst();
|
||||
},
|
||||
/* Default ribbon (nothing selected). */
|
||||
eM: function (r) {
|
||||
let m = [];
|
||||
if (bool(r, false) === true) { m.push({ lbl: $uct.rel, fnc: $usr.renderLst }); }
|
||||
return $('#topbar').ocmsmenu(m);
|
||||
},
|
||||
/* Per-row ribbon shown while a user row is selected (mirrors the Banking module's iMn/eM swap). */
|
||||
iMn: function (rw) {
|
||||
return $('#topbar').ocmsmenu([
|
||||
{ lbl: $uct.pwdreset, fnc: function () { $usr.pwdResetDlg(rw); } },
|
||||
{ lbl: $uct.email, fnc: function () { $usr.emailDlg(rw); } },
|
||||
{ lbl: $uct.groups, fnc: function () { $usr.groupsDlg(rw); } }
|
||||
]);
|
||||
},
|
||||
renderLst: function () {
|
||||
let usrlst = $fis.frm_list(true, true).ldng(1), usrfrm = $$.dc('usrfrm', usrlst);
|
||||
$usr.eM(true);
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('usr/list'), success: function (r) {
|
||||
usrfrm.empty();
|
||||
let ts = $$.tblset({}, usrfrm);
|
||||
let thr = $$.tr(ts.hd);
|
||||
$.each([$uct.col_name, $uct.col_email, $uct.col_status], function (i, lbl) {
|
||||
$$.th(thr).text(lbl);
|
||||
});
|
||||
$.each(r.users || [], function (ri, rw) {
|
||||
let tr = $$.tr(ts.bdy, { id: 'usr' + rw.useraccount_id });
|
||||
$$.td(tr).text(`${rw.firstname || ''} ${rw.name || ''}`.trim());
|
||||
$$.td(tr).text(rw.email || '');
|
||||
$$.td(tr).text(bool(rw.disabled, false) === true ? $uct.disabled : $uct.active);
|
||||
tr.click(function () {
|
||||
tr.toggleClass('selected').siblings().rC('selected');
|
||||
if (tr.is('.selected') === true) { $usr.iMn(rw); } else { $usr.eM(true); }
|
||||
});
|
||||
});
|
||||
}, error: function () {
|
||||
usrfrm.empty();
|
||||
$$.dc('usr_err', usrfrm).text($uct.loadfail);
|
||||
}, complete: function () { usrlst.ldng(0); }
|
||||
});
|
||||
},
|
||||
pwdResetDlg: function (rw) {
|
||||
$ocms.dlgform([
|
||||
{ name: 'useraccount_id', type: 'hidden', value: rw.useraccount_id },
|
||||
{ name: 'newpassword', label: $uct.pwd_field, type: 'string', note: $uct.pwd_hint }
|
||||
], {
|
||||
title: $uct.pwdreset,
|
||||
button: $uct.pwdreset_btn,
|
||||
submit: function (e) {
|
||||
let c = $(this).ldng(1);
|
||||
let qs = c.serializeObject(true, { typedvalues: true });
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('usr/pwdreset'), data: qs, success: function (response) {
|
||||
alert(response.emailed === true ? $uct.pwdreset_sent : $uct.pwdreset_noemail);
|
||||
c.trigger('modal_close');
|
||||
}, error: function (jqXHR) {
|
||||
let code = ((jqXHR || {}).responseJSON || {}).error;
|
||||
alert($uct.err[code] || $uct.err_generic);
|
||||
}, complete: function () { c.ldng(0); $usr.renderLst(); }
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
emailDlg: function (rw) {
|
||||
$ocms.dlgform([
|
||||
{ name: 'useraccount_id', type: 'hidden', value: rw.useraccount_id },
|
||||
{ name: 'email', label: $uct.email_field, type: 'email', required: true, value: rw.email }
|
||||
], {
|
||||
title: $uct.email,
|
||||
submit: function (e) {
|
||||
let c = $(this).ldng(1);
|
||||
let qs = c.serializeObject(true, { typedvalues: true });
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('usr/email'), data: qs, success: function () {
|
||||
c.trigger('modal_close');
|
||||
}, error: function (jqXHR) {
|
||||
let code = ((jqXHR || {}).responseJSON || {}).error;
|
||||
alert($uct.err[code] || $uct.err_generic);
|
||||
}, complete: function () { c.ldng(0); $usr.renderLst(); }
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
groupsDlg: function (rw) {
|
||||
let f = $$.dc('usr_groups_dlg');
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('usr/groups'), data: { useraccount_id: rw.useraccount_id }, success: function (r) {
|
||||
$.each(r.groups || [], function (gi, g) {
|
||||
let row = $$.dc('usr_group_row', f);
|
||||
let cid = 'usrg' + g.usergroup_id;
|
||||
let cb = $$.i({ type: 'checkbox', id: cid }).prop('checked', bool(g.member, false)).appendTo(row);
|
||||
$$.lbl(g.name, { for: cid }).appendTo(row);
|
||||
cb.change(function () {
|
||||
let checked = cb.prop('checked');
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('usr/groupset'),
|
||||
data: { useraccount_id: rw.useraccount_id, usergroup_id: g.usergroup_id, member: checked },
|
||||
error: function () {
|
||||
cb.prop('checked', !checked);
|
||||
alert($uct.err_generic);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
$ocms.dlg(f, { title: string($uct.groups_title, [`${rw.firstname || ''} ${rw.name || ''}`.trim()]), size: [420, 380] });
|
||||
}
|
||||
};
|
||||
let $$usr = { init2: $usr.init2, auth: {} };
|
||||
export default $$usr;
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
let $uct={mdl:"Benutzerverwaltung",rel:"Neu Laden",col_name:"Name",col_email:"E-Mail",col_status:"Status",active:"Aktiv",disabled:"Deaktiviert",loadfail:"Die Benutzerliste konnte nicht geladen werden.",pwdreset:"Passwort zurücksetzen",pwdreset_btn:"Zurücksetzen",pwd_field:"Neues Passwort",pwd_hint:"Leer lassen, um automatisch ein Passwort zu generieren. In jedem Fall wird das neue Passwort per E-Mail an den Benutzer gesendet.",pwdreset_sent:"Das neue Passwort wurde per E-Mail an den Benutzer gesendet.",pwdreset_noemail:"Das Passwort wurde zurückgesetzt, konnte aber nicht per E-Mail zugestellt werden.",email:"E-Mail-Adresse ändern",email_field:"E-Mail-Adresse",groups:"Gruppenzugehörigkeit",groups_title:"Gruppen – {0}",err:{requirements:"Das Passwort erfüllt nicht die Mindestanforderungen (mind. 6 Zeichen).",duplicate_email:"Diese E-Mail-Adresse ist bereits einem anderen Benutzerkonto zugeordnet.",reset_failed:"Das Passwort konnte nicht zurückgesetzt werden.",change_failed:"Die Änderung konnte nicht gespeichert werden."},err_generic:"Die Änderung konnte nicht gespeichert werden."},$usr={init2:function(){$ocms.getScript([],(function(){$usr.init3()}))},init3:function(){$fis.cf(!0),$fis.lf(!0),$("#activemodule").text($uct.mdl),$usr.eM(),$usr.renderLst()},eM:function(e){let t=[];return!0===bool(e,!1)&&t.push({lbl:$uct.rel,fnc:$usr.renderLst}),$("#topbar").ocmsmenu(t)},iMn:function(e){return $("#topbar").ocmsmenu([{lbl:$uct.pwdreset,fnc:function(){$usr.pwdResetDlg(e)}},{lbl:$uct.email,fnc:function(){$usr.emailDlg(e)}},{lbl:$uct.groups,fnc:function(){$usr.groupsDlg(e)}}])},renderLst:function(){let e=$fis.frm_list(!0,!0).ldng(1),t=$$.dc("usrfrm",e);$usr.eM(!0),$ocms.postXT({url:$ocms.url("usr/list"),success:function(e){t.empty();let r=$$.tblset({},t),n=$$.tr(r.hd);$.each([$uct.col_name,$uct.col_email,$uct.col_status],(function(e,t){$$.th(n).text(t)})),$.each(e.users||[],(function(e,t){let n=$$.tr(r.bdy,{id:"usr"+t.useraccount_id});$$.td(n).text(`${t.firstname||""} ${t.name||""}`.trim()),$$.td(n).text(t.email||""),$$.td(n).text(!0===bool(t.disabled,!1)?$uct.disabled:$uct.active),n.click((function(){n.toggleClass("selected").siblings().rC("selected"),!0===n.is(".selected")?$usr.iMn(t):$usr.eM(!0)}))}))},error:function(){t.empty(),$$.dc("usr_err",t).text($uct.loadfail)},complete:function(){e.ldng(0)}})},pwdResetDlg:function(e){$ocms.dlgform([{name:"useraccount_id",type:"hidden",value:e.useraccount_id},{name:"newpassword",label:$uct.pwd_field,type:"string",note:$uct.pwd_hint}],{title:$uct.pwdreset,button:$uct.pwdreset_btn,submit:function(e){let t=$(this).ldng(1),r=t.serializeObject(!0,{typedvalues:!0});$ocms.postXT({url:$ocms.url("usr/pwdreset"),data:r,success:function(e){alert(!0===e.emailed?$uct.pwdreset_sent:$uct.pwdreset_noemail),t.trigger("modal_close")},error:function(e){let t=((e||{}).responseJSON||{}).error;alert($uct.err[t]||$uct.err_generic)},complete:function(){t.ldng(0),$usr.renderLst()}})}})},emailDlg:function(e){$ocms.dlgform([{name:"useraccount_id",type:"hidden",value:e.useraccount_id},{name:"email",label:$uct.email_field,type:"email",required:!0,value:e.email}],{title:$uct.email,submit:function(e){let t=$(this).ldng(1),r=t.serializeObject(!0,{typedvalues:!0});$ocms.postXT({url:$ocms.url("usr/email"),data:r,success:function(){t.trigger("modal_close")},error:function(e){let t=((e||{}).responseJSON||{}).error;alert($uct.err[t]||$uct.err_generic)},complete:function(){t.ldng(0),$usr.renderLst()}})}})},groupsDlg:function(e){let t=$$.dc("usr_groups_dlg");$ocms.postXT({url:$ocms.url("usr/groups"),data:{useraccount_id:e.useraccount_id},success:function(r){$.each(r.groups||[],(function(r,n){let s=$$.dc("usr_group_row",t),u="usrg"+n.usergroup_id,i=$$.i({type:"checkbox",id:u}).prop("checked",bool(n.member,!1)).appendTo(s);$$.lbl(n.name,{for:u}).appendTo(s),i.change((function(){let t=i.prop("checked");$ocms.postXT({url:$ocms.url("usr/groupset"),data:{useraccount_id:e.useraccount_id,usergroup_id:n.usergroup_id,member:t},error:function(){i.prop("checked",!t),alert($uct.err_generic)}})}))}))}}),$ocms.dlg(t,{title:string($uct.groups_title,[`${e.firstname||""} ${e.name||""}`.trim()]),size:[420,380]})}},$$usr={init2:$usr.init2,auth:{}};export default $$usr;
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
.usrfrm>table{border-collapse:collapse;margin:2rem}.usrfrm>table td,.usrfrm>table th{padding:.2rem .35rem;border:1px solid #ccc}.usrfrm>table tr{cursor:pointer}.usrfrm>table tr:nth-child(2n+1) td{background-color:#eee}.usrfrm>table tr:hover td{background-color:#f3f6fb}.usrfrm>table tr.selected td{background-color:#add8e6}.usrfrm .usr_err{padding:1rem 2rem;color:#b00020;font-weight:700}.usr_groups_dlg{padding:.25rem 0}.usr_groups_dlg .usr_group_row{display:flex;align-items:center;gap:.5rem;padding:.35rem 0;border-bottom:1px solid #eee}.usr_groups_dlg .usr_group_row:last-child{border-bottom:none}.usr_groups_dlg .usr_group_row input[type=checkbox]{margin:0}.usr_groups_dlg .usr_group_row label{margin:0;font-weight:400}
|
||||
Reference in New Issue
Block a user