From 505177b5e2044eac3de61f9f48eb60652f22fec7 Mon Sep 17 00:00:00 2001 From: Gerd Date: Sat, 5 Sep 2026 16:14:13 +0200 Subject: [PATCH] ProjektUser-Modal: Mehrfachauswahl, Filterung und Fehlermeldungs-Verbesserung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Modal von Einzelauswahl (Select) auf Mehrfachauswahl mit Checkboxen umgestellt - Nutzerliste sortiert nach username, scrollbar (eine Zeile pro Nutzer) - SysAdmin und AppAdmin aus der Auswahlliste ausgeblendet (immer Zugriff) - Bereits zugeordnete Nutzer ausgeblendet - Fehleranzeige bei fehlender Rolle: closeModal im catch entfernt, sodass showServerError nicht sofort überschrieben wird - AutorisationError: Meldung "Erforderliche Rollen" nur bei nicht-leerer allowed_roles-Liste (entfällt bei @authorisation_required([])) Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- App_Server/root/auth/model/errors.py | 15 ++- Client/Components/projekt_user.ts | 140 +++++++++++++++++++++------ 2 files changed, 119 insertions(+), 36 deletions(-) diff --git a/App_Server/root/auth/model/errors.py b/App_Server/root/auth/model/errors.py index d31110e..4223a11 100644 --- a/App_Server/root/auth/model/errors.py +++ b/App_Server/root/auth/model/errors.py @@ -22,10 +22,17 @@ class AutorisationError(ApplicationError): else: userrolestxt = ", ".join([role.name for role in self.user_roles]) - ApplicationError.__init__(self, - "Sie haben nicht die passende Rolle für diese Funktion", - "Ihre Rollen sind: " + userrolestxt, - "Erforderliche Rollen sind: " + ", ".join(self.allowed_roles), htmlstatus=HtmlStatusCode.Unauthorized) + # Die Meldung über erforderliche Rollen nur aufnehmen, wenn + # welche spezifiziert sind. Bei leerer allowed_roles-Liste + # (z.B. Endpunkte, die nur AppAdmin/SysAdmin erlauben) entfällt + # dieser Teil der Fehlermeldung komplett. + messages = [ + "Sie haben nicht die passende Rolle für diese Funktion", + "Ihre Rollen sind: " + userrolestxt, + ] + if self.allowed_roles: + messages.append("Erforderliche Rollen sind: " + ", ".join(self.allowed_roles)) + ApplicationError.__init__(self, *messages, htmlstatus=HtmlStatusCode.Unauthorized) class HtmlStatusCode(): Bad_Request = 400 diff --git a/Client/Components/projekt_user.ts b/Client/Components/projekt_user.ts index cb525de..8d3ba85 100644 --- a/Client/Components/projekt_user.ts +++ b/Client/Components/projekt_user.ts @@ -1,6 +1,5 @@ import m from "mithril" import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2" -import {Select, iSelect, iSelectOption} from "./widgets/select" import {ModalView, ModalType, iModalView} from "./widgets/Modal" import {gridMountpoints} from "./mainlayout" import {iProjektUser, fetchProjektUsers, addProjektUserREST, removeProjektUserREST} from "../models/projekt_user" @@ -101,43 +100,81 @@ export const projektuserliste: m.ClosureComponent = () => { } /** - * Modal-Komponente zur Auswahl eines Nutzers, der dem Projekt - * zugeordnet werden soll. Verwendet das Select-Widget für die - * Suche/Auswahl. Bereits zugeordnete Nutzer werden ausgeblendet. + * Modal-Komponente zur Auswahl mehrerer Nutzer, die dem Projekt + * zugeordnet werden sollen. Zeigt eine durchsuchbare Liste mit + * Checkboxen pro noch nicht zugeordnetem Nutzer. Bereits zugeordnete + * Nutzer werden ausgeblendet. */ const addUserModal: m.ClosureComponent = () => { - let selectedUserId: number = -1 + // Menge der ausgewählten user_ids (Checkbox aktiviert). + let selectedIds: Set = new Set() + // Suchtext zum Filtern der Liste. + let search: string = "" - // Verfügbare Nutzer: alle, die noch nicht zugeordnet sind. - const getAvailableOptions = (): iSelectOption[] => { + // Verfügbare Nutzer: alle, die noch nicht zugeordnet sind, nicht + // SysAdmin oder AppAdmin sind (diese haben ohnehin immer Zugriff) + // und deren Suchtext-Filter entsprechen. Sortiert nach username. + const getAvailableUsers = (): iUser[] => { const assignedIds = new Set(projektUsers.map((pu) => pu.user_id)) - return allUsers - .filter((u: iUser) => !assignedIds.has(u.id)) - .map((u: iUser) => ({ - value: u.id, - label: `${u.userid} (${u.username})` - } as iSelectOption)) + const searchLower = search.toLowerCase() + return allUsers.filter((u: iUser) => { + // SysAdmin und AppAdmin haben immer Zugriff, + // sie brauchen keine projektbezogene Zuordnung. + if (u.isSysAdmin) return false + if (u.userapps && u.userapps.some((ua) => + ua.userapp_roles && ua.userapp_roles.some((r) => r.roleid === "AppAdmin") + )) return false + if (assignedIds.has(u.id)) return false + if (searchLower === "") return true + return (u.userid || "").toLowerCase().includes(searchLower) || + (u.username || "").toLowerCase().includes(searchLower) || + (u.email || "").toLowerCase().includes(searchLower) + }).sort((a: iUser, b: iUser) => { + return (a.username || "").localeCompare(b.username || "") + }) } const closeModal = () => { m.mount(gridMountpoints.modalhook, null) } + /** + * Ordnet alle ausgewählten Nutzer dem Projekt zu. Die Aufrufe + * erfolgen parallel; nachdem alle abgeschlossen sind, wird das + * Modal geschlossen und die Liste neu geladen. + */ const confirmAdd = () => { - if (selectedUserId > 0) { - addProjektUserREST(projektId, selectedUserId).then(() => { - closeModal() - loadProjektUsers() - }).catch((e) => { - showServerError(e) - closeModal() - }) + if (selectedIds.size === 0) { + closeModal() + return + } + const promises: Promise[] = [] + selectedIds.forEach((uid: number) => { + promises.push(addProjektUserREST(projektId, uid)) + }) + Promise.all(promises).then(() => { + closeModal() + loadProjektUsers() + }).catch((e) => { + showServerError(e) + loadProjektUsers() + }) + } + + /** + * Schaltet die Auswahl für einen Nutzer um. + */ + const toggleUser = (userId: number) => { + if (selectedIds.has(userId)) { + selectedIds.delete(userId) + } else { + selectedIds.add(userId) } } return { view: (vnode) => { - const options = getAvailableOptions() + const availableUsers = getAvailableUsers() return m(ModalView, { title: "Nutzer zuordnen", @@ -145,18 +182,57 @@ export const projektuserliste: m.ClosureComponent = () => { width: '500px' } as iModalView, m("div.w3-padding-16", - m("p", "Wählen Sie einen Nutzer aus, der dem Projekt \"" + projektName + "\" zugeordnet werden soll:"), - m("label", "Nutzer"), - m(Select, { - name: "user_id", - value: selectedUserId, - options: options, - required: true, - onchanged: (value: any) => { - selectedUserId = value + m("p", "Wählen Sie die Nutzer aus, die dem Projekt \"" + projektName + "\" zugeordnet werden sollen:"), + // Suchfeld zum Filtern der Nutzerliste. + m("label", "Suche"), + m("input.w3-input.w3-border", + { + type: "text", + value: search, + placeholder: "Nach userid, Name oder E-Mail suchen", + style: {'margin-bottom': '8px'}, + oninput: (e: Event) => { + search = (e.target as HTMLInputElement).value + } } - } as iSelect), + ), + // Scrollbare Liste mit Checkboxen. + m("div", + { + style: { + 'max-height': '300px', + 'overflow-y': 'auto', + 'margin-top': '8px', + border: '1px solid #ccc' + } + }, + availableUsers.length === 0 + ? m("div.w3-padding-small", "Keine weiteren Nutzer verfügbar") + : availableUsers.map((u: iUser) => + m("label.w3-block.w3-hover-light-grey.w3-text-left", + { + style: {'border-bottom': '1px solid #eee', padding: '4px 8px'}, + onclick: () => toggleUser(u.id) + }, + m("input", + { + type: "checkbox", + checked: selectedIds.has(u.id), + style: {'margin-right': '8px'}, + onclick: (e: Event) => e.stopPropagation(), + onchange: (e: Event) => toggleUser(u.id) + } + ), + m("strong", u.userid), + " (" + u.username + ")", + u.email ? m("span.w3-small.w3-opacity", " — " + u.email) : null + ) + ) + ), m("div.w3-bar.w3-padding-16", + m("span.w3-small.w3-left", + {style: {'line-height': '36px', 'margin-right': '8px'}}, + selectedIds.size + " ausgewählt"), m("button.w3-button.w3-theme.w3-right", {style: {'margin-left': '4px'}, onclick: confirmAdd}, "Zuordnen"),