Add Administration user management: list, password reset, email change, group assignments
Playwright Tests / test (push) Canceled after 0s
Playwright Tests / test (push) Canceled after 0s
Wires the previously-unused fis_admin_* user/group stored procs into the Admin module (fds_sys-gated), adds a users grid with a per-row ribbon (reset password, change email, manage group membership), and always emails a password reset result to the user rather than showing it to the admin. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
using Fuchs.Controllers;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the pure (non-DB) logic behind the Admin module's user-management endpoints —
|
||||
/// specifically the admin-initiated password-reset notification content, since the SQL-bound
|
||||
/// parts of IntranetController.Admin.cs (fis_admin_* proc calls) can't be unit-tested per
|
||||
/// repo convention and require a running database.
|
||||
/// </summary>
|
||||
public class AdminUserManagementTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildPasswordResetEmail_ValidInput_ContainsNameAndPassword()
|
||||
{
|
||||
var (subject, html) = IntranetController.BuildPasswordResetEmail("Max Mustermann", "Ab12Cd34");
|
||||
|
||||
Assert.Contains("Passwort", subject);
|
||||
Assert.Contains("Max Mustermann", html);
|
||||
Assert.Contains("Ab12Cd34", html);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("<script>alert(1)</script>", "<script>alert(1)</script>")]
|
||||
[InlineData("O'Brien&Co", "O'Brien&Co")]
|
||||
public void BuildPasswordResetEmail_PasswordWithHtmlSpecialChars_IsHtmlEncoded(string rawPassword, string encoded)
|
||||
{
|
||||
var (_, html) = IntranetController.BuildPasswordResetEmail("Test User", rawPassword);
|
||||
|
||||
Assert.Contains(encoded, html);
|
||||
Assert.DoesNotContain(rawPassword, html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPasswordResetEmail_EmptyName_StillProducesValidContent()
|
||||
{
|
||||
var (subject, html) = IntranetController.BuildPasswordResetEmail("", "somepassword");
|
||||
|
||||
Assert.False(string.IsNullOrWhiteSpace(subject));
|
||||
Assert.Contains("somepassword", html);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
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;
|
||||
|
||||
@@ -14,6 +18,13 @@ namespace Fuchs.Controllers;
|
||||
// The menu button is only shown, and the module script only loaded, for such users (frontend),
|
||||
// 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.
|
||||
public partial class IntranetController
|
||||
{
|
||||
// fds_sys authorization must exceed this to use the Admin module.
|
||||
@@ -72,6 +83,183 @@ 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();
|
||||
@@ -90,6 +278,29 @@ public partial class IntranetController
|
||||
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"/>).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: Active
|
||||
lastUpdated: 2026-07-16
|
||||
lastUpdated: 2026-09-09
|
||||
applyTo:
|
||||
- "Fuchs/Controllers/IntranetController.Admin.cs"
|
||||
- "Fuchs/Services/SystemStatusService.cs"
|
||||
@@ -9,8 +9,11 @@ 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"
|
||||
---
|
||||
|
||||
# Admin / System-Status module
|
||||
@@ -84,14 +87,46 @@ 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.
|
||||
- `Fuchs/Controllers/IntranetController.Admin.cs` — `Do_Process_Admin` dispatch + auth gate + camelCase JSON helper + user-management endpoints.
|
||||
- `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.Tests/SystemStatusServiceTests.cs` — service tests.
|
||||
- `Fuchs.Tests/AdminUserManagementTests.cs` — password-reset email content tests.
|
||||
|
||||
## 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)
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-09-09
|
||||
applyTo:
|
||||
- "Fuchs/Controllers/IntranetController.Admin.cs"
|
||||
- "Fuchs/js/intranet/modules/fis.admin*.js"
|
||||
- "Fuchs/js/intranet/modules/fis.admin.scss"
|
||||
- "Fuchs_Database/dbo/Functions/fis_admin_*.sql"
|
||||
- "Fuchs_Database/dbo/Stored Procedures/fis_admin_*.sql"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0014 — Admin user management: folded into the `admin` module, dual auth axis, password always emailed
|
||||
|
||||
## Context
|
||||
An operator needs to see the list of non-deleted intranet user accounts (name + email), and
|
||||
per account reset the password, change the email address, and view/change group assignments.
|
||||
The database layer for this already existed (`fis_admin_getUserAccountList`,
|
||||
`fis_admin_getUserGroupList`, `fis_admin_setUserAccount`, `fis_admin_resetUserPassword`,
|
||||
`fis_admin_addGroupMember`/`remGroupMember`) but was completely unwired from any controller.
|
||||
Those procs gate on a **different** authorization mechanism than the rest of the app: a
|
||||
per-user `intranet_admin` setting read via `fis_admin_getUserAuth(@authaccount) > 1`, entirely
|
||||
independent of the `fis_getModuleAuth`/`fis_module_auth` mechanism the rest of the app
|
||||
(including the existing Admin module, see
|
||||
[0011](0011-admin-module-system-status.md)) uses.
|
||||
|
||||
## Decision
|
||||
- **Fold user management into the existing `admin` module** (`fn=admin`) rather than create a
|
||||
new top-level module. New `id` cases (`users`, `userpwdreset`, `useremail`, `usergroups`,
|
||||
`usergroupset`) sit in `Do_Process_Admin` alongside the system-status diagnostics, behind the
|
||||
**same** `fds_sys > 4` server-side gate as the rest of that module.
|
||||
- **Known, accepted gap: two independent authorization axes.** `fds_sys > 4` controls whether
|
||||
the endpoints are reachable at all; the underlying `fis_admin_*` SQL objects separately
|
||||
require the caller's `intranet_admin` setting to be `> 1` (`fis_admin_getUserAuth`). An
|
||||
account with `fds_sys > 4` but no `intranet_admin` setting will reach these endpoints but get
|
||||
empty result sets / no-op writes from SQL — not an error, just silently nothing. **Operational
|
||||
consequence:** any account meant to do user management must be provisioned with both
|
||||
`fds_sys > 4` and `intranet_admin >= 2` (via `fis_useraccounts_settings`). This was a deliberate
|
||||
simplicity trade-off (see Alternatives) rather than unifying the two permission systems.
|
||||
- **Password reset always emails the result; never displays it.** The ribbon's "Passwort
|
||||
zurücksetzen" action lets the admin either type a specific new password or leave the field
|
||||
blank to auto-generate one (`fis_admin_resetUserPassword` gained an optional `@newpassword`
|
||||
parameter for this). Either way, the resulting plaintext password is sent only to the target
|
||||
user's stored email address via `IComService` (respecting the `Fuchs:Email:OverrideRecipient`
|
||||
dev safety net) — the admin never sees it on screen.
|
||||
- **Change email reuses `fis_admin_setUserAccount`** (prefilling the other fields from the
|
||||
existing row, `@password = NULL` so the update path leaves the password untouched) instead of
|
||||
adding a narrower proc — its `WHEN MATCHED` branch never touches `password_enc`, and its
|
||||
existing duplicate-email `THROW 60001` is mapped to an HTTP 409 in the controller.
|
||||
- **New `fis_admin_getUserGroupMembership` function** returns every group plus a `member` bit
|
||||
for one target user (mirrors `fis_admin_getUserGroupList`'s auth-gate style) — needed for the
|
||||
per-user group checkbox dialog; toggling a checkbox calls `fis_admin_addGroupMember`/
|
||||
`remGroupMember` immediately (no separate save step, consistent with the rest of the module's
|
||||
ribbon actions).
|
||||
- Per the [exception-user-notification rule](0003-interrupting-exceptions-notify-the-user.md),
|
||||
a failed password reset, a failed reset-email delivery, or a target with no usable email all
|
||||
call `IEventService.UserIssueAsync` so the admin is never left believing an operation
|
||||
succeeded silently.
|
||||
|
||||
## Consequences
|
||||
- Deploying/onboarding an admin for user management is now a two-step provisioning task
|
||||
(`fds_sys` **and** `intranet_admin`), not obvious from the UI — document this whenever
|
||||
granting the permission.
|
||||
- `fis_admin_resetUserPassword`'s signature changed (added optional `@newpassword`) and its
|
||||
result set gained a `newpassword` column; it was previously unwired from any caller, so this
|
||||
is a safe in-place change.
|
||||
- The Admin module's frontend now mixes two concerns (system diagnostics + user management)
|
||||
under one ribbon/menu entry; if user management grows substantially, revisit splitting it into
|
||||
its own module (see Alternatives).
|
||||
|
||||
## Alternatives considered
|
||||
- **Unify the two authorization axes** (e.g. make `fis_admin_*` procs check `fis_getModuleAuth`
|
||||
instead of `fis_admin_getUserAuth`, or vice versa). Rejected for this change — it would touch
|
||||
every existing `fis_admin_*` proc/function (all already gate on `fis_admin_getUserAuth`) for a
|
||||
feature that only needed to wire up what already existed; revisit if a real conflict surfaces
|
||||
in practice.
|
||||
- **New standalone top-level module** (`fn=usr`) with its own menu button and its own
|
||||
`fis_admin_getUserAuth`-based gate. Rejected per explicit user direction — folding into the
|
||||
existing Administration module was preferred over a second admin-ish menu entry.
|
||||
- **Show the new/reset password on screen instead of emailing it.** Rejected per explicit user
|
||||
direction — the password must only ever reach the user's own inbox, never be visible to the
|
||||
admin performing the reset.
|
||||
@@ -16,6 +16,14 @@ 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) {
|
||||
@@ -42,6 +50,109 @@ 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,6 +188,29 @@
|
||||
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 {
|
||||
|
||||
@@ -81,4 +81,29 @@ 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.'
|
||||
};
|
||||
|
||||
@@ -245,6 +245,33 @@
|
||||
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;
|
||||
|
||||
@@ -81,6 +81,31 @@ 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 = {
|
||||
@@ -101,6 +126,14 @@ 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) {
|
||||
@@ -127,6 +160,109 @@ 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)}.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)}.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}
|
||||
@@ -223,6 +223,7 @@
|
||||
<Build Include="dbo\Functions\json_diffObjectArrays.sql" />
|
||||
<Build Include="dbo\Functions\json_compareObjectArrays.sql" />
|
||||
<Build Include="dbo\Functions\fis_getONEPersonWidgets.sql" />
|
||||
<Build Include="dbo\Functions\fis_admin_getUserGroupMembership.sql" />
|
||||
<Build Include="dbo\Functions\fis_admin_getUserGroupList.sql" />
|
||||
<Build Include="dbo\Functions\fis_admin_getUserAccountList.sql" />
|
||||
<Build Include="dbo\Functions\fis_admin_getUserAccount_Namelist.sql" />
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
|
||||
|
||||
-- =============================================
|
||||
-- Author: <Author,,Name>
|
||||
-- Create date: <Create Date, ,>
|
||||
-- Description: <Description, ,>
|
||||
-- =============================================
|
||||
CREATE FUNCTION [dbo].[fis_admin_getUserGroupMembership]
|
||||
(
|
||||
@authaccount varchar(5)
|
||||
, @useraccount_id varchar(5)
|
||||
)
|
||||
RETURNS @RET TABLE ( [usergroup_id] varchar(5), [name] nvarchar(255) null, [member] bit, [order] int)
|
||||
AS
|
||||
BEGIN
|
||||
DECLARE @auth smallint = [dbo].[fis_admin_getUserAuth](@authaccount);
|
||||
|
||||
IF @auth > 1
|
||||
BEGIN
|
||||
INSERT INTO @RET
|
||||
SELECT ug.[usergroup_id]
|
||||
,CAST(DECRYPTBYKEY(ug.[name_enc]) as nvarchar(255))
|
||||
,'member' = CASE WHEN ugm.[useraccount_id] is not null THEN 1 ELSE 0 END
|
||||
,'order' = ROW_NUMBER() OVER (ORDER BY CAST(DECRYPTBYKEY(ug.[name_enc]) as nvarchar(255)))
|
||||
FROM [dbo].[fis_usergroups] as ug
|
||||
LEFT JOIN [dbo].[fis_usergroups_members] as ugm ON ugm.[usergroup_id] = ug.[usergroup_id]
|
||||
AND ugm.[useraccount_id] = @useraccount_id AND ugm.[DateDeleted] is null
|
||||
WHERE ug.[DateDeleted] is null;
|
||||
END
|
||||
|
||||
RETURN;
|
||||
|
||||
END
|
||||
@@ -10,6 +10,7 @@ CREATE PROCEDURE [dbo].[fis_admin_resetUserPassword]
|
||||
@authaccount varchar(10)
|
||||
,@useraccount_id varchar(10)
|
||||
,@enc_key uniqueidentifier
|
||||
,@newpassword nvarchar(20) = NULL /* admin-supplied password; when NULL a random one is generated */
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
@@ -17,7 +18,10 @@ BEGIN
|
||||
DECLARE @utcnow datetime = GETUTCDATE();
|
||||
DECLARE @auth smallint = [dbo].[fis_admin_getUserAuth](@authaccount);
|
||||
|
||||
DECLARE @newpassword nvarchar(20) = [dbo].[ocms_fn_generatePassword] (8, 0, 1, 1);
|
||||
IF @newpassword IS NULL
|
||||
BEGIN
|
||||
SET @newpassword = [dbo].[ocms_fn_generatePassword] (8, 0, 1, 1);
|
||||
END
|
||||
|
||||
|
||||
IF @auth > 1 OR @authaccount = @useraccount_id
|
||||
@@ -29,7 +33,8 @@ BEGIN
|
||||
, [UserModified] = @authaccount
|
||||
WHERE [useraccount_id] = @useraccount_id AND [dateDeleted] is null;
|
||||
|
||||
SELECT CAST(CASE WHEN DECRYPTBYKEY((SELECT TOP(1) [password_enc] FROM [dbo].[fis_useraccounts] WHERE [useraccount_id] = @useraccount_id)) = @newpassword THEN 1 ELSE 0 END as bit);
|
||||
SELECT CAST(CASE WHEN DECRYPTBYKEY((SELECT TOP(1) [password_enc] FROM [dbo].[fis_useraccounts] WHERE [useraccount_id] = @useraccount_id)) = @newpassword THEN 1 ELSE 0 END as bit) as [success]
|
||||
, @newpassword as [newpassword];
|
||||
|
||||
END
|
||||
|
||||
|
||||
Reference in New Issue
Block a user