Files
kbcrm/Client/Components/projekt_user.ts
T
gg 505177b5e2 ProjektUser-Modal: Mehrfachauswahl, Filterung und Fehlermeldungs-Verbesserung
- 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 <vibe@mistral.ai>
2026-09-05 16:14:13 +02:00

300 lines
13 KiB
TypeScript

import m from "mithril"
import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2"
import {ModalView, ModalType, iModalView} from "./widgets/Modal"
import {gridMountpoints} from "./mainlayout"
import {iProjektUser, fetchProjektUsers, addProjektUserREST, removeProjektUserREST} from "../models/projekt_user"
import {iProjekt, fetchProjektById} from "../models/projekt"
import {iUser} from "./auth/models/users"
import {showServerError} from "./auth/authmessages"
/**
* Liste der einem Projekt zugeordneten Nutzer (Sichtbarkeit).
*
* Der Liste wird die projekt_id über die Route-Params übergeben. Sie zeigt
* alle Nutzer, die das Projekt sehen dürfen. Der AppAdmin (und SysAdmin)
* kann Nutzer über ein Modal mit Select-Widget hinzufügen und über einen
* Entfernen-Button pro Zeile wieder wegnehmen.
*/
export const projektuserliste: m.ClosureComponent = () => {
let treenodes: iTreenode[] = []
let selectedNode: iTreenode | null = null
let projektId: number = -1
let projektName: string = ""
let projektUsers: iProjektUser[] = []
// Alle Nutzer aus der Auth-DB, für das Hinzufügen-Modal.
let allUsers: iUser[] = []
const handleRowMarked = (node: iTreenode) => {
selectedNode = node
}
/**
* Lädt die zugeordneten Nutzer vom Server und aktualisiert die Treenodes.
*/
const loadProjektUsers = () => {
fetchProjektUsers(projektId).then((result) => {
projektUsers = result
treenodes = result.map((pu: iProjektUser) => new Treenode(pu, true, []))
}).catch((e) => showServerError(e))
}
/**
* Lädt alle Nutzer aus der Auth-DB (für das Hinzufügen-Modal).
*/
const loadAllUsers = () => {
m.request({
method: "GET",
url: "/auth/getusers"
}).then((result) => {
allUsers = result as iUser[]
}).catch((e) => showServerError(e))
}
/**
* Entfernt einen Nutzer aus dem Projekt. Wird beim Klick auf den
* Entfernen-Button in der Zeile aufgerufen.
*/
const removeUser = (projektUser: iProjektUser, e: Event) => {
e.stopPropagation()
removeProjektUserREST(projektId, projektUser.user_id).then(() => {
loadProjektUsers()
}).catch((e) => showServerError(e))
}
const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
let pu: iProjektUser = node.nodevalue
return [
m("div", m("strong", pu.userid)),
m("span", pu.username),
m("span", pu.email),
m("div", {style: {display: 'flex', gap: '0', 'white-space': 'nowrap'}},
m("button.w3-button.w3-small.w3-red",
{
title: "Nutzer entfernen",
style: {padding: '0 4px'},
onclick: (e: Event) => removeUser(pu, e)
},
m("i.material-icons", "person_remove"))
)
]
}
const filterFunction = (node: iTreenode, searchvalue: string): boolean => {
let pu: iProjektUser = node.nodevalue
let searchLower = searchvalue.toLowerCase()
return (pu.userid || "").toLowerCase().includes(searchLower) ||
(pu.username || "").toLowerCase().includes(searchLower) ||
(pu.email || "").toLowerCase().includes(searchLower)
}
/**
* Öffnet das Modal zum Hinzufügen eines Nutzers. Die verfügbaren
* Nutzer werden clientseitig gegen die bereits zugeordneten gefiltert.
*/
const openAddModal = () => {
// Alle Nutzer laden, falls noch nicht geschehen.
if (allUsers.length === 0) {
loadAllUsers()
}
m.mount(gridMountpoints.modalhook, addUserModal)
}
/**
* 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 = () => {
// Menge der ausgewählten user_ids (Checkbox aktiviert).
let selectedIds: Set<number> = new Set()
// Suchtext zum Filtern der Liste.
let search: string = ""
// 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))
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 (selectedIds.size === 0) {
closeModal()
return
}
const promises: Promise<void>[] = []
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 availableUsers = getAvailableUsers()
return m(ModalView,
{
title: "Nutzer zuordnen",
type: ModalType.MESSAGE,
width: '500px'
} as iModalView,
m("div.w3-padding-16",
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
}
}
),
// 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"),
m("button.w3-button.w3-theme.w3-right",
{style: {'margin-left': '4px'}, onclick: closeModal},
"Abbrechen")
)
)
)
}
}
}
return {
oninit: (vnode) => {
const routeParams = m.route.param()
projektId = routeParams && routeParams.projekt_id ? parseInt(routeParams.projekt_id) : -1
projektName = routeParams && routeParams.projekt_name ? routeParams.projekt_name : ""
// Projektname asynchron auflösen, falls nicht in Route-Params.
if (projektId !== -1 && projektName === "") {
fetchProjektById(projektId).then((projekt: iProjekt) => {
projektName = projekt.projektname
}).catch((e) => showServerError(e))
}
// Alle Nutzer für das Hinzufügen-Modal vorab laden.
loadAllUsers()
// Zugeordnete Nutzer laden.
loadProjektUsers()
},
view: (vnode) => {
return m("div.w3-container.w3-auto",
{
style: {
display: 'flex',
'flex-direction': 'column',
height: 'inherit',
'overflow-y': 'inherit'
}
},
m("h1", projektName
? "Nutzerberechtigungen für Projekt: " + projektName
: "Nutzerberechtigungen"),
m("div", {style: {flex: '1', 'overflow-y': 'inherit'}},
m(Treetable,
{
treenodes: treenodes,
columnmapper: columnmapper,
filter: filterFunction,
onRowMarked: handleRowMarked,
isSelected: (node: iTreenode) => node === selectedNode
} as iTreetable,
m(TopbarButton,
{
onclick: openAddModal,
tooltip: "Nutzer hinzufügen"
} as iTopbarButton,
m("i.material-icons", "person_add")
)
)
)
)
}
}
}