Compare commits

..
3 Commits
Author SHA1 Message Date
StefanandClaude Sonnet 5 4386a6336a Let admins create and delete users in Benutzerverwaltung, modernize list
Playwright Tests / test (push) Canceled after 0s
Wire up two previously-unwired stored procedures for the usr module:
- fis_admin_createUserAccount gained the missing @authaccount +
  intranet_admin auth gate, a duplicate-email check, and now returns
  the (possibly auto-generated) password so it can be emailed to the
  new user, mirroring fis_admin_resetUserPassword's pattern.
- fis_admin_delUserAccount gained the same missing auth gate plus a
  guard against an admin deleting their own account.

New /do/usr/create and /do/usr/delete endpoints in
IntranetController.AdminUsers.cs, a "Neuer Benutzer" button and a
"Benutzer löschen" ribbon action in fis.usr.js, and unit tests for the
new welcome-email content (BuildWelcomeEmail).

Also restyle the user list (fis.usr.scss) from a plain bordered table
to a card-style row grid with avatar-initial badges and status pills,
matching the Admin dashboard's visual language.

Documented as ADR 0020 and in the admin-user-management concept doc.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 14:51:59 +02:00
StefanandClaude Sonnet 5 38145a917e Fix login accepting wrong credentials (empty user signed in anyway)
fis_admin_getUserAccount always inserted a result row, even when no
matching user was found, so fis_admin_authenticate never truly
returned "no result" on wrong credentials — the login handler then
signed the caller in with an empty useraccount_id. The authenticated
shell rendered (IsAuthenticated only checks the claim exists), while
every data endpoint 401'd (FuchsUserIdentity.IsAuthenticated also
checks the id is non-empty), producing an empty portal.

Also treat an empty useraccount_id in HandleLogin as a failed login
(defense in depth) and fix a column-name mismatch (auth vs
authorization) that was silently zeroing the authorization level.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 14:51:44 +02:00
StefanandClaude Sonnet 5 c18e34a3f1 Update OCORE/OCORE_web submodules, align Microsoft.Data.SqlClient to 7.0.3
Pull latest origin/main for OCORE and OCORE_web. OCORE now depends on
Microsoft.Data.SqlClient 7.0.3, so bump Fuchs and Fuchs_DataService's
own pinned reference to match and avoid an NU1605 downgrade error.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 14:51:37 +02:00
20 changed files with 739 additions and 158 deletions
+42 -3
View File
@@ -5,9 +5,9 @@ namespace Fuchs.Tests;
/// <summary> /// <summary>
/// Covers the pure (non-DB) logic behind the Admin module's user-management endpoints — /// 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 /// the admin-initiated password-reset and new-account welcome notification content, since the
/// parts of IntranetController.Admin.cs (fis_admin_* proc calls) can't be unit-tested per /// SQL-bound parts of IntranetController.AdminUsers.cs (fis_admin_* proc calls) can't be
/// repo convention and require a running database. /// unit-tested per repo convention and require a running database.
/// </summary> /// </summary>
public class AdminUserManagementTests public class AdminUserManagementTests
{ {
@@ -40,4 +40,43 @@ public class AdminUserManagementTests
Assert.False(string.IsNullOrWhiteSpace(subject)); Assert.False(string.IsNullOrWhiteSpace(subject));
Assert.Contains("somepassword", html); Assert.Contains("somepassword", html);
} }
[Fact]
public void BuildWelcomeEmail_ValidInput_ContainsNameEmailAndPassword()
{
var (subject, html) = IntranetController.BuildWelcomeEmail("Max Mustermann", "max@example.com", "Ab12Cd34");
Assert.Contains("Zugang", subject);
Assert.Contains("Max Mustermann", html);
Assert.Contains("max@example.com", html);
Assert.Contains("Ab12Cd34", html);
}
[Theory]
[InlineData("<script>alert(1)</script>", "&lt;script&gt;alert(1)&lt;/script&gt;")]
[InlineData("O'Brien&Co", "O&#39;Brien&amp;Co")]
public void BuildWelcomeEmail_PasswordWithHtmlSpecialChars_IsHtmlEncoded(string rawPassword, string encoded)
{
var (_, html) = IntranetController.BuildWelcomeEmail("Test User", "test@example.com", rawPassword);
Assert.Contains(encoded, html);
Assert.DoesNotContain(rawPassword, html);
}
[Fact]
public void BuildWelcomeEmail_EmailWithHtmlSpecialChars_IsHtmlEncoded()
{
var (_, html) = IntranetController.BuildWelcomeEmail("Test User", "a&b@example.com", "somepassword");
Assert.Contains("a&amp;b@example.com", html);
}
[Fact]
public void BuildWelcomeEmail_EmptyName_StillProducesValidContent()
{
var (subject, html) = IntranetController.BuildWelcomeEmail("", "test@example.com", "somepassword");
Assert.False(string.IsNullOrWhiteSpace(subject));
Assert.Contains("somepassword", html);
}
} }
@@ -175,6 +175,113 @@ public partial class IntranetController
return AdminJson(new { ok = true }); return AdminJson(new { ok = true });
} }
case "create":
{
if (!HasForm("name", "firstname", "email")) return BadRequest400();
string name = Form("name");
string firstname = Form("firstname");
string email = Form("email");
var pl = StdParamlist(
SQL_VarChar("@authaccount", UserAccountID),
SQL_NVarChar("@name", name),
SQL_NVarChar("@firstname", firstname),
SQL_VarChar("@title", Form("title"), dbNull_IfEmpty: true),
SQL_VarChar("@gender", Form("gender"), dbNull_IfEmpty: true),
SQL_VarChar("@email", email),
SQL_VarChar("@mobile", Form("mobile"), dbNull_IfEmpty: true),
SQL_NVarChar("@password", null, dbNull_IfEmpty: true));
System.Data.DataRow? createdRow;
try
{
var res = await getSQLDatatable_async(
"EXECUTE [dbo].[fis_admin_createUserAccount] @authaccount, @name, @firstname, " +
"@title, @gender, @email, @mobile, @password, @enc_key;",
_intranet.Intranet__SQLConnectionString, pl,
Security: DbSec, options: SqlOpt(fn, id, code));
createdRow = res.Count > 0 ? res.FirstRow : null;
}
catch (SqlException ex) when (ex.Number == 60001)
{
_logger.LogWarning("AdminUsers create: duplicate email rejected user={User}", UserAccountID);
return StatusCode(409, new { error = "duplicate_email" });
}
catch (Exception ex)
{
await NotifyAdminUserActionFailedAsync("Benutzer anlegen fehlgeschlagen",
"Ein neues Benutzerkonto konnte nicht angelegt werden.", "", ex);
return StatusCode(500, new { error = "create_failed" });
}
if (createdRow == null)
{
_logger.LogWarning("AdminUsers create not successful user={User}", UserAccountID);
return StatusCode(500, new { error = "create_failed" });
}
string newId = createdRow.nz("useraccount_id");
string newPassword = createdRow.nz("newpassword");
string targetName = $"{firstname} {name}".Trim();
var (subject, html) = BuildWelcomeEmail(targetName, email, newPassword);
bool emailed = email.Length > 5 &&
await _comService.SendEmailAsync("usrwelcome_" + email, subject, html, email, targetName, null);
if (!emailed)
{
_logger.LogWarning("AdminUsers create: welcome mail not delivered target={Target} user={User}",
newId, UserAccountID);
await _events.UserIssueAsync("Neuer Benutzer: E-Mail-Versand fehlgeschlagen",
$"Die Zugangsdaten für das neue Benutzerkonto {newId} konnten nicht per E-Mail zugestellt werden.",
UserAccountID, new Dictionary<string, object?> { ["target"] = newId, ["email"] = email });
}
_logger.LogInformation("AdminUsers create succeeded target={Target} emailed={Emailed} user={User}",
newId, emailed, UserAccountID);
return AdminJson(new { ok = true, useraccount_id = newId, emailed });
}
case "delete":
{
if (!HasForm("useraccount_id")) return BadRequest400();
string targetId = Form("useraccount_id");
if (targetId.Equals(UserAccountID, StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("AdminUsers delete: self-delete rejected user={User}", UserAccountID);
return StatusCode(409, new { error = "cannot_delete_self" });
}
bool success;
try
{
var pl = StdParamlist(
SQL_VarChar("@authaccount", UserAccountID),
SQL_VarChar("@useraccount_id", targetId));
var res = await getSQLDatatable_async(
"EXECUTE [dbo].[fis_admin_delUserAccount] @authaccount, @useraccount_id;",
_intranet.Intranet__SQLConnectionString, pl,
Security: DbSec, options: SqlOpt(fn, id, code));
success = res.Count > 0 && res.FirstRow[0] is true;
}
catch (Exception ex)
{
await NotifyAdminUserActionFailedAsync("Benutzer löschen fehlgeschlagen",
$"Das Benutzerkonto {targetId} konnte nicht gelöscht werden.", targetId, ex);
return StatusCode(500, new { error = "delete_failed" });
}
if (!success)
{
_logger.LogWarning("AdminUsers delete not successful target={Target} user={User}", targetId, UserAccountID);
return StatusCode(500, new { error = "delete_failed" });
}
_logger.LogInformation("AdminUsers delete succeeded target={Target} user={User}", targetId, UserAccountID);
return AdminJson(new { ok = true });
}
case "groups": case "groups":
{ {
if (!HasForm("useraccount_id")) return BadRequest400(); if (!HasForm("useraccount_id")) return BadRequest400();
@@ -238,6 +345,16 @@ public partial class IntranetController
$"<p>Guten Tag {targetName},<br/>Ihr Passwort wurde von einem Administrator zurückgesetzt.<br/>" + $"<p>Guten Tag {targetName},<br/>Ihr Passwort wurde von einem Administrator zurückgesetzt.<br/>" +
$"Ihr neues Passwort: {HttpUtility.HtmlEncode(newPassword)}</p>"); $"Ihr neues Passwort: {HttpUtility.HtmlEncode(newPassword)}</p>");
/// <summary>
/// Builds the subject/HTML body for the "a new intranet account was created for you"
/// notification. Extracted as pure logic (no DB/HTTP), mirroring <see cref="BuildPasswordResetEmail"/>.
/// </summary>
internal static (string Subject, string Html) BuildWelcomeEmail(string targetName, string email, string newPassword) => (
"sanitaerfuchs.de Intranet Zugang eingerichtet",
$"<p>Guten Tag {targetName},<br/>für Sie wurde ein Zugang zum sanitaerfuchs.de Intranet eingerichtet.<br/>" +
$"Ihre E-Mail-Adresse: {HttpUtility.HtmlEncode(email)}<br/>" +
$"Ihr Passwort: {HttpUtility.HtmlEncode(newPassword)}</p>");
/// <summary> /// <summary>
/// Logs and raises a user-facing notification for an admin user-management action that /// Logs and raises a user-facing notification for an admin user-management action that
/// threw unexpectedly, per the "interrupting exceptions notify the user" rule /// threw unexpectedly, per the "interrupting exceptions notify the user" rule
+3 -3
View File
@@ -249,16 +249,16 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
_logger.LogDebug("HandleLogin attempt for email={Email} ip={IP}", _logger.LogDebug("HandleLogin attempt for email={Email} ip={IP}",
email, HttpContext.Connection.RemoteIpAddress); email, HttpContext.Connection.RemoteIpAddress);
var row = await _intranet.AuthenticateAsync(email, password); var row = await _intranet.AuthenticateAsync(email, password);
if (row == null) string userId = row?.nz("useraccount_id") ?? "";
if (row == null || string.IsNullOrEmpty(userId))
{ {
_logger.LogWarning("Login failed for email={Email} ip={IP}", _logger.LogWarning("Login failed for email={Email} ip={IP}",
email, HttpContext.Connection.RemoteIpAddress); email, HttpContext.Connection.RemoteIpAddress);
return Unauthorized401(); return Unauthorized401();
} }
string userId = row.nz("useraccount_id");
string userEmail = row.nz("email"); string userEmail = row.nz("email");
int auth = int.TryParse(row.nz("authorization"), out var a) ? a : 0; int auth = int.TryParse(row.nz("auth"), out var a) ? a : 0;
var identity = FuchsUserIdentity.BuildIdentity(userId, userEmail, auth, Fuchs_intranet.AuthScheme); var identity = FuchsUserIdentity.BuildIdentity(userId, userEmail, auth, Fuchs_intranet.AuthScheme);
var principal = new System.Security.Claims.ClaimsPrincipal(identity); var principal = new System.Security.Claims.ClaimsPrincipal(identity);
+40 -18
View File
@@ -1,6 +1,6 @@
--- ---
status: Active status: Active
lastUpdated: 2026-09-09 lastUpdated: 2026-09-14
applyTo: applyTo:
- "Fuchs/Controllers/IntranetController.AdminUsers.cs" - "Fuchs/Controllers/IntranetController.AdminUsers.cs"
- "Fuchs/js/intranet/modules/fis.usr*.js" - "Fuchs/js/intranet/modules/fis.usr*.js"
@@ -11,20 +11,22 @@ applyTo:
relatedDecisions: relatedDecisions:
- "0014-admin-user-management.md" - "0014-admin-user-management.md"
- "0015-split-admin-user-management-module.md" - "0015-split-admin-user-management-module.md"
- "0020-user-management-create-and-delete.md"
--- ---
# User Management module (`usr`) # User Management module (`usr`)
## Summary ## Summary
The **Benutzerverwaltung** module lets a privileged operator see every non-deleted intranet 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 user account (name + email + active/disabled), create a new account, delete an existing one,
view/change group assignments. It shares its sidemenu-visibility and server-side authorization reset a password, change an email address, and view/change group assignments. It shares its
gate with the separate Admin/status-dashboard module (`fds_sys > 4`) but is otherwise fully sidemenu-visibility and server-side authorization gate with the separate Admin/status-dashboard
independent — own `fn=usr`, own frontend bundle, own controller partial. See module (`fds_sys > 4`) but is otherwise fully independent — own `fn=usr`, own frontend bundle,
[0015](../Decisions/0015-split-admin-user-management-module.md) for why these were split from own controller partial. See [0015](../Decisions/0015-split-admin-user-management-module.md) for
one module into two, and [0014](../Decisions/0014-admin-user-management.md) for the original why these were split from one module into two, [0014](../Decisions/0014-admin-user-management.md)
decisions this one carries forward (password always emailed, dual auth axis, reuse of for the original decisions this one carries forward (password always emailed, dual auth axis,
`fis_admin_setUserAccount` for email changes). reuse of `fis_admin_setUserAccount` for email changes), and
[0020](../Decisions/0020-user-management-create-and-delete.md) for create/delete.
## How it works ## How it works
@@ -50,11 +52,14 @@ click "Benutzerverwaltung" → $ocms.init('usr')
load /web/fis.usr.de.js + /web/fis.usr.css load /web/fis.usr.de.js + /web/fis.usr.css
$ocms.usr.init2() → init3() → renderLst() $ocms.usr.init2() → init3() → renderLst()
POST /do/usr/list → { users: [...] } grid of every non-deleted account 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 base ribbon (eM) → "Neuer Benutzer" (always available, not just when a row is selected)
POST /do/usr/create {name, firstname, email, title?, gender?, mobile?} → { ok, useraccount_id, emailed } | 409 duplicate_email
row click → per-row ribbon (iMn): Passwort zurücksetzen / E-Mail ändern / Gruppenzugehörigkeit / Benutzer löschen
POST /do/usr/pwdreset {useraccount_id, newpassword?} → { ok, emailed } POST /do/usr/pwdreset {useraccount_id, newpassword?} → { ok, emailed }
POST /do/usr/email {useraccount_id, email} → { ok } | 409 duplicate_email POST /do/usr/email {useraccount_id, email} → { ok } | 409 duplicate_email
POST /do/usr/groups {useraccount_id} → { groups: [...] } POST /do/usr/groups {useraccount_id} → { groups: [...] }
POST /do/usr/groupset {useraccount_id, usergroup_id, member} → { ok, member } POST /do/usr/groupset {useraccount_id, usergroup_id, member} → { ok, member }
POST /do/usr/delete {useraccount_id} → { ok } | 409 cannot_delete_self
``` ```
### Backend (`IntranetController.AdminUsers.cs`) ### Backend (`IntranetController.AdminUsers.cs`)
@@ -80,26 +85,43 @@ click "Benutzerverwaltung" → $ocms.init('usr')
returns every group plus a `member` bit for that one user. returns every group plus a `member` bit for that one user.
- `groupset` → calls `fis_admin_addGroupMember`/`remGroupMember` depending on the posted - `groupset` → calls `fis_admin_addGroupMember`/`remGroupMember` depending on the posted
`member` flag — applied immediately per checkbox toggle, no separate save step. `member` flag — applied immediately per checkbox toggle, no separate save step.
- `create` → `fis_admin_createUserAccount(@authaccount, @name, @firstname, @title, @gender,
@email, @mobile, @password, @enc_key)`. Only name/firstname/email are required from the
dialog; the admin may leave password blank (the proc auto-generates one and returns it as
`newpassword`, mirroring `fis_admin_resetUserPassword`'s pattern). The controller then emails
the new account's credentials via `BuildWelcomeEmail` — same "emailed, never shown on screen"
rule as `pwdreset` — and returns `emailed` so the UI can tell the admin whether delivery
succeeded. Duplicate email → 409 `duplicate_email`, same mapping as `email`.
- `delete` → `fis_admin_delUserAccount(@authaccount, @useraccount_id)` (soft-delete: sets
`UserDeleted`/`DateDeleted`). The controller rejects a self-delete with 409
`cannot_delete_self` before even calling SQL; the proc enforces the same guard independently.
See [0020](../Decisions/0020-user-management-create-and-delete.md) for why both procs needed
the `intranet_admin` auth gate added when they were wired up.
### Frontend (`fis.usr.js` + `fis.usr_txt_de.js` + `fis.usr.scss`) ### 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 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 rather than the Admin dashboard's card layout: `renderLst` builds a card-style row grid (avatar
`$fis.frm_list`/`$$.tblset`; a row click toggles `.selected` and swaps the `#topbar` ribbon initials badge, name/email columns, a status pill) rather than a plain `<table>`; a row click
between the base "Neu Laden" button (`eM`) and the three per-row actions (`iMn`). Each action is toggles `.selected` and swaps the `#topbar` ribbon between the base ribbon (`eM`: "Neuer
a dialog (`pwdResetDlg`/`emailDlg`/`groupsDlg`) using `$ocms.dlgform`/`$ocms.dlg` with a manual Benutzer" always available, plus "Neu Laden" once a row was selected) and the four per-row
actions (`iMn`: Passwort zurücksetzen / E-Mail ändern / Gruppenzugehörigkeit / Benutzer löschen).
`pwdResetDlg`/`emailDlg`/`groupsDlg`/`createDlg` use `$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 `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`. to a specific German message via `$uct.err`; `deleteUser` instead follows Banking's
`confirm(...)`-before-destructive-action pattern (`fis.bam.js`'s `smd`/`mbd`) since there's
nothing to collect beyond the confirmation itself.
## Key files ## Key files
- `Fuchs/Controllers/IntranetController.AdminUsers.cs` — `Do_Process_AdminUsers` dispatch + `BuildPasswordResetEmail` + `NotifyAdminUserActionFailedAsync`. - `Fuchs/Controllers/IntranetController.AdminUsers.cs` — `Do_Process_AdminUsers` dispatch + `BuildPasswordResetEmail`/`BuildWelcomeEmail` + `NotifyAdminUserActionFailedAsync`.
- `Fuchs/Controllers/IntranetController.Admin.cs` — shared `AdminMinAuthExclusive`/`GetSystemAdminAuthAsync`/`AdminJson` this module reuses. - `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/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/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/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_Database/dbo/Stored Procedures/fis_admin_resetUserPassword.sql`, `fis_admin_setUserAccount.sql`, `fis_admin_addGroupMember.sql`, `fis_admin_remGroupMember.sql`, `fis_admin_createUserAccount.sql`, `fis_admin_delUserAccount.sql` — write-side user/group procs.
- `Fuchs.Tests/AdminUserManagementTests.cs` — password-reset email content tests. - `Fuchs.Tests/AdminUserManagementTests.cs` — password-reset and welcome-email content tests.
## Related decisions ## Related decisions
- [0014 — Admin user management (original decisions, still in force)](../Decisions/0014-admin-user-management.md) - [0014 — Admin user management (original decisions, still in force)](../Decisions/0014-admin-user-management.md)
- [0020 — Wire up create/delete, welcome email on create, guard delete](../Decisions/0020-user-management-create-and-delete.md)
- [0015 — Split Admin into separate Status/User-Management modules](../Decisions/0015-split-admin-user-management-module.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) - [0003 — Interrupting exceptions notify the user](../Decisions/0003-interrupting-exceptions-notify-the-user.md)
@@ -0,0 +1,91 @@
---
status: Accepted
date: 2026-09-14
applyTo:
- "Fuchs/Controllers/IntranetController.AdminUsers.cs"
- "Fuchs/js/intranet/modules/fis.usr*.js"
- "Fuchs/js/intranet/modules/fis.usr.scss"
- "Fuchs_Database/dbo/Stored Procedures/fis_admin_createUserAccount.sql"
- "Fuchs_Database/dbo/Stored Procedures/fis_admin_delUserAccount.sql"
supersededBy: ""
---
# 0020 — User management: wire up create/delete, welcome email on create, guard delete
## Context
The `usr` module (see [0015](0015-split-admin-user-management-module.md)) let an admin list
users, reset a password, change an email address, and manage group membership, but had no way
to add a new account or remove one — the admin had to do that directly in the database. Two
stored procedures for this already existed in `Fuchs_Database` but were completely unwired from
any controller: `fis_admin_createUserAccount` and `fis_admin_delUserAccount`. Neither followed
the module's established conventions:
- `fis_admin_createUserAccount` took no `@authaccount` and performed **no** `intranet_admin`
authorization check at all (every other write in this module checks
`fis_admin_getUserAuth(@authaccount) > 1`, per [0014](0014-admin-user-management.md)'s dual
auth axis), had no duplicate-email guard (`fis_admin_setUserAccount` does), and generated the
initial password inline in the `INSERT` without ever returning it — so nothing calling it
could email the new user their credentials.
- `fis_admin_delUserAccount` likewise had no `intranet_admin` check, and nothing stopped an
admin from deleting their own account.
## Decision
- **`create` and `delete` are new `id` cases in `Do_Process_AdminUsers`**, behind the same
`fds_sys > 4` gate as every other action in the module.
- **`fis_admin_createUserAccount` was brought in line with its siblings** rather than reused via
`fis_admin_setUserAccount`'s existing "insert when `@useraccount_id` is NULL" branch (which
would have been the smaller diff): it gained `@authaccount` + the standard
`fis_admin_getUserAuth(@authaccount) > 1` gate, the same duplicate-email check
`fis_admin_setUserAccount` already has, and — mirroring `fis_admin_resetUserPassword`'s
existing pattern — resolves `@password` (auto-generating one via `ocms_fn_generatePassword`
when the admin leaves it blank) **before** encrypting it, and returns it in the result set as
`newpassword` alongside the created row. `fis_admin_setUserAccount`'s insert branch was left
alone (still reachable in principle, but has no caller) since changing its shared behavior
risked the existing `email` action; a dedicated create proc with an explicit contract was
judged clearer than overloading an update proc's edge case.
- **New account creation always emails the credentials, never displays them** — identical
handling to password reset ([0014](0014-admin-user-management.md)): `HandleLogin`-style
`IComService.SendEmailAsync` (respecting `Fuchs:Email:OverrideRecipient`) sends name, email,
and password via `IntranetController.BuildWelcomeEmail`; on delivery failure the admin gets an
`IEventService.UserIssueAsync` notification per
[0003](0003-interrupting-exceptions-notify-the-user.md), matching `pwdreset`'s existing
`emailed` flag semantics.
- **`fis_admin_delUserAccount` gained the same `intranet_admin` gate** (previously entirely
missing — a real authorization hole once the proc is reachable from a controller) **and a
self-delete guard** (`@authaccount <> @useraccount_id`), enforced at both the SQL layer (belt)
and the controller (suspenders — returns 409 `cannot_delete_self` without a round-trip when the
caller targets their own account).
- **Frontend**: `fis.usr.js`'s base ribbon gained an always-visible "Neuer Benutzer" button
(`createDlg`, a `$ocms.dlgform` collecting Vorname/Nachname/E-Mail required, Titel/Anrede/Mobil
optional); the per-row ribbon gained "Benutzer löschen" (`deleteUser`), using the same
`confirm(...)` guard pattern Banking's `fis.bam.js` uses before destructive actions
(`smd`/`mbd`) rather than a modal dialog, since delete has no fields to collect.
## Consequences
- `fis_admin_createUserAccount`'s signature changed (`@authaccount` added as the first
parameter) and its result set gained a `newpassword` column; safe in-place change since it had
no existing caller.
- `fis_admin_delUserAccount` now silently no-ops (same "no-op instead of error" style as every
other `fis_admin_*` proc in this module) for a caller without `intranet_admin >= 2` **or** who
targets their own account — the controller's own `cannot_delete_self` check makes the
self-delete case surface as a clear error to the admin instead of a silent no-op, but a caller
lacking `intranet_admin` still only sees an unexplained failure, consistent with the accepted
gap from 0014.
- Delete is a soft-delete (`fis_admin_delUserAccount` already set `DateDeleted`/`UserDeleted`
before this change) — deleted accounts simply stop appearing in `fis_admin_getUserAccountList`
and can only be restored directly in the database; no "undo" is exposed in the UI.
- The list view (`fis.usr.scss`) was restyled from a plain bordered `<table>` to a card-style row
grid (avatar-initial badge, status pill, hover/selection highlighting) matching the visual
language `fis.admin.scss` already established (`adm_pill`, `adm_card`) — a pure presentation
change, the DOM is still built by the same `renderLst`/`$ocms.postXT('usr/list')` flow.
## Alternatives considered
- **Reuse `fis_admin_setUserAccount` for create** by calling it with `@useraccount_id = NULL`.
Rejected — its insert branch doesn't return the generated password (nothing to email to the
new user), and giving one endpoint two very different response shapes depending on a hidden
parameter was judged more surprising than a small, explicit `create` proc.
- **Hard-delete instead of soft-delete.** Not considered — `fis_admin_delUserAccount` already
implemented soft-delete before this change and every other user-facing list in the app
(`fis_admin_getUserAccountList` et al.) already filters on `DateDeleted is null`; changing to a
hard delete would be an unrelated, larger change.
- **Let an admin delete their own account and rely on another admin to recover.** Rejected —
trivially avoidable lockout risk with no upside.
+1 -1
View File
@@ -50,7 +50,7 @@
<PackageReference Include="MimeKit" Version="4.17.0" /> <PackageReference Include="MimeKit" Version="4.17.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<!-- New packages (needed for .NET 10) --> <!-- New packages (needed for .NET 10) -->
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" /> <PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.3" />
<PackageReference Include="SixLabors.ImageSharp" Version="4.1.1" /> <PackageReference Include="SixLabors.ImageSharp" Version="4.1.1" />
<PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.12" /> <PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.12" />
<PackageReference Include="System.Drawing.Common" Version="10.0.12" /> <PackageReference Include="System.Drawing.Common" Version="10.0.12" />
+68 -14
View File
@@ -12,7 +12,7 @@ let $usr = {
}, },
/* Default ribbon (nothing selected). */ /* Default ribbon (nothing selected). */
eM: function (r) { eM: function (r) {
let m = []; let m = [{ lbl: $uct.newuser_btn, fnc: $usr.createDlg }];
if (bool(r, false) === true) { m.push({ lbl: $uct.rel, fnc: $usr.renderLst }); } if (bool(r, false) === true) { m.push({ lbl: $uct.rel, fnc: $usr.renderLst }); }
return $('#topbar').ocmsmenu(m); return $('#topbar').ocmsmenu(m);
}, },
@@ -21,28 +21,43 @@ let $usr = {
return $('#topbar').ocmsmenu([ return $('#topbar').ocmsmenu([
{ lbl: $uct.pwdreset, fnc: function () { $usr.pwdResetDlg(rw); } }, { lbl: $uct.pwdreset, fnc: function () { $usr.pwdResetDlg(rw); } },
{ lbl: $uct.email, fnc: function () { $usr.emailDlg(rw); } }, { lbl: $uct.email, fnc: function () { $usr.emailDlg(rw); } },
{ lbl: $uct.groups, fnc: function () { $usr.groupsDlg(rw); } } { lbl: $uct.groups, fnc: function () { $usr.groupsDlg(rw); } },
{ lbl: $uct.delete, fnc: function () { $usr.deleteUser(rw); } }
]); ]);
}, },
/* initials for the avatar badge, e.g. "Nadine Beckmann" -> "NB" */
initials: function (firstname, name) {
let f = (firstname || '').trim().charAt(0), n = (name || '').trim().charAt(0);
return (f + n).toUpperCase() || '?';
},
renderLst: function () { renderLst: function () {
let usrlst = $fis.frm_list(true, true).ldng(1), usrfrm = $$.dc('usrfrm', usrlst); let usrlst = $fis.frm_list(true, true).ldng(1), usrfrm = $$.dc('usrfrm', usrlst);
$usr.eM(true); $usr.eM(true);
$ocms.postXT({ $ocms.postXT({
url: $ocms.url('usr/list'), success: function (r) { url: $ocms.url('usr/list'), success: function (r) {
usrfrm.empty(); usrfrm.empty();
let ts = $$.tblset({}, usrfrm); let grid = $$.dc('usr_grid', usrfrm);
let thr = $$.tr(ts.hd); let hdr = $$.dc('usr_row usr_row_hd', grid);
$.each([$uct.col_name, $uct.col_email, $uct.col_status], function (i, lbl) { $$.dc('usr_col_avatar', hdr);
$$.th(thr).text(lbl); $$.dc('usr_col_name', hdr).text($uct.col_name);
}); $$.dc('usr_col_email', hdr).text($uct.col_email);
$$.dc('usr_col_status', hdr).text($uct.col_status);
if ((r.users || []).length === 0) {
$$.dc('usr_empty', grid).text($uct.empty);
return;
}
$.each(r.users || [], function (ri, rw) { $.each(r.users || [], function (ri, rw) {
let tr = $$.tr(ts.bdy, { id: 'usr' + rw.useraccount_id }); let disabled = bool(rw.disabled, false) === true;
$$.td(tr).text(`${rw.firstname || ''} ${rw.name || ''}`.trim()); let row = $$.dc('usr_row', grid, { id: 'usr' + rw.useraccount_id });
$$.td(tr).text(rw.email || ''); $$.dc('usr_avatar', row).text($usr.initials(rw.firstname, rw.name));
$$.td(tr).text(bool(rw.disabled, false) === true ? $uct.disabled : $uct.active); $$.dc('usr_col_name', row).text(`${rw.firstname || ''} ${rw.name || ''}`.trim());
tr.click(function () { $$.dc('usr_col_email', row).text(rw.email || '');
tr.toggleClass('selected').siblings().rC('selected'); $$.dc('usr_col_status', row).append(
if (tr.is('.selected') === true) { $usr.iMn(rw); } else { $usr.eM(true); } $$.dc(disabled ? 'usr_pill usr_pill_off' : 'usr_pill usr_pill_on').text(disabled ? $uct.disabled : $uct.active)
);
row.click(function () {
row.toggleClass('selected').siblings().rC('selected');
if (row.is('.selected') === true) { $usr.iMn(rw); } else { $usr.eM(true); }
}); });
}); });
}, error: function () { }, error: function () {
@@ -51,6 +66,45 @@ let $usr = {
}, complete: function () { usrlst.ldng(0); } }, complete: function () { usrlst.ldng(0); }
}); });
}, },
createDlg: function () {
$ocms.dlgform([
{ name: 'firstname', label: $uct.firstname_field, type: 'string', required: true },
{ name: 'name', label: $uct.name_field, type: 'string', required: true },
{ name: 'email', label: $uct.email_field, type: 'email', required: true },
{ name: 'title', label: $uct.title_field, type: 'string' },
{ name: 'gender', label: $uct.gender_field, type: 'select', url: [['', ''], ['m', $uct.gender_m], ['f', $uct.gender_f]] },
{ name: 'mobile', label: $uct.mobile_field, type: 'string' }
], {
title: $uct.newuser,
button: $uct.newuser_btn,
submit: function (e) {
let c = $(this).ldng(1);
let qs = c.serializeObject(true, { typedvalues: true });
$ocms.postXT({
url: $ocms.url('usr/create'), data: qs, success: function (response) {
alert(response.emailed === true ? $uct.create_sent : $uct.create_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(); }
});
}
});
},
deleteUser: function (rw) {
let displayName = `${rw.firstname || ''} ${rw.name || ''}`.trim();
if (confirm(string($uct.delete_confirm, [displayName]))) {
$ocms.postXT({
url: $ocms.url('usr/delete'), data: { useraccount_id: rw.useraccount_id }, success: function () {
$usr.eM(true);
}, error: function (jqXHR) {
let code = ((jqXHR || {}).responseJSON || {}).error;
alert($uct.err[code] || $uct.err_generic);
}, complete: function () { $usr.renderLst(); }
});
}
},
pwdResetDlg: function (rw) { pwdResetDlg: function (rw) {
$ocms.dlgform([ $ocms.dlgform([
{ name: 'useraccount_id', type: 'hidden', value: rw.useraccount_id }, { name: 'useraccount_id', type: 'hidden', value: rw.useraccount_id },
+75 -17
View File
@@ -1,33 +1,78 @@
.usrfrm { .usrfrm {
> table { padding: 1.5rem 2rem;
border-collapse: collapse;
margin: 2rem;
th { .usr_grid {
padding: 0.2rem 0.35rem; border: 1px solid #e2e2e2;
border: 1px solid #CCC; border-radius: 0.5rem;
background: #fff;
box-shadow: 0 1px 3px rgba(50, 50, 50, 0.1);
overflow: hidden;
} }
td { .usr_row {
padding: 0.2rem 0.35rem; display: grid;
border: 1px solid #CCC; grid-template-columns: 2.75rem 1.6fr 1.8fr 8rem;
align-items: center;
gap: 0.75rem;
padding: 0.55rem 1rem;
border-bottom: 1px solid #eee;
&:last-child { border-bottom: none; }
} }
tr { .usr_row_hd {
background: $fuchs_blau;
color: #fff;
font-weight: bold;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.02em;
padding: 0.5rem 1rem;
}
.usr_row:not(.usr_row_hd) {
cursor: pointer; cursor: pointer;
transition: background-color 0.12s ease-in-out;
&:nth-child(2n+1) td { &:hover { background-color: #f3f6fb; }
background-color: #EEE;
&.selected {
background-color: rgba($fuchs_akzent, 0.12);
box-shadow: inset 3px 0 0 $fuchs_akzent;
}
} }
&:hover td { .usr_avatar {
background-color: #f3f6fb; width: 2.1rem;
height: 2.1rem;
border-radius: 50%;
background: $fuchs_blau;
color: #fff;
font-size: 0.78rem;
font-weight: bold;
display: flex;
align-items: center;
justify-content: center;
} }
&.selected td { .usr_col_name { font-weight: 600; color: #222; }
background-color: lightblue; .usr_col_email { color: #555; overflow-wrap: anywhere; }
}
.usr_pill {
display: inline-block;
padding: 0.1rem 0.6rem;
border-radius: 1rem;
font-size: 0.78rem;
font-weight: bold;
white-space: nowrap;
} }
.usr_pill_on { background: #e2f3da; color: #2f6d18; }
.usr_pill_off { background: #ececec; color: #777; }
.usr_empty {
padding: 1.5rem 1rem;
text-align: center;
color: #777;
} }
.usr_err { .usr_err {
@@ -37,6 +82,19 @@
} }
} }
@media (max-width: 640px) {
.usrfrm {
padding: 0.75rem;
.usr_row { grid-template-columns: 2.25rem 1fr; grid-template-areas: "avatar name" "avatar email" "avatar status"; }
.usr_row_hd { display: none; }
.usr_avatar { grid-area: avatar; }
.usr_col_name { grid-area: name; }
.usr_col_email { grid-area: email; font-size: 0.85rem; }
.usr_col_status { grid-area: status; }
}
}
.usr_groups_dlg { .usr_groups_dlg {
padding: 0.25rem 0; padding: 0.25rem 0;
+18 -1
View File
@@ -7,6 +7,20 @@ let $uct = {
, active: 'Aktiv' , active: 'Aktiv'
, disabled: 'Deaktiviert' , disabled: 'Deaktiviert'
, loadfail: 'Die Benutzerliste konnte nicht geladen werden.' , loadfail: 'Die Benutzerliste konnte nicht geladen werden.'
, empty: 'Es sind keine Benutzerkonten vorhanden.'
, newuser: 'Neuer Benutzer'
, newuser_btn: 'Neuer Benutzer'
, firstname_field: 'Vorname'
, name_field: 'Nachname'
, title_field: 'Titel'
, gender_field: 'Anrede'
, gender_m: 'Herr'
, gender_f: 'Frau'
, mobile_field: 'Mobil'
, create_sent: 'Das Benutzerkonto wurde angelegt, die Zugangsdaten wurden per E-Mail versendet.'
, create_noemail: 'Das Benutzerkonto wurde angelegt, die Zugangsdaten konnten aber nicht per E-Mail zugestellt werden.'
, delete: 'Benutzer löschen'
, delete_confirm: 'Soll der Benutzer "{0}" wirklich gelöscht werden?'
, pwdreset: 'Passwort zurücksetzen' , pwdreset: 'Passwort zurücksetzen'
, pwdreset_btn: 'Zurücksetzen' , pwdreset_btn: 'Zurücksetzen'
, pwd_field: 'Neues Passwort' , pwd_field: 'Neues Passwort'
@@ -21,7 +35,10 @@ let $uct = {
requirements: 'Das Passwort erfüllt nicht die Mindestanforderungen (mind. 6 Zeichen).', requirements: 'Das Passwort erfüllt nicht die Mindestanforderungen (mind. 6 Zeichen).',
duplicate_email: 'Diese E-Mail-Adresse ist bereits einem anderen Benutzerkonto zugeordnet.', duplicate_email: 'Diese E-Mail-Adresse ist bereits einem anderen Benutzerkonto zugeordnet.',
reset_failed: 'Das Passwort konnte nicht zurückgesetzt werden.', reset_failed: 'Das Passwort konnte nicht zurückgesetzt werden.',
change_failed: 'Die Änderung konnte nicht gespeichert werden.' change_failed: 'Die Änderung konnte nicht gespeichert werden.',
create_failed: 'Das Benutzerkonto konnte nicht angelegt werden.',
delete_failed: 'Der Benutzer konnte nicht gelöscht werden.',
cannot_delete_self: 'Das eigene Benutzerkonto kann nicht gelöscht werden.'
} }
, err_generic: 'Die Änderung konnte nicht gespeichert werden.' , err_generic: 'Die Änderung konnte nicht gespeichert werden.'
}; };
+99 -16
View File
@@ -16,29 +16,87 @@
/* #1b4379 #0033b3 */ /* #1b4379 #0033b3 */
/*neu: #56a532 ; alt: rgb(32,144,119);*/ /*neu: #56a532 ; alt: rgb(32,144,119);*/
/* 10% sw */ /* 10% sw */
.usrfrm > table { .usrfrm {
border-collapse: collapse; padding: 1.5rem 2rem;
margin: 2rem;
} }
.usrfrm > table th { .usrfrm .usr_grid {
padding: 0.2rem 0.35rem; border: 1px solid #e2e2e2;
border: 1px solid #CCC; border-radius: 0.5rem;
background: #fff;
box-shadow: 0 1px 3px rgba(50, 50, 50, 0.1);
overflow: hidden;
} }
.usrfrm > table td { .usrfrm .usr_row {
padding: 0.2rem 0.35rem; display: grid;
border: 1px solid #CCC; grid-template-columns: 2.75rem 1.6fr 1.8fr 8rem;
align-items: center;
gap: 0.75rem;
padding: 0.55rem 1rem;
border-bottom: 1px solid #eee;
} }
.usrfrm > table tr { .usrfrm .usr_row:last-child {
border-bottom: none;
}
.usrfrm .usr_row_hd {
background: rgb(27, 67, 121);
color: #fff;
font-weight: bold;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.02em;
padding: 0.5rem 1rem;
}
.usrfrm .usr_row:not(.usr_row_hd) {
cursor: pointer; cursor: pointer;
transition: background-color 0.12s ease-in-out;
} }
.usrfrm > table tr:nth-child(2n+1) td { .usrfrm .usr_row:not(.usr_row_hd):hover {
background-color: #EEE;
}
.usrfrm > table tr:hover td {
background-color: #f3f6fb; background-color: #f3f6fb;
} }
.usrfrm > table tr.selected td { .usrfrm .usr_row:not(.usr_row_hd).selected {
background-color: lightblue; background-color: rgba(86, 165, 50, 0.12);
box-shadow: inset 3px 0 0 rgb(86, 165, 50);
}
.usrfrm .usr_avatar {
width: 2.1rem;
height: 2.1rem;
border-radius: 50%;
background: rgb(27, 67, 121);
color: #fff;
font-size: 0.78rem;
font-weight: bold;
display: flex;
align-items: center;
justify-content: center;
}
.usrfrm .usr_col_name {
font-weight: 600;
color: #222;
}
.usrfrm .usr_col_email {
color: #555;
overflow-wrap: anywhere;
}
.usrfrm .usr_pill {
display: inline-block;
padding: 0.1rem 0.6rem;
border-radius: 1rem;
font-size: 0.78rem;
font-weight: bold;
white-space: nowrap;
}
.usrfrm .usr_pill_on {
background: #e2f3da;
color: #2f6d18;
}
.usrfrm .usr_pill_off {
background: #ececec;
color: #777;
}
.usrfrm .usr_empty {
padding: 1.5rem 1rem;
text-align: center;
color: #777;
} }
.usrfrm .usr_err { .usrfrm .usr_err {
padding: 1rem 2rem; padding: 1rem 2rem;
@@ -46,6 +104,31 @@
font-weight: bold; font-weight: bold;
} }
@media (max-width: 640px) {
.usrfrm {
padding: 0.75rem;
}
.usrfrm .usr_row {
grid-template-columns: 2.25rem 1fr;
grid-template-areas: "avatar name" "avatar email" "avatar status";
}
.usrfrm .usr_row_hd {
display: none;
}
.usrfrm .usr_avatar {
grid-area: avatar;
}
.usrfrm .usr_col_name {
grid-area: name;
}
.usrfrm .usr_col_email {
grid-area: email;
font-size: 0.85rem;
}
.usrfrm .usr_col_status {
grid-area: status;
}
}
.usr_groups_dlg { .usr_groups_dlg {
padding: 0.25rem 0; padding: 0.25rem 0;
} }
+86 -15
View File
@@ -7,6 +7,20 @@ let $uct = {
, active: 'Aktiv' , active: 'Aktiv'
, disabled: 'Deaktiviert' , disabled: 'Deaktiviert'
, loadfail: 'Die Benutzerliste konnte nicht geladen werden.' , loadfail: 'Die Benutzerliste konnte nicht geladen werden.'
, empty: 'Es sind keine Benutzerkonten vorhanden.'
, newuser: 'Neuer Benutzer'
, newuser_btn: 'Neuer Benutzer'
, firstname_field: 'Vorname'
, name_field: 'Nachname'
, title_field: 'Titel'
, gender_field: 'Anrede'
, gender_m: 'Herr'
, gender_f: 'Frau'
, mobile_field: 'Mobil'
, create_sent: 'Das Benutzerkonto wurde angelegt, die Zugangsdaten wurden per E-Mail versendet.'
, create_noemail: 'Das Benutzerkonto wurde angelegt, die Zugangsdaten konnten aber nicht per E-Mail zugestellt werden.'
, delete: 'Benutzer löschen'
, delete_confirm: 'Soll der Benutzer "{0}" wirklich gelöscht werden?'
, pwdreset: 'Passwort zurücksetzen' , pwdreset: 'Passwort zurücksetzen'
, pwdreset_btn: 'Zurücksetzen' , pwdreset_btn: 'Zurücksetzen'
, pwd_field: 'Neues Passwort' , pwd_field: 'Neues Passwort'
@@ -21,7 +35,10 @@ let $uct = {
requirements: 'Das Passwort erfüllt nicht die Mindestanforderungen (mind. 6 Zeichen).', requirements: 'Das Passwort erfüllt nicht die Mindestanforderungen (mind. 6 Zeichen).',
duplicate_email: 'Diese E-Mail-Adresse ist bereits einem anderen Benutzerkonto zugeordnet.', duplicate_email: 'Diese E-Mail-Adresse ist bereits einem anderen Benutzerkonto zugeordnet.',
reset_failed: 'Das Passwort konnte nicht zurückgesetzt werden.', reset_failed: 'Das Passwort konnte nicht zurückgesetzt werden.',
change_failed: 'Die Änderung konnte nicht gespeichert werden.' change_failed: 'Die Änderung konnte nicht gespeichert werden.',
create_failed: 'Das Benutzerkonto konnte nicht angelegt werden.',
delete_failed: 'Der Benutzer konnte nicht gelöscht werden.',
cannot_delete_self: 'Das eigene Benutzerkonto kann nicht gelöscht werden.'
} }
, err_generic: 'Die Änderung konnte nicht gespeichert werden.' , err_generic: 'Die Änderung konnte nicht gespeichert werden.'
}; };
@@ -40,7 +57,7 @@ let $usr = {
}, },
/* Default ribbon (nothing selected). */ /* Default ribbon (nothing selected). */
eM: function (r) { eM: function (r) {
let m = []; let m = [{ lbl: $uct.newuser_btn, fnc: $usr.createDlg }];
if (bool(r, false) === true) { m.push({ lbl: $uct.rel, fnc: $usr.renderLst }); } if (bool(r, false) === true) { m.push({ lbl: $uct.rel, fnc: $usr.renderLst }); }
return $('#topbar').ocmsmenu(m); return $('#topbar').ocmsmenu(m);
}, },
@@ -49,28 +66,43 @@ let $usr = {
return $('#topbar').ocmsmenu([ return $('#topbar').ocmsmenu([
{ lbl: $uct.pwdreset, fnc: function () { $usr.pwdResetDlg(rw); } }, { lbl: $uct.pwdreset, fnc: function () { $usr.pwdResetDlg(rw); } },
{ lbl: $uct.email, fnc: function () { $usr.emailDlg(rw); } }, { lbl: $uct.email, fnc: function () { $usr.emailDlg(rw); } },
{ lbl: $uct.groups, fnc: function () { $usr.groupsDlg(rw); } } { lbl: $uct.groups, fnc: function () { $usr.groupsDlg(rw); } },
{ lbl: $uct.delete, fnc: function () { $usr.deleteUser(rw); } }
]); ]);
}, },
/* initials for the avatar badge, e.g. "Nadine Beckmann" -> "NB" */
initials: function (firstname, name) {
let f = (firstname || '').trim().charAt(0), n = (name || '').trim().charAt(0);
return (f + n).toUpperCase() || '?';
},
renderLst: function () { renderLst: function () {
let usrlst = $fis.frm_list(true, true).ldng(1), usrfrm = $$.dc('usrfrm', usrlst); let usrlst = $fis.frm_list(true, true).ldng(1), usrfrm = $$.dc('usrfrm', usrlst);
$usr.eM(true); $usr.eM(true);
$ocms.postXT({ $ocms.postXT({
url: $ocms.url('usr/list'), success: function (r) { url: $ocms.url('usr/list'), success: function (r) {
usrfrm.empty(); usrfrm.empty();
let ts = $$.tblset({}, usrfrm); let grid = $$.dc('usr_grid', usrfrm);
let thr = $$.tr(ts.hd); let hdr = $$.dc('usr_row usr_row_hd', grid);
$.each([$uct.col_name, $uct.col_email, $uct.col_status], function (i, lbl) { $$.dc('usr_col_avatar', hdr);
$$.th(thr).text(lbl); $$.dc('usr_col_name', hdr).text($uct.col_name);
}); $$.dc('usr_col_email', hdr).text($uct.col_email);
$$.dc('usr_col_status', hdr).text($uct.col_status);
if ((r.users || []).length === 0) {
$$.dc('usr_empty', grid).text($uct.empty);
return;
}
$.each(r.users || [], function (ri, rw) { $.each(r.users || [], function (ri, rw) {
let tr = $$.tr(ts.bdy, { id: 'usr' + rw.useraccount_id }); let disabled = bool(rw.disabled, false) === true;
$$.td(tr).text(`${rw.firstname || ''} ${rw.name || ''}`.trim()); let row = $$.dc('usr_row', grid, { id: 'usr' + rw.useraccount_id });
$$.td(tr).text(rw.email || ''); $$.dc('usr_avatar', row).text($usr.initials(rw.firstname, rw.name));
$$.td(tr).text(bool(rw.disabled, false) === true ? $uct.disabled : $uct.active); $$.dc('usr_col_name', row).text(`${rw.firstname || ''} ${rw.name || ''}`.trim());
tr.click(function () { $$.dc('usr_col_email', row).text(rw.email || '');
tr.toggleClass('selected').siblings().rC('selected'); $$.dc('usr_col_status', row).append(
if (tr.is('.selected') === true) { $usr.iMn(rw); } else { $usr.eM(true); } $$.dc(disabled ? 'usr_pill usr_pill_off' : 'usr_pill usr_pill_on').text(disabled ? $uct.disabled : $uct.active)
);
row.click(function () {
row.toggleClass('selected').siblings().rC('selected');
if (row.is('.selected') === true) { $usr.iMn(rw); } else { $usr.eM(true); }
}); });
}); });
}, error: function () { }, error: function () {
@@ -79,6 +111,45 @@ let $usr = {
}, complete: function () { usrlst.ldng(0); } }, complete: function () { usrlst.ldng(0); }
}); });
}, },
createDlg: function () {
$ocms.dlgform([
{ name: 'firstname', label: $uct.firstname_field, type: 'string', required: true },
{ name: 'name', label: $uct.name_field, type: 'string', required: true },
{ name: 'email', label: $uct.email_field, type: 'email', required: true },
{ name: 'title', label: $uct.title_field, type: 'string' },
{ name: 'gender', label: $uct.gender_field, type: 'select', url: [['', ''], ['m', $uct.gender_m], ['f', $uct.gender_f]] },
{ name: 'mobile', label: $uct.mobile_field, type: 'string' }
], {
title: $uct.newuser,
button: $uct.newuser_btn,
submit: function (e) {
let c = $(this).ldng(1);
let qs = c.serializeObject(true, { typedvalues: true });
$ocms.postXT({
url: $ocms.url('usr/create'), data: qs, success: function (response) {
alert(response.emailed === true ? $uct.create_sent : $uct.create_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(); }
});
}
});
},
deleteUser: function (rw) {
let displayName = `${rw.firstname || ''} ${rw.name || ''}`.trim();
if (confirm(string($uct.delete_confirm, [displayName]))) {
$ocms.postXT({
url: $ocms.url('usr/delete'), data: { useraccount_id: rw.useraccount_id }, success: function () {
$usr.eM(true);
}, error: function (jqXHR) {
let code = ((jqXHR || {}).responseJSON || {}).error;
alert($uct.err[code] || $uct.err_generic);
}, complete: function () { $usr.renderLst(); }
});
}
},
pwdResetDlg: function (rw) { pwdResetDlg: function (rw) {
$ocms.dlgform([ $ocms.dlgform([
{ name: 'useraccount_id', type: 'hidden', value: rw.useraccount_id }, { name: 'useraccount_id', type: 'hidden', value: rw.useraccount_id },
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +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} .usrfrm{padding:1.5rem 2rem}.usrfrm .usr_grid{border:1px solid #e2e2e2;border-radius:.5rem;background:#fff;box-shadow:0 1px 3px rgba(50,50,50,.1);overflow:hidden}.usrfrm .usr_row{display:grid;grid-template-columns:2.75rem 1.6fr 1.8fr 8rem;align-items:center;gap:.75rem;padding:.55rem 1rem;border-bottom:1px solid #eee}.usrfrm .usr_row:last-child{border-bottom:none}.usrfrm .usr_row_hd{background:#1b4379;color:#fff;font-weight:700;font-size:.85rem;text-transform:uppercase;letter-spacing:.02em;padding:.5rem 1rem}.usrfrm .usr_row:not(.usr_row_hd){cursor:pointer;transition:background-color .12s ease-in-out}.usrfrm .usr_row:not(.usr_row_hd):hover{background-color:#f3f6fb}.usrfrm .usr_row:not(.usr_row_hd).selected{background-color:rgba(86,165,50,.12);box-shadow:inset 3px 0 0 #56a532}.usrfrm .usr_avatar{width:2.1rem;height:2.1rem;border-radius:50%;background:#1b4379;color:#fff;font-size:.78rem;font-weight:700;display:flex;align-items:center;justify-content:center}.usrfrm .usr_col_name{font-weight:600;color:#222}.usrfrm .usr_col_email{color:#555;overflow-wrap:anywhere}.usrfrm .usr_pill{display:inline-block;padding:.1rem .6rem;border-radius:1rem;font-size:.78rem;font-weight:700;white-space:nowrap}.usrfrm .usr_pill_on{background:#e2f3da;color:#2f6d18}.usrfrm .usr_pill_off{background:#ececec;color:#777}.usrfrm .usr_empty{padding:1.5rem 1rem;text-align:center;color:#777}.usrfrm .usr_err{padding:1rem 2rem;color:#b00020;font-weight:700}@media (max-width:640px){.usrfrm{padding:.75rem}.usrfrm .usr_row{grid-template-columns:2.25rem 1fr;grid-template-areas:"avatar name" "avatar email" "avatar status"}.usrfrm .usr_row_hd{display:none}.usrfrm .usr_avatar{grid-area:avatar}.usrfrm .usr_col_name{grid-area:name}.usrfrm .usr_col_email{grid-area:email;font-size:.85rem}.usrfrm .usr_col_status{grid-area:status}}.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}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -28,7 +28,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" /> <PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.3" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.12" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.12" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.12" /> <PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.12" />
</ItemGroup> </ItemGroup>
@@ -36,19 +36,22 @@ BEGIN
(SELECT ',' + [value] as [text()] FROM [fis_useraccounts_settings] WHERE [useraccount_id] = @useraccount_id and [key] = 'intranet_function' FOR XML PATH(''), TYPE).value('.','nvarchar(max)') (SELECT ',' + [value] as [text()] FROM [fis_useraccounts_settings] WHERE [useraccount_id] = @useraccount_id and [key] = 'intranet_function' FOR XML PATH(''), TYPE).value('.','nvarchar(max)')
, 1,1,''); , 1,1,'');
IF @useraccount_id IS NOT NULL
BEGIN
INSERT INTO @RET INSERT INTO @RET
VALUES( VALUES(
@useraccount_id @useraccount_id
,IIF(@useraccount_id is null, -1, @auth) ,@auth
,IIF(@useraccount_id is null, null, @name) ,@name
,IIF(@useraccount_id is null, null, @firstname) ,@firstname
,IIF(@useraccount_id is null, null, @email) ,@email
,IIF(@useraccount_id is null, null, @mobile) ,@mobile
,IIF(@useraccount_id is null, null, @functions) ,@functions
,IIF(@useraccount_id is null, null, @language) ,@language
,IIF(@useraccount_id is null, null, @fullname) ,@fullname
,IIF(@useraccount_id is null, null, @fullname_rev) ,@fullname_rev
); );
END
RETURN; RETURN;
@@ -7,7 +7,8 @@
-- Description: <Description, ,> -- Description: <Description, ,>
-- ============================================= -- =============================================
CREATE PROCEDURE [dbo].[fis_admin_createUserAccount] CREATE PROCEDURE [dbo].[fis_admin_createUserAccount]
@name nvarchar(100) @authaccount varchar(5)
,@name nvarchar(100)
,@firstname nvarchar(100) ,@firstname nvarchar(100)
,@title varchar(50) ,@title varchar(50)
,@gender varchar(1) ,@gender varchar(1)
@@ -20,6 +21,21 @@ BEGIN
SET NOCOUNT ON; SET NOCOUNT ON;
DECLARE @utcnow datetime = GETUTCDATE(); DECLARE @utcnow datetime = GETUTCDATE();
DECLARE @auth smallint = [dbo].[fis_admin_getUserAuth](@authaccount);
DECLARE @newid varchar(5);
IF @auth > 1
BEGIN
IF EXISTS (SELECT * FROM [dbo].[fis_useraccounts] WHERE [DateDeleted] is null AND ISNULL(@email,'') <> '' AND LTRIM(RTRIM(DECRYPTBYKEY([email_enc]))) like LTRIM(RTRIM(@email)))
THROW 60001, 'email is already assigned to another account', 1;
IF @password IS NULL
BEGIN
SET @password = [dbo].[ocms_fn_generatePassword] (8, 0, 1, 1);
END
SET @newid = [dbo].[fis_fn_useraccount_id] ();
INSERT INTO [dbo].[fis_useraccounts] INSERT INTO [dbo].[fis_useraccounts]
([useraccount_id] ([useraccount_id]
@@ -38,23 +54,26 @@ BEGIN
,[DateModified] ,[DateModified]
,[UserDeleted] ,[UserDeleted]
,[DateDeleted]) ,[DateDeleted])
OUTPUT inserted.[useraccount_id]
VALUES VALUES
([dbo].[fis_fn_useraccount_id] () (@newid
,ENCRYPTBYKEY(@enc_key, @name) ,ENCRYPTBYKEY(@enc_key, @name)
,ENCRYPTBYKEY(@enc_key, @firstname) ,ENCRYPTBYKEY(@enc_key, @firstname)
,ENCRYPTBYKEY(@enc_key, @title) ,ENCRYPTBYKEY(@enc_key, @title)
,ENCRYPTBYKEY(@enc_key, @gender) ,ENCRYPTBYKEY(@enc_key, @gender)
,ENCRYPTBYKEY(@enc_key, @email) ,ENCRYPTBYKEY(@enc_key, @email)
,ENCRYPTBYKEY(@enc_key, @mobile) ,ENCRYPTBYKEY(@enc_key, @mobile)
,CASE WHEN @password is null then ENCRYPTBYKEY(@enc_key, CAST([dbo].[ocms_fn_generatePassword] (8, 0, 1, 1) AS nvarchar(20))) ELSE ENCRYPTBYKEY(@enc_key, @password) END ,ENCRYPTBYKEY(@enc_key, @password)
,NULL --[UserDisabled] ,NULL --[UserDisabled]
,NULL --[DateDisabled] ,NULL --[DateDisabled]
,'sa' ,@authaccount
,@utcnow ,@utcnow
,'sa' ,@authaccount
,@utcnow ,@utcnow
,NULL ,NULL
,NULL); ,NULL);
SELECT TOP(1) *, @password as [newpassword] FROM [dbo].[fis_admin_getUserAccountList](@authaccount, @newid, null, null);
END
END END
@@ -12,6 +12,10 @@ AS
BEGIN BEGIN
SET NOCOUNT ON; SET NOCOUNT ON;
DECLARE @utcnow datetime = GETUTCDATE(); DECLARE @utcnow datetime = GETUTCDATE();
DECLARE @auth smallint = [dbo].[fis_admin_getUserAuth](@authaccount);
IF @auth > 1 AND @authaccount <> @useraccount_id
BEGIN
IF EXISTS (SELECT * FROM [dbo].[fis_useraccounts] WHERE [useraccount_id] = @useraccount_id and [DateDeleted] is null) IF EXISTS (SELECT * FROM [dbo].[fis_useraccounts] WHERE [useraccount_id] = @useraccount_id and [DateDeleted] is null)
BEGIN BEGIN
@@ -24,6 +28,8 @@ BEGIN
END END
END
SELECT CAST((CASE WHEN NOT EXISTS (SELECT * FROM [dbo].[fis_useraccounts] WHERE [useraccount_id] = @useraccount_id and [DateDeleted] is null) THEN 1 ELSE 0 END) as bit); SELECT CAST((CASE WHEN NOT EXISTS (SELECT * FROM [dbo].[fis_useraccounts] WHERE [useraccount_id] = @useraccount_id and [DateDeleted] is null) THEN 1 ELSE 0 END) as bit);
END END
+1 -1
Submodule OCORE updated: ddb3d8971f...12cb9667ba