Compare commits
3
Commits
e69a146408
...
4386a6336a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4386a6336a | ||
|
|
38145a917e | ||
|
|
c18e34a3f1 |
@@ -5,9 +5,9 @@ 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.
|
||||
/// the admin-initiated password-reset and new-account welcome notification content, since the
|
||||
/// SQL-bound parts of IntranetController.AdminUsers.cs (fis_admin_* proc calls) can't be
|
||||
/// unit-tested per repo convention and require a running database.
|
||||
/// </summary>
|
||||
public class AdminUserManagementTests
|
||||
{
|
||||
@@ -40,4 +40,43 @@ public class AdminUserManagementTests
|
||||
Assert.False(string.IsNullOrWhiteSpace(subject));
|
||||
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>", "<script>alert(1)</script>")]
|
||||
[InlineData("O'Brien&Co", "O'Brien&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&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 });
|
||||
}
|
||||
|
||||
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":
|
||||
{
|
||||
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/>" +
|
||||
$"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>
|
||||
/// Logs and raises a user-facing notification for an admin user-management action that
|
||||
/// threw unexpectedly, per the "interrupting exceptions notify the user" rule
|
||||
|
||||
@@ -249,16 +249,16 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
_logger.LogDebug("HandleLogin attempt for email={Email} ip={IP}",
|
||||
email, HttpContext.Connection.RemoteIpAddress);
|
||||
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}",
|
||||
email, HttpContext.Connection.RemoteIpAddress);
|
||||
return Unauthorized401();
|
||||
}
|
||||
|
||||
string userId = row.nz("useraccount_id");
|
||||
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 principal = new System.Security.Claims.ClaimsPrincipal(identity);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: Active
|
||||
lastUpdated: 2026-09-09
|
||||
lastUpdated: 2026-09-14
|
||||
applyTo:
|
||||
- "Fuchs/Controllers/IntranetController.AdminUsers.cs"
|
||||
- "Fuchs/js/intranet/modules/fis.usr*.js"
|
||||
@@ -11,20 +11,22 @@ applyTo:
|
||||
relatedDecisions:
|
||||
- "0014-admin-user-management.md"
|
||||
- "0015-split-admin-user-management-module.md"
|
||||
- "0020-user-management-create-and-delete.md"
|
||||
---
|
||||
|
||||
# User Management module (`usr`)
|
||||
|
||||
## Summary
|
||||
The **Benutzerverwaltung** module lets a privileged operator see every non-deleted intranet
|
||||
user account (name + email + active/disabled), reset a password, change an email address, and
|
||||
view/change group assignments. It shares its sidemenu-visibility and server-side authorization
|
||||
gate with the separate Admin/status-dashboard module (`fds_sys > 4`) but is otherwise fully
|
||||
independent — own `fn=usr`, own frontend bundle, own controller partial. See
|
||||
[0015](../Decisions/0015-split-admin-user-management-module.md) for why these were split from
|
||||
one module into two, and [0014](../Decisions/0014-admin-user-management.md) for the original
|
||||
decisions this one carries forward (password always emailed, dual auth axis, reuse of
|
||||
`fis_admin_setUserAccount` for email changes).
|
||||
user account (name + email + active/disabled), create a new account, delete an existing one,
|
||||
reset a password, change an email address, and view/change group assignments. It shares its
|
||||
sidemenu-visibility and server-side authorization gate with the separate Admin/status-dashboard
|
||||
module (`fds_sys > 4`) but is otherwise fully independent — own `fn=usr`, own frontend bundle,
|
||||
own controller partial. See [0015](../Decisions/0015-split-admin-user-management-module.md) for
|
||||
why these were split from one module into two, [0014](../Decisions/0014-admin-user-management.md)
|
||||
for the original decisions this one carries forward (password always emailed, dual auth axis,
|
||||
reuse of `fis_admin_setUserAccount` for email changes), and
|
||||
[0020](../Decisions/0020-user-management-create-and-delete.md) for create/delete.
|
||||
|
||||
## How it works
|
||||
|
||||
@@ -50,11 +52,14 @@ click "Benutzerverwaltung" → $ocms.init('usr')
|
||||
load /web/fis.usr.de.js + /web/fis.usr.css
|
||||
$ocms.usr.init2() → init3() → renderLst()
|
||||
POST /do/usr/list → { users: [...] } grid of every non-deleted account
|
||||
row click → per-row ribbon (iMn): Passwort zurücksetzen / E-Mail ändern / Gruppenzugehörigkeit
|
||||
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/email {useraccount_id, email} → { ok } | 409 duplicate_email
|
||||
POST /do/usr/groups {useraccount_id} → { groups: [...] }
|
||||
POST /do/usr/groupset {useraccount_id, usergroup_id, member} → { ok, member }
|
||||
POST /do/usr/delete {useraccount_id} → { ok } | 409 cannot_delete_self
|
||||
```
|
||||
|
||||
### Backend (`IntranetController.AdminUsers.cs`)
|
||||
@@ -80,26 +85,43 @@ click "Benutzerverwaltung" → $ocms.init('usr')
|
||||
returns every group plus a `member` bit for that one user.
|
||||
- `groupset` → calls `fis_admin_addGroupMember`/`remGroupMember` depending on the posted
|
||||
`member` flag — applied immediately per checkbox toggle, no separate save step.
|
||||
- `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`)
|
||||
A self-contained list+ribbon module modeled on Banking (`fis.bam.js`)'s `eM`/`iMn` pattern
|
||||
rather than the Admin dashboard's card layout: `renderLst` builds the table via
|
||||
`$fis.frm_list`/`$$.tblset`; a row click toggles `.selected` and swaps the `#topbar` ribbon
|
||||
between the base "Neu Laden" button (`eM`) and the three per-row actions (`iMn`). Each action is
|
||||
a dialog (`pwdResetDlg`/`emailDlg`/`groupsDlg`) using `$ocms.dlgform`/`$ocms.dlg` with a manual
|
||||
rather than the Admin dashboard's card layout: `renderLst` builds a card-style row grid (avatar
|
||||
initials badge, name/email columns, a status pill) rather than a plain `<table>`; a row click
|
||||
toggles `.selected` and swaps the `#topbar` ribbon between the base ribbon (`eM`: "Neuer
|
||||
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
|
||||
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
|
||||
- `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/js/intranet/modules/fis.usr.js`, `fis.usr_txt_de.js`, `fis.usr.scss` — the module, texts, styles (bundled via `bdlconfig.json`, `intranet:usr` context).
|
||||
- `Fuchs/js/intranet/fis_main_menu.js` (`addAdminMenuButtonsIfAuthorized`) + `fis_main_go.js` — conditional menu buttons (this module and `admin`).
|
||||
- `Fuchs_Database/dbo/Functions/fis_admin_getUserAccountList.sql`, `fis_admin_getUserGroupList.sql`, `fis_admin_getUserGroupMembership.sql` — read-side user/group queries.
|
||||
- `Fuchs_Database/dbo/Stored Procedures/fis_admin_resetUserPassword.sql`, `fis_admin_setUserAccount.sql`, `fis_admin_addGroupMember.sql`, `fis_admin_remGroupMember.sql` — write-side user/group procs.
|
||||
- `Fuchs.Tests/AdminUserManagementTests.cs` — password-reset email content tests.
|
||||
- `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 and welcome-email content tests.
|
||||
|
||||
## Related decisions
|
||||
- [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)
|
||||
- [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
@@ -50,7 +50,7 @@
|
||||
<PackageReference Include="MimeKit" Version="4.17.0" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<!-- 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="System.Configuration.ConfigurationManager" Version="10.0.12" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="10.0.12" />
|
||||
|
||||
@@ -12,7 +12,7 @@ let $usr = {
|
||||
},
|
||||
/* Default ribbon (nothing selected). */
|
||||
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 }); }
|
||||
return $('#topbar').ocmsmenu(m);
|
||||
},
|
||||
@@ -21,28 +21,43 @@ let $usr = {
|
||||
return $('#topbar').ocmsmenu([
|
||||
{ lbl: $uct.pwdreset, fnc: function () { $usr.pwdResetDlg(rw); } },
|
||||
{ lbl: $uct.email, fnc: function () { $usr.emailDlg(rw); } },
|
||||
{ lbl: $uct.groups, fnc: function () { $usr.groupsDlg(rw); } }
|
||||
{ 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 () {
|
||||
let usrlst = $fis.frm_list(true, true).ldng(1), usrfrm = $$.dc('usrfrm', usrlst);
|
||||
$usr.eM(true);
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('usr/list'), success: function (r) {
|
||||
usrfrm.empty();
|
||||
let ts = $$.tblset({}, usrfrm);
|
||||
let thr = $$.tr(ts.hd);
|
||||
$.each([$uct.col_name, $uct.col_email, $uct.col_status], function (i, lbl) {
|
||||
$$.th(thr).text(lbl);
|
||||
});
|
||||
let grid = $$.dc('usr_grid', usrfrm);
|
||||
let hdr = $$.dc('usr_row usr_row_hd', grid);
|
||||
$$.dc('usr_col_avatar', hdr);
|
||||
$$.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) {
|
||||
let tr = $$.tr(ts.bdy, { id: 'usr' + rw.useraccount_id });
|
||||
$$.td(tr).text(`${rw.firstname || ''} ${rw.name || ''}`.trim());
|
||||
$$.td(tr).text(rw.email || '');
|
||||
$$.td(tr).text(bool(rw.disabled, false) === true ? $uct.disabled : $uct.active);
|
||||
tr.click(function () {
|
||||
tr.toggleClass('selected').siblings().rC('selected');
|
||||
if (tr.is('.selected') === true) { $usr.iMn(rw); } else { $usr.eM(true); }
|
||||
let disabled = bool(rw.disabled, false) === true;
|
||||
let row = $$.dc('usr_row', grid, { id: 'usr' + rw.useraccount_id });
|
||||
$$.dc('usr_avatar', row).text($usr.initials(rw.firstname, rw.name));
|
||||
$$.dc('usr_col_name', row).text(`${rw.firstname || ''} ${rw.name || ''}`.trim());
|
||||
$$.dc('usr_col_email', row).text(rw.email || '');
|
||||
$$.dc('usr_col_status', row).append(
|
||||
$$.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 () {
|
||||
@@ -51,6 +66,45 @@ let $usr = {
|
||||
}, 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) {
|
||||
$ocms.dlgform([
|
||||
{ name: 'useraccount_id', type: 'hidden', value: rw.useraccount_id },
|
||||
|
||||
@@ -1,33 +1,78 @@
|
||||
.usrfrm {
|
||||
> table {
|
||||
border-collapse: collapse;
|
||||
margin: 2rem;
|
||||
padding: 1.5rem 2rem;
|
||||
|
||||
th {
|
||||
padding: 0.2rem 0.35rem;
|
||||
border: 1px solid #CCC;
|
||||
.usr_grid {
|
||||
border: 1px solid #e2e2e2;
|
||||
border-radius: 0.5rem;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(50, 50, 50, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 0.2rem 0.35rem;
|
||||
border: 1px solid #CCC;
|
||||
.usr_row {
|
||||
display: grid;
|
||||
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;
|
||||
transition: background-color 0.12s ease-in-out;
|
||||
|
||||
&:nth-child(2n+1) td {
|
||||
background-color: #EEE;
|
||||
&:hover { background-color: #f3f6fb; }
|
||||
|
||||
&.selected {
|
||||
background-color: rgba($fuchs_akzent, 0.12);
|
||||
box-shadow: inset 3px 0 0 $fuchs_akzent;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover td {
|
||||
background-color: #f3f6fb;
|
||||
.usr_avatar {
|
||||
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 {
|
||||
background-color: lightblue;
|
||||
}
|
||||
.usr_col_name { font-weight: 600; color: #222; }
|
||||
.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 {
|
||||
@@ -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 {
|
||||
padding: 0.25rem 0;
|
||||
|
||||
|
||||
@@ -7,6 +7,20 @@ let $uct = {
|
||||
, active: 'Aktiv'
|
||||
, disabled: 'Deaktiviert'
|
||||
, 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_btn: 'Zurücksetzen'
|
||||
, pwd_field: 'Neues Passwort'
|
||||
@@ -21,7 +35,10 @@ let $uct = {
|
||||
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.'
|
||||
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.'
|
||||
};
|
||||
|
||||
@@ -16,29 +16,87 @@
|
||||
/* #1b4379 #0033b3 */
|
||||
/*neu: #56a532 ; alt: rgb(32,144,119);*/
|
||||
/* 10% sw */
|
||||
.usrfrm > table {
|
||||
border-collapse: collapse;
|
||||
margin: 2rem;
|
||||
.usrfrm {
|
||||
padding: 1.5rem 2rem;
|
||||
}
|
||||
.usrfrm > table th {
|
||||
padding: 0.2rem 0.35rem;
|
||||
border: 1px solid #CCC;
|
||||
.usrfrm .usr_grid {
|
||||
border: 1px solid #e2e2e2;
|
||||
border-radius: 0.5rem;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(50, 50, 50, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
.usrfrm > table td {
|
||||
padding: 0.2rem 0.35rem;
|
||||
border: 1px solid #CCC;
|
||||
.usrfrm .usr_row {
|
||||
display: grid;
|
||||
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;
|
||||
transition: background-color 0.12s ease-in-out;
|
||||
}
|
||||
.usrfrm > table tr:nth-child(2n+1) td {
|
||||
background-color: #EEE;
|
||||
}
|
||||
.usrfrm > table tr:hover td {
|
||||
.usrfrm .usr_row:not(.usr_row_hd):hover {
|
||||
background-color: #f3f6fb;
|
||||
}
|
||||
.usrfrm > table tr.selected td {
|
||||
background-color: lightblue;
|
||||
.usrfrm .usr_row:not(.usr_row_hd).selected {
|
||||
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 {
|
||||
padding: 1rem 2rem;
|
||||
@@ -46,6 +104,31 @@
|
||||
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 {
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,20 @@ let $uct = {
|
||||
, active: 'Aktiv'
|
||||
, disabled: 'Deaktiviert'
|
||||
, 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_btn: 'Zurücksetzen'
|
||||
, pwd_field: 'Neues Passwort'
|
||||
@@ -21,7 +35,10 @@ let $uct = {
|
||||
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.'
|
||||
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.'
|
||||
};
|
||||
@@ -40,7 +57,7 @@ let $usr = {
|
||||
},
|
||||
/* Default ribbon (nothing selected). */
|
||||
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 }); }
|
||||
return $('#topbar').ocmsmenu(m);
|
||||
},
|
||||
@@ -49,28 +66,43 @@ let $usr = {
|
||||
return $('#topbar').ocmsmenu([
|
||||
{ lbl: $uct.pwdreset, fnc: function () { $usr.pwdResetDlg(rw); } },
|
||||
{ lbl: $uct.email, fnc: function () { $usr.emailDlg(rw); } },
|
||||
{ lbl: $uct.groups, fnc: function () { $usr.groupsDlg(rw); } }
|
||||
{ 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 () {
|
||||
let usrlst = $fis.frm_list(true, true).ldng(1), usrfrm = $$.dc('usrfrm', usrlst);
|
||||
$usr.eM(true);
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('usr/list'), success: function (r) {
|
||||
usrfrm.empty();
|
||||
let ts = $$.tblset({}, usrfrm);
|
||||
let thr = $$.tr(ts.hd);
|
||||
$.each([$uct.col_name, $uct.col_email, $uct.col_status], function (i, lbl) {
|
||||
$$.th(thr).text(lbl);
|
||||
});
|
||||
let grid = $$.dc('usr_grid', usrfrm);
|
||||
let hdr = $$.dc('usr_row usr_row_hd', grid);
|
||||
$$.dc('usr_col_avatar', hdr);
|
||||
$$.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) {
|
||||
let tr = $$.tr(ts.bdy, { id: 'usr' + rw.useraccount_id });
|
||||
$$.td(tr).text(`${rw.firstname || ''} ${rw.name || ''}`.trim());
|
||||
$$.td(tr).text(rw.email || '');
|
||||
$$.td(tr).text(bool(rw.disabled, false) === true ? $uct.disabled : $uct.active);
|
||||
tr.click(function () {
|
||||
tr.toggleClass('selected').siblings().rC('selected');
|
||||
if (tr.is('.selected') === true) { $usr.iMn(rw); } else { $usr.eM(true); }
|
||||
let disabled = bool(rw.disabled, false) === true;
|
||||
let row = $$.dc('usr_row', grid, { id: 'usr' + rw.useraccount_id });
|
||||
$$.dc('usr_avatar', row).text($usr.initials(rw.firstname, rw.name));
|
||||
$$.dc('usr_col_name', row).text(`${rw.firstname || ''} ${rw.name || ''}`.trim());
|
||||
$$.dc('usr_col_email', row).text(rw.email || '');
|
||||
$$.dc('usr_col_status', row).append(
|
||||
$$.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 () {
|
||||
@@ -79,6 +111,45 @@ let $usr = {
|
||||
}, 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) {
|
||||
$ocms.dlgform([
|
||||
{ name: 'useraccount_id', type: 'hidden', value: rw.useraccount_id },
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -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
@@ -28,7 +28,7 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<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.Configuration.Binder" Version="10.0.12" />
|
||||
</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)')
|
||||
, 1,1,'');
|
||||
|
||||
IF @useraccount_id IS NOT NULL
|
||||
BEGIN
|
||||
INSERT INTO @RET
|
||||
VALUES(
|
||||
@useraccount_id
|
||||
,IIF(@useraccount_id is null, -1, @auth)
|
||||
,IIF(@useraccount_id is null, null, @name)
|
||||
,IIF(@useraccount_id is null, null, @firstname)
|
||||
,IIF(@useraccount_id is null, null, @email)
|
||||
,IIF(@useraccount_id is null, null, @mobile)
|
||||
,IIF(@useraccount_id is null, null, @functions)
|
||||
,IIF(@useraccount_id is null, null, @language)
|
||||
,IIF(@useraccount_id is null, null, @fullname)
|
||||
,IIF(@useraccount_id is null, null, @fullname_rev)
|
||||
,@auth
|
||||
,@name
|
||||
,@firstname
|
||||
,@email
|
||||
,@mobile
|
||||
,@functions
|
||||
,@language
|
||||
,@fullname
|
||||
,@fullname_rev
|
||||
);
|
||||
END
|
||||
|
||||
RETURN;
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
-- Description: <Description, ,>
|
||||
-- =============================================
|
||||
CREATE PROCEDURE [dbo].[fis_admin_createUserAccount]
|
||||
@name nvarchar(100)
|
||||
@authaccount varchar(5)
|
||||
,@name nvarchar(100)
|
||||
,@firstname nvarchar(100)
|
||||
,@title varchar(50)
|
||||
,@gender varchar(1)
|
||||
@@ -20,6 +21,21 @@ BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
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]
|
||||
([useraccount_id]
|
||||
@@ -38,23 +54,26 @@ BEGIN
|
||||
,[DateModified]
|
||||
,[UserDeleted]
|
||||
,[DateDeleted])
|
||||
OUTPUT inserted.[useraccount_id]
|
||||
VALUES
|
||||
([dbo].[fis_fn_useraccount_id] ()
|
||||
(@newid
|
||||
,ENCRYPTBYKEY(@enc_key, @name)
|
||||
,ENCRYPTBYKEY(@enc_key, @firstname)
|
||||
,ENCRYPTBYKEY(@enc_key, @title)
|
||||
,ENCRYPTBYKEY(@enc_key, @gender)
|
||||
,ENCRYPTBYKEY(@enc_key, @email)
|
||||
,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 --[DateDisabled]
|
||||
,'sa'
|
||||
,@authaccount
|
||||
,@utcnow
|
||||
,'sa'
|
||||
,@authaccount
|
||||
,@utcnow
|
||||
,NULL
|
||||
,NULL);
|
||||
|
||||
SELECT TOP(1) *, @password as [newpassword] FROM [dbo].[fis_admin_getUserAccountList](@authaccount, @newid, null, null);
|
||||
|
||||
END
|
||||
|
||||
END
|
||||
@@ -12,6 +12,10 @@ AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
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)
|
||||
BEGIN
|
||||
@@ -24,6 +28,8 @@ BEGIN
|
||||
|
||||
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);
|
||||
|
||||
END
|
||||
+1
-1
Submodule OCORE updated: ddb3d8971f...12cb9667ba
+1
-1
Submodule OCORE_web updated: a4cb888398...546acf4cdb
Reference in New Issue
Block a user