Compare commits

..
4 Commits
Author SHA1 Message Date
gg 9ed5e88e01 Menü Projekt-Berechtigungen, Übersichtssichten und AppAdmin-Erkennung
- Menü: Dropdown "Projekt-Berechtigungen" unter Optionen mit
  Unterpunkten "Projekt-Zugriffe" und "Nutzer-Zugriffe" (nur SysAdmin/AppAdmin)
- AppAdmin-Erkennung: Server liefert isAppAdmin-Flag im Login- und
  getusers-Response (applikationsspezifisch via has_permission)
- Login: CurrentUserIsAppAdmin in sessionStorage speichern/entfernen
- Projekt-Zugriffe: geschachtelter Treetable (Projekt → Nutzer) als
  reine Anzeige, Detail-Sicht aus Projekt-Maske mit Bearbeitung
- Nutzer-Zugriffe: neue Komponente user_projektliste.ts, geschachtelter
  Treetable (Nutzer → Projekte), reine Anzeige
- Server: neuer Endpunkt GET /xhr/projektuser/listall (nur AppAdmin/SysAdmin)
- Server: get_all_projekt_users() mit Batch-Query für Projekte und User
- Treetable-Widget: Platzhalter-Pfeil bei kinderlosen Nodes nur wenn
  Geschwister mit Kindern existieren (hasSiblingsWithChildren)
- Datei projekt_user.ts umbenannt in projekt_userliste.ts (konsistent
  mit user_projektliste.ts)

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-07 16:04:06 +02:00
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
gg 4cb0084066 Projekt-Sichtbarkeit: Nutzer-Zuordnung, projektbezogene Filterung und Schreibschutz
- Neues Modell ProjektUser (projekt_users) für projektbezogene Sichtbarkeit
- Migration V01_09 legt Tabelle an (keine Initial-Befüllung)
- CRUD-Logik für Projekt-User-Zuordnungen mit Cross-DB-User-Auflösung
- Serverseitige Sichtbarkeits-Filterung: Projekt-Liste/Get filtert nach
  projekt_users-Zuordnung für Nicht-SysAdmin/Nicht-AppAdmin
- Auto-Assignment: Ersteller wird bei Projektanlage automatisch zugeordnet
- Sichtbarkeits-Filterung auf CallLog und Kontaktadresse erweitert
  (Liste und Einzelabruf)
- Schreibschutz: Alle Schreib-Endpunkte (update/delete) für Projekt,
  Kontaktadresse und CallLog prüfen Projekt-Zuordnung vor der Ausführung
- Hilfsfunktionen _get_visible_user_id() und _require_projekt_access()
  zur Kapselung der wiederkehrenden Prüfungen
- REST-Endpunkte /xhr/projektuser/{list,add,remove};
  Schreib-Endpunkte mit @authorisation_required([]) als Erweiterungspunkt
- Client: Neues Modell projekt_user.ts, Listen-Komponente mit Modal
  (Select-Widget), Button in Projekt-Maske, Route registriert
- Build aktualisiert (tsc + rollup)
- AGENTS.md: Vorgaben für projektbezogene Sichtbarkeit dokumentiert

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-05 15:45:32 +02:00
gg b2f8684afb Fehlerbehandlung für REST-Aufrufe in allen CRM-Komponenten und Konvention in AGENTS.md
Alle m.request-Aufrufe in den CRM-Komponenten (kunde, kontakt, projekt,
kontaktadresse, call_log) erhalten .catch(showServerError), sodass
Serverfehler wie 401 (fehlende Autorisierung) dem Nutzer als Modal
angezeigt werden. Die Navigation bleibt im .then-Zweig, damit sie bei
Fehler nicht ausgeführt wird. AGENTS.md erhält die entsprechende
Konvention für zukünftige REST-Aufrufe.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-05 14:29:23 +02:00
22 changed files with 1496 additions and 62 deletions

No files matched your search

+17
View File
@@ -30,6 +30,8 @@ Erstelle die Datenmodelle für die Typescript-Entwicklung im Verzeichnis Client/
Beim Aufruf einer Bearbeitungsmaske werden in den Route-Params nur die ID (und ggf. zum Vorbelegen notwendige Eltern-FKs wie `kunden_id`) übergeben — nicht die vollständigen Felder des Objekts. Die Maske lädt die Daten frisch vom Server via `fetchXxxById(id)` im `oninit`. So wird sichergestellt, dass die Daten aktuell sind und zwischenzeitliche Änderungen anderer Nutzer nicht mit veralteten Werten aus der Liste überschrieben werden. Beim Neuanlegen (`id === -1`) entfällt der Fetch; vorbelegte Werte (z.B. `kunden_id`) kommen aus den Route-Params.
Jeder `m.request`-Aufruf (REST-Endpunkt) muss `.catch((e) => showServerError(e))` anhängen, damit Serverfehler (z.B. 401 bei fehlender Autorisierung) dem Nutzer als Modal angezeigt werden. Die Navigation (`m.route.set`) steht im `.then`-Zweig, damit sie bei Fehler nicht ausgeführt wird und der Nutzer auf der Maske bleibt. `showServerError` wird aus `./auth/authmessages` importiert.
Bei `m.ClosureComponent` den Closure-Parameter weglassen (`() =>`) und Attribute ausschließlich über das `vnode` des jeweiligen Lifecycle-Hooks (`view`, `oninit`, `onupdate` etc.) lesen. Das `vnode` der Closure-Funktion ist ein Snapshot zum Erzeugungszeitpunkt und wird bei Redraws nicht aktualisiert — daraus gelesene `attrs` sind veraltet. Initialisierung, die `attrs` braucht, gehört nach `oninit`. Beispiel:
```typescript
export const Select: m.ClosureComponent<iSelect> = () => {
@@ -53,5 +55,20 @@ Nach jeder Änderung an `.ts`-Dateien unter `Client/` ist der Build-Prozess (tsc
## Vorgaben für die Server-seitige Entwicklung unter Python
### Projektbezogene Sichtbarkeit und Schreibschutz
Projekte sind nutzerbezogen: Ein Nutzer sieht nur Projekte, denen er über die Tabelle `projekt_users` zugeordnet ist. Diese Sichtbarkeits-Filterung muss für **jede** projektbezogene Entität (Projekt selbst und alle davon abhängigen Entitäten wie Kontaktadresse, CallLog) in den Lese- und Schreib-Endpunkten umgesetzt werden.
Die Prüfung erfolgt rollenbasiert, nicht flagbasiert: SysAdmin und AppAdmin (geprüft via `current_user.is_SysAdmin()` bzw. `current_user.has_permission(app.config["APPID"], [APP_ADMIN])`) sehen alle Projekte. Alle anderen Nutzer werden über ihre `current_user.id` gefiltert.
Dafür werden in `crm/routes.py` zwei Hilfsfunktionen bereitgestellt:
- `_get_visible_user_id()`: Liefert `None` für SysAdmin/AppAdmin (kein Filter), sonst `current_user.id`. Wird an die Modell-Funktionen der Lese-Endpunkte (list/get) übergeben.
- `_require_projekt_access(projekt_id)`: Prüft vor Schreiboperationen (create/update/delete), ob der Nutzer dem Projekt zugeordnet ist. Bei fehlender Zuordnung wird 404 (nicht 403) geworfen, um keine Rückschlüsse auf die Existenz des Projekts zu ermöglichen.
Bei neuen projektbezogenen Entitäten muss:
1. Die Modell-Funktion `get_xxx(...)` einen optionalen `user_id`-Parameter erhalten. Ist dieser gesetzt, wird per Subquery auf `projekt_users` gefiltert.
2. Die Modell-Funktion `get_xxx_by_id(id, user_id)` bei gesetztem `user_id` die Projekt-Zuordnung prüfen und bei Fehlen 404 werfen.
3. Die Lese-Endpunkte in `crm/routes.py` `_get_visible_user_id()` aufrufen und das Ergebnis an die Modell-Funktion übergeben.
4. Die Schreib-Endpunkte `_require_projekt_access(projekt_id)` aufrufen, bevor die Änderung durchgeführt wird. Bei create/update steht `projekt_id` im JSON-Body (`request.json["projekt_id"]` bzw. `request.json["id"]`); bei delete muss die Entität ggf. erst mit Sichtbarkeits-Check geladen werden, um die `projekt_id` zu ermitteln.
## Generierte Dateien nicht beachten
Das Verzeichnis `Client/tsout/` enthält die JavaScript-Ausgabe der TypeScript-Kompilation und wird durch den Build-Prozess (tsc + rollup) erzeugt. Diese Dateien sind nicht manuell zu bearbeiten und brauchen bei der Codeanalyse nicht berücksichtigt zu werden. Ebenso sind die JavaScript-Dateien in `NGINX_Server/html/static/` (z.B. `kbp_crm_00.00.01.js`) die Ausgabe des Rollup-Builds und nicht zu untersuchen.
+9 -2
View File
@@ -22,10 +22,17 @@ class AutorisationError(ApplicationError):
else:
userrolestxt = ", ".join([role.name for role in self.user_roles])
ApplicationError.__init__(self,
# 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,
"Erforderliche Rollen sind: " + ", ".join(self.allowed_roles), htmlstatus=HtmlStatusCode.Unauthorized)
]
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
+8 -2
View File
@@ -52,6 +52,7 @@ def checklogin():
login_user(user)
result = user.to_dict()
result["isSysAdmin"] = user.is_SysAdmin()
result["isAppAdmin"] = user.has_permission(app.config["APPID"], [APP_ADMIN])
return jsonify(result), 200
@@ -86,13 +87,18 @@ def logout():
def getusers():
"""
Die Rückgabe wird in 'users.get_users()' (s.u.) anhand er App-Berechtigung und der
Rollen gefiltert.
Rollen gefiltert. Pro User wird zusätzlich isSysAdmin und das app-spezifische
isAppAdmin Flag ergänzt, damit der Client keine app-unspezifische Rollenprüfung
durchführen muss.
"""
userquery = users.get_users()
result = []
user: User
for user in userquery:
result.append(user.to_relationaldict())
eintrag = user.to_relationaldict()
eintrag["isSysAdmin"] = user.is_SysAdmin()
eintrag["isAppAdmin"] = user.has_permission(app.config["APPID"], [APP_ADMIN])
result.append(eintrag)
return jsonify(result), 200
+35 -1
View File
@@ -12,7 +12,7 @@ erfolgt ausschließlich über __bind_key__.
import datetime
import decimal
from sqlalchemy import Boolean, Column, Date, DateTime, ForeignKey, Index, Integer, String
from sqlalchemy import Boolean, Column, Date, DateTime, ForeignKey, Index, Integer, String, UniqueConstraint
from sqlalchemy.orm import relationship
from db import dbapp
@@ -160,6 +160,40 @@ class Projekt(dbapp.Model):
return result
class ProjektUser(dbapp.Model):
"""
Verknüpfungstabelle zwischen Projekt und User.
Steuert die Sichtbarkeit von Projekten: Ein Nutzer sieht nur Projekte,
denen er über diese Tabelle zugeordnet ist. Die Zuordnung erfolgt durch
den AppAdmin (oder später andere berechtigte Rollen).
Die Tabelle liegt in der CRM-Datenbank (appdb). Die Spalte 'user_id'
referenziert logisch die 'id' in der Auth-Datenbanktabelle 'users', ein
physischer Fremdschlüssel ist aufgrund der zwei getrennten SQLite-Datenbanken
nicht möglich. Die Auflösung von user_id auf username etc. erfolgt zur
Laufzeit durch eine separate Query gegen die Auth-Datenbank.
Das UniqueConstraint stellt sicher, dass jeder Nutzer nur einmal pro
Projekt zugeordnet werden kann.
"""
__tablename__ = 'projekt_users'
__bind_key__ = BINDKEY
__table_args__ = (
Index('ix_projekt_users_projekt_id', 'projekt_id'),
UniqueConstraint('projekt_id', 'user_id', name='uq_projekt_user'),
)
id = Column(Integer, primary_key=True)
projekt_id = Column(Integer, ForeignKey('projekte.id'), nullable=False)
user_id = Column(Integer, nullable=False)
projekt = relationship('Projekt')
def to_dict(self):
"""Liefert die Projekt-User-Zuordnung als Dictionary."""
return columns2dict(self)
class Kontaktadresse(dbapp.Model):
"""
Akquise-Ziel innerhalb eines Projekts. Jede Kontaktadresse ist genau
+29 -3
View File
@@ -10,31 +10,47 @@ dort über den Decorator authorisation_required.
:version: 1.0
"""
from db import dbapp
from crm.model import CallLog, Projekt
from crm.model import CallLog, Projekt, ProjektUser
from auth.model.errors import ApplicationError, HtmlStatusCode
session = dbapp.session
def get_call_logs(projekt_id: int | None = None):
def get_call_logs(projekt_id: int | None = None, user_id: int | None = None):
"""
Liefert Gesprächsnotizen, sortiert nach Datum absteigend.
Ist projekt_id gesetzt, werden nur die Notizen dieses Projekts
zurückgegeben (serverseitige Filterung). Ohne projekt_id werden
alle Notizen geliefert.
Ist user_id gesetzt, werden nur Notizen von Projekten geliefert, denen
der Nutzer über projekt_users zugeordnet ist (Sichtbarkeits-Filterung).
SysAdmin und AppAdmin übergeben user_id=None und sehen alle Notizen.
"""
query = CallLog.query
if projekt_id is not None:
query = query.filter(CallLog.projekt_id == projekt_id)
if user_id is not None:
# Nur Notizen von Projekten, denen der Nutzer zugeordnet ist.
query = query.filter(
CallLog.projekt_id.in_(
dbapp.session.query(ProjektUser.projekt_id).filter(
ProjektUser.user_id == user_id
)
)
)
return query.order_by(CallLog.datum.desc()).all()
def get_call_log(call_log_id: int) -> CallLog:
def get_call_log(call_log_id: int, user_id: int | None = None) -> CallLog:
"""
Liefert eine einzelne Gesprächsnotiz anhand ihrer ID.
Wirft ApplicationError (404), falls die Notiz nicht existiert.
Ist user_id gesetzt, wird zusätzlich geprüft, ob der Nutzer dem
Projekt der Notiz zugeordnet ist; sonst wird 404 geliefert.
"""
call_log = CallLog.query.get(call_log_id)
if call_log is None:
@@ -42,6 +58,16 @@ def get_call_log(call_log_id: int) -> CallLog:
"Gesprächsnotiz mit der ID {} wurde nicht gefunden.".format(call_log_id),
htmlstatus=HtmlStatusCode.Not_Found,
)
if user_id is not None:
zuordnung = ProjektUser.query.filter(
ProjektUser.projekt_id == call_log.projekt_id,
ProjektUser.user_id == user_id,
).first()
if zuordnung is None:
raise ApplicationError(
"Gesprächsnotiz mit der ID {} wurde nicht gefunden.".format(call_log_id),
htmlstatus=HtmlStatusCode.Not_Found,
)
return call_log
+12
View File
@@ -200,6 +200,18 @@ def _db_V01_08():
session.commit()
def _db_V01_09():
"""
Legt die Tabelle 'projekt_users' an. Keine Initial-Befüllung —
bestehende Projekte bleiben ohne Zuordnungen, bis der AppAdmin
diese manuell vergibt. SysAdmin/AppAdmin sehen alle Projekte ohnehin.
create_all erzeugt nur fehlende Tabellen, bestehende bleiben unangetastet.
"""
dbapp.create_all(bind_key=BINDKEY)
_updatesysteminfo('01.09')
session.commit()
def checkupdates():
"""
Ermittelt die aktuelle DB-Version aus der Systeminfo-Tabelle und führt alle
+29 -3
View File
@@ -10,31 +10,47 @@ dort über den Decorator authorisation_required.
:version: 1.0
"""
from db import dbapp
from crm.model import Kontaktadresse, Projekt
from crm.model import Kontaktadresse, Projekt, ProjektUser
from auth.model.errors import ApplicationError, HtmlStatusCode
session = dbapp.session
def get_kontaktadressen(projekt_id: int | None = None):
def get_kontaktadressen(projekt_id: int | None = None, user_id: int | None = None):
"""
Liefert Kontaktadressen, sortiert nach Name.
Ist projekt_id gesetzt, werden nur die Kontaktadressen dieses
Projekts zurückgegeben (serverseitige Filterung). Ohne projekt_id
werden alle Kontaktadressen geliefert.
Ist user_id gesetzt, werden nur Kontaktadressen von Projekten geliefert,
denen der Nutzer über projekt_users zugeordnet ist (Sichtbarkeits-
Filterung). SysAdmin und AppAdmin übergeben user_id=None und sehen alle.
"""
query = Kontaktadresse.query
if projekt_id is not None:
query = query.filter(Kontaktadresse.projekt_id == projekt_id)
if user_id is not None:
# Nur Kontaktadressen von Projekten, denen der Nutzer zugeordnet ist.
query = query.filter(
Kontaktadresse.projekt_id.in_(
dbapp.session.query(ProjektUser.projekt_id).filter(
ProjektUser.user_id == user_id
)
)
)
return query.order_by(Kontaktadresse.name).all()
def get_kontaktadresse(kontaktadresse_id: int) -> Kontaktadresse:
def get_kontaktadresse(kontaktadresse_id: int, user_id: int | None = None) -> Kontaktadresse:
"""
Liefert eine einzelne Kontaktadresse anhand ihrer ID.
Wirft ApplicationError (404), falls die Kontaktadresse nicht existiert.
Ist user_id gesetzt, wird zusätzlich geprüft, ob der Nutzer dem
Projekt der Kontaktadresse zugeordnet ist; sonst wird 404 geliefert.
"""
kontaktadresse = Kontaktadresse.query.get(kontaktadresse_id)
if kontaktadresse is None:
@@ -42,6 +58,16 @@ def get_kontaktadresse(kontaktadresse_id: int) -> Kontaktadresse:
"Kontaktadresse mit der ID {} wurde nicht gefunden.".format(kontaktadresse_id),
htmlstatus=HtmlStatusCode.Not_Found,
)
if user_id is not None:
zuordnung = ProjektUser.query.filter(
ProjektUser.projekt_id == kontaktadresse.projekt_id,
ProjektUser.user_id == user_id,
).first()
if zuordnung is None:
raise ApplicationError(
"Kontaktadresse mit der ID {} wurde nicht gefunden.".format(kontaktadresse_id),
htmlstatus=HtmlStatusCode.Not_Found,
)
return kontaktadresse
+47 -3
View File
@@ -10,31 +10,54 @@ dort über den Decorator authorisation_required.
:version: 1.0
"""
from db import dbapp
from crm.model import Projekt, Kunde
from crm.model import Projekt, Kunde, ProjektUser
from auth.model.errors import ApplicationError, HtmlStatusCode
from flask_login import current_user
current_user: object
session = dbapp.session
def get_projekte(kunden_id: int | None = None):
def get_projekte(kunden_id: int | None = None, user_id: int | None = None):
"""
Liefert Projekte, sortiert nach projektname.
Ist kunden_id gesetzt, werden nur die Projekte dieses Kunden
zurückgegeben (serverseitige Filterung). Ohne kunden_id werden
alle Projekte geliefert.
Ist user_id gesetzt, werden nur Projekte geliefert, denen der
Nutzer über die Tabelle 'projekt_users' zugeordnet ist. Dies
steuert die Sichtbarkeit: Nicht zugeordnete Nutzer sehen diese
Projekte nicht. SysAdmin und AppAdmin übergeben user_id=None
und sehen alle Projekte.
"""
query = Projekt.query
if kunden_id is not None:
query = query.filter(Projekt.kunden_id == kunden_id)
if user_id is not None:
# Nur Projekte, denen der Nutzer zugeordnet ist. Der Subquery
# auf projekt_users filtert die sichtbaren Projekt-IDs.
query = query.filter(
Projekt.id.in_(
dbapp.session.query(ProjektUser.projekt_id).filter(
ProjektUser.user_id == user_id
)
)
)
return query.order_by(Projekt.projektname).all()
def get_projekt(projekt_id: int) -> Projekt:
def get_projekt(projekt_id: int, user_id: int | None = None) -> Projekt:
"""
Liefert ein einzelnes Projekt anhand seiner ID.
Wirft ApplicationError (404), falls das Projekt nicht existiert.
Ist user_id gesetzt, wird zusätzlich geprüft, ob der Nutzer dem
Projekt zugeordnet ist. Ist er nicht zugeordnet, wird ein 404
geliefert, damit nicht zugeordnete Nutzer keine Rückschlüsse auf
die Existenz des Projekts ziehen können.
"""
projekt = Projekt.query.get(projekt_id)
if projekt is None:
@@ -42,6 +65,19 @@ def get_projekt(projekt_id: int) -> Projekt:
"Projekt mit der ID {} wurde nicht gefunden.".format(projekt_id),
htmlstatus=HtmlStatusCode.Not_Found,
)
if user_id is not None:
# Sichtbarkeitsprüfung: Ist der Nutzer dem Projekt zugeordnet?
zuordnung = ProjektUser.query.filter(
ProjektUser.projekt_id == projekt_id,
ProjektUser.user_id == user_id,
).first()
if zuordnung is None:
# 404 statt 403, damit nicht zugeordnete Nutzer keine Info über
# die Existenz des Projekts erhalten.
raise ApplicationError(
"Projekt mit der ID {} wurde nicht gefunden.".format(projekt_id),
htmlstatus=HtmlStatusCode.Not_Found,
)
return projekt
@@ -78,6 +114,14 @@ def create_projekt(data: dict) -> Projekt:
projekt.startdatum = (data.get("startdatum") or "").strip() or None
session.add(projekt)
session.commit()
# Auto-Assignment: Den Ersteller dem neuen Projekt zuordnen, damit
# er es sehen kann. Nur für authentifizierte Nutzer mit einer id.
if hasattr(current_user, "id") and current_user.id is not None:
zuordnung = ProjektUser()
zuordnung.projekt_id = projekt.id
zuordnung.user_id = current_user.id
session.add(zuordnung)
session.commit()
return projekt
+209
View File
@@ -0,0 +1,209 @@
"""
Geschäftslogik für die Projekt-User-Zuordnung (Sichtbarkeit von Projekten).
Diese Funktionen kapseln den DB-Zugriff auf die Tabelle 'projekt_users' und
die Auflösung der user_id gegen die Auth-Datenbank (username, email etc.).
Da CRM- und Auth-Datenbank getrennte SQLite-Dateien sind, erfolgt die
Nutzer-Auflösung durch eine separate Query, nicht durch einen SQL-Join.
Aufgerufen werden die Funktionen aus crm/routes.py; die Autorisierungsprüfung
erfolgt dort über den Decorator authorisation_required.
:author: Mistral Vibe <vibe@mistral.ai>
:date: 2026-09-05
:version: 1.0
"""
from db import dbapp
from crm.model import ProjektUser, Projekt
from auth.model import User
from auth.model.errors import ApplicationError, HtmlStatusCode
session = dbapp.session
def get_projekt_users(projekt_id: int):
"""
Liefert alle einem Projekt zugeordneten Nutzer als Liste von Dictionaries.
Jedes Dictionary enthält die Felder der ProjektUser-Zuordnung (id,
projekt_id, user_id) sowie die aus der Auth-Datenbank aufgelösten
Nutzerdaten (userid, username, email).
- projekt_id: int
- Die ID des Projekts, dessen Zuordnungen geliefert werden sollen.
"""
zuordnungen = ProjektUser.query.filter(
ProjektUser.projekt_id == projekt_id
).all()
if not zuordnungen:
return []
# Alle user_ids sammeln und in einer einzigen Query gegen die Auth-DB
# auflösen (Batch-Query statt N+1).
user_ids = [z.user_id for z in zuordnungen]
users = User.query.filter(User.id.in_(user_ids)).all()
user_map = {u.id: u for u in users}
result = []
for z in zuordnungen:
eintrag = z.to_dict()
user = user_map.get(z.user_id)
if user is not None:
eintrag['userid'] = user.userid
eintrag['username'] = user.username
eintrag['email'] = user.email or ""
else:
# Nutzer wurde aus der Auth-DB gelöscht, die Zuordnung existiert
# noch. Die Felder werden leer geliefert.
eintrag['userid'] = ""
eintrag['username'] = ""
eintrag['email'] = ""
result.append(eintrag)
return result
def add_projekt_user(projekt_id: int, user_id: int) -> ProjektUser:
"""
Ordnet einen Nutzer einem Projekt zu. Das UniqueConstraint verhindert
Duplikate. Wirft ApplicationError (404), falls das Projekt nicht
existiert, oder (400), falls die Zuordnung bereits besteht oder die
Nutzer-ID ungültig ist.
- projekt_id: int
- Die ID des Projekts.
- user_id: int
- Die ID (nicht userid) des Nutzers aus der Auth-Datenbank.
"""
projekt = Projekt.query.get(projekt_id)
if projekt is None:
raise ApplicationError(
"Das Projekt mit der ID {} wurde nicht gefunden.".format(projekt_id),
htmlstatus=HtmlStatusCode.Not_Found,
)
if user_id is None or int(user_id) <= 0:
raise ApplicationError(
"Es muss ein gültiger Nutzer ausgewählt werden.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
# Prüfen, ob der Nutzer in der Auth-Datenbank existiert.
user = User.query.get(int(user_id))
if user is None:
raise ApplicationError(
"Der ausgewählte Nutzer existiert nicht.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
# Duplikat-Prüfung (zusätzlich zum UniqueConstraint, um eine
# aussagekräftige Fehlermeldung zu liefern).
existing = ProjektUser.query.filter(
ProjektUser.projekt_id == projekt_id,
ProjektUser.user_id == int(user_id),
).first()
if existing is not None:
raise ApplicationError(
"Der Nutzer ist diesem Projekt bereits zugeordnet.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
zuordnung = ProjektUser()
zuordnung.projekt_id = projekt_id
zuordnung.user_id = int(user_id)
session.add(zuordnung)
session.commit()
return zuordnung
def remove_projekt_user(projekt_id: int, user_id: int):
"""
Entfernt die Zuordnung eines Nutzers zu einem Projekt.
Wirft ApplicationError (404), falls die Zuordnung nicht existiert.
- projekt_id: int
- Die ID des Projekts.
- user_id: int
- Die ID (nicht userid) des Nutzers aus der Auth-Datenbank.
"""
zuordnung = ProjektUser.query.filter(
ProjektUser.projekt_id == projekt_id,
ProjektUser.user_id == int(user_id),
).first()
if zuordnung is None:
raise ApplicationError(
"Die Zuordnung existiert nicht.",
htmlstatus=HtmlStatusCode.Not_Found,
)
session.delete(zuordnung)
session.commit()
def get_projekt_ids_for_user(user_id: int):
"""
Liefert alle Projekt-IDs, denen der Nutzer zugeordnet ist.
Wird für die serverseitige Sichtbarkeits-Filterung in get_projekte
und get_projekt verwendet.
- user_id: int
- Die ID (nicht userid) des Nutzers aus der Auth-Datenbank.
"""
zuordnungen = ProjektUser.query.filter(
ProjektUser.user_id == user_id
).all()
return [z.projekt_id for z in zuordnungen]
def is_user_assigned(projekt_id: int, user_id: int) -> bool:
"""
Prüft, ob ein Nutzer einem Projekt zugeordnet ist.
- projekt_id: int
- Die ID des Projekts.
- user_id: int
- Die ID (nicht userid) des Nutzers aus der Auth-Datenbank.
"""
zuordnung = ProjektUser.query.filter(
ProjektUser.projekt_id == projekt_id,
ProjektUser.user_id == user_id,
).first()
return zuordnung is not None
def get_all_projekt_users():
"""
Liefert alle Projekt-User-Zuordnungen, angereichert mit projektname
(aus Projekt-Relationship) sowie userid, username und email (aus
Auth-DB via Batch-Query).
Wird für die Übersichtssicht "Nutzer-Zugriffe" verwendet, in der alle
Zuordnungen in einem Aufruf geladen werden.
"""
zuordnungen = ProjektUser.query.all()
if not zuordnungen:
return []
# Projekte und Nutzer in jeweils einer Batch-Query auflösen.
projekt_ids = list({z.projekt_id for z in zuordnungen})
user_ids = list({z.user_id for z in zuordnungen})
projekte = Projekt.query.filter(Projekt.id.in_(projekt_ids)).all()
projekt_map = {p.id: p for p in projekte}
users = User.query.filter(User.id.in_(user_ids)).all()
user_map = {u.id: u for u in users}
result = []
for z in zuordnungen:
eintrag = z.to_dict()
projekt = projekt_map.get(z.projekt_id)
eintrag['projektname'] = projekt.projektname if projekt else ""
user = user_map.get(z.user_id)
if user is not None:
eintrag['userid'] = user.userid
eintrag['username'] = user.username
eintrag['email'] = user.email or ""
else:
eintrag['userid'] = ""
eintrag['username'] = ""
eintrag['email'] = ""
result.append(eintrag)
return result
+136 -12
View File
@@ -15,18 +15,65 @@ KundenManager werden aus crm.model importiert.
"""
from flask import current_app as app
from flask import jsonify, request
from flask_login import current_user
from auth.decorators import authorisation_required
from auth.model import APP_ADMIN
from auth.model.errors import ApplicationError, HtmlStatusCode
from crm.model import KUNDEN_MANAGER, MITARBEITER
from crm.model import kunde as kunden
from crm.model import kontakt as kontakte
from crm.model import projekt as projekte
from crm.model import projekt_user as projekt_users
from crm.model import kontaktadresse as kontaktadressen
from crm.model import call_log as call_logs
print("crm-routes")
def _get_visible_user_id():
"""
Liefert die user_id für die Sichtbarkeits-Filterung.
SysAdmin und AppAdmin sehen alle Projekte und abhängige Entitäten —
für sie wird None zurückgegeben, sodass die Modell-Funktionen keinen
Filter anwenden. Alle anderen Nutzer (Mitarbeiter, KundenManager)
erhalten ihre current_user.id, sodass nur die ihnen zugeordneten
Projekte bzw. abhängige Einträge geliefert werden.
"""
if current_user.is_SysAdmin() or current_user.has_permission(
app.config["APPID"], [APP_ADMIN]
):
return None
return current_user.id
def _require_projekt_access(projekt_id: int):
"""
Prüft, ob der aktuelle Nutzer für das angegebene Projekt schreibberechtigt ist.
SysAdmin und AppAdmin haben immer Zugriff. Alle anderen Nutzer müssen
dem Projekt über projekt_users zugeordnet sein. Ist der Nutzer nicht
zugeordnet, wird ein ApplicationError (404) geworfen — 404 statt 403,
damit nicht zugeordnete Nutzer keine Rückschlüsse auf die Existenz
des Projekts ziehen können.
Muss in allen Schreib-Endpunkten (create/update/delete) aufgerufen
werden, die ein konkretes Projekt betreffen, bevor die Änderung
durchgeführt wird.
- projekt_id: int
- Die ID des Projekts, auf das zugegriffen werden soll.
"""
user_id = _get_visible_user_id()
if user_id is not None:
if not projekt_users.is_user_assigned(projekt_id, user_id):
raise ApplicationError(
"Projekt mit der ID {} wurde nicht gefunden.".format(projekt_id),
htmlstatus=HtmlStatusCode.Not_Found,
)
# --------------------------------------------------------------------------- #
# Kunde #
# --------------------------------------------------------------------------- #
@@ -126,17 +173,23 @@ def kontakt_delete():
@app.get("/xhr/projekt/list")
@authorisation_required([MITARBEITER, KUNDEN_MANAGER])
def projekt_list():
"""Liefert Projekte als Liste. Optional nach kunden_id gefiltert."""
"""Liefert Projekte als Liste. Optional nach kunden_id gefiltert.
Für Nicht-SysAdmin/Nicht-AppAdmin werden nur die Projekte geliefert,
denen der Nutzer über projekt_users zugeordnet ist."""
kunden_id = request.args.get("kunden_id", type=int)
result = [p.to_dict() for p in projekte.get_projekte(kunden_id)]
user_id = _get_visible_user_id()
result = [p.to_dict() for p in projekte.get_projekte(kunden_id, user_id)]
return jsonify(result), 200
@app.get("/xhr/projekt/get")
@authorisation_required([MITARBEITER, KUNDEN_MANAGER])
def projekt_get():
"""Liefert ein einzelnes Projekt anhand seiner ID (Query-Param id)."""
projekt = projekte.get_projekt(int(request.args.get("id")))
"""Liefert ein einzelnes Projekt anhand seiner ID (Query-Param id).
Für Nicht-SysAdmin/Nicht-AppAdmin wird geprüft, ob der Nutzer dem
Projekt zugeordnet ist; sonst wird 404 geliefert."""
user_id = _get_visible_user_id()
projekt = projekte.get_projekt(int(request.args.get("id")), user_id)
return jsonify(projekt.to_dict()), 200
@@ -152,6 +205,7 @@ def projekt_create():
@authorisation_required([KUNDEN_MANAGER])
def projekt_update():
"""Aktualisiert ein bestehendes Projekt."""
_require_projekt_access(request.json["id"])
projekt = projekte.update_projekt(request.json)
return jsonify(projekt.to_dict()), 200
@@ -160,10 +214,58 @@ def projekt_update():
@authorisation_required([KUNDEN_MANAGER])
def projekt_delete():
"""Löscht ein Projekt anhand seiner ID (Query-Param id)."""
_require_projekt_access(int(request.args.get("id")))
projekte.delete_projekt(int(request.args.get("id")))
return jsonify({}), 200
# --------------------------------------------------------------------------- #
# ProjektUser (Nutzer-Zuordnung / Sichtbarkeit) #
# --------------------------------------------------------------------------- #
@app.get("/xhr/projektuser/list")
@authorisation_required([MITARBEITER, KUNDEN_MANAGER])
def projektuser_list():
"""Liefert alle einem Projekt zugeordneten Nutzer (Query-Param projekt_id)."""
projekt_id = request.args.get("projekt_id", type=int)
result = projekt_users.get_projekt_users(projekt_id)
return jsonify(result), 200
@app.get("/xhr/projektuser/listall")
# Leeres allowed_roles-Array als Erweiterungspunkt (nur AppAdmin/SysAdmin).
@authorisation_required([])
def projektuser_listall():
"""Liefert alle Projekt-User-Zuordnungen inkl. projektname und Nutzerdaten."""
result = projekt_users.get_all_projekt_users()
return jsonify(result), 200
@app.post("/xhr/projektuser/add")
# Leeres allowed_roles-Array als Erweiterungspunkt: Aktuell passieren nur
# AppAdmin und SysAdmin (vom Decorator implizit ergänzt). Sobald später eine
# weitere Rolle diese Endpunkte nutzen soll, wird sie einfach hier eingetragen,
# z.B. @authorisation_required([KUNDEN_MANAGER]).
@authorisation_required([])
def projektuser_add():
"""Ordnet einen Nutzer einem Projekt zu (body: {projekt_id, user_id})."""
data = request.json
projekt_users.add_projekt_user(data["projekt_id"], data["user_id"])
return jsonify({}), 200
@app.delete("/xhr/projektuser/remove")
# Leeres allowed_roles-Array als Erweiterungspunkt (siehe projektuser_add).
@authorisation_required([])
def projektuser_remove():
"""Entfernt die Zuordnung eines Nutzers zu einem Projekt (params: projekt_id, user_id)."""
projekt_id = request.args.get("projekt_id", type=int)
user_id = request.args.get("user_id", type=int)
projekt_users.remove_projekt_user(projekt_id, user_id)
return jsonify({}), 200
# --------------------------------------------------------------------------- #
# Kontaktadresse #
# --------------------------------------------------------------------------- #
@@ -172,17 +274,22 @@ def projekt_delete():
@app.get("/xhr/kontaktadresse/list")
@authorisation_required([MITARBEITER, KUNDEN_MANAGER])
def kontaktadresse_list():
"""Liefert Kontaktadressen als Liste. Optional nach projekt_id gefiltert."""
"""Liefert Kontaktadressen als Liste. Optional nach projekt_id gefiltert.
Für Nicht-SysAdmin/Nicht-AppAdmin nur Kontaktadressen sichtbarer Projekte."""
projekt_id = request.args.get("projekt_id", type=int)
result = [k.to_dict() for k in kontaktadressen.get_kontaktadressen(projekt_id)]
user_id = _get_visible_user_id()
result = [k.to_dict() for k in kontaktadressen.get_kontaktadressen(projekt_id, user_id)]
return jsonify(result), 200
@app.get("/xhr/kontaktadresse/get")
@authorisation_required([MITARBEITER, KUNDEN_MANAGER])
def kontaktadresse_get():
"""Liefert eine einzelne Kontaktadresse anhand ihrer ID (Query-Param id)."""
kontaktadresse = kontaktadressen.get_kontaktadresse(int(request.args.get("id")))
"""Liefert eine einzelne Kontaktadresse anhand ihrer ID (Query-Param id).
Für Nicht-SysAdmin/Nicht-AppAdmin wird geprüft, ob der Nutzer dem
Projekt der Kontaktadresse zugeordnet ist; sonst 404."""
user_id = _get_visible_user_id()
kontaktadresse = kontaktadressen.get_kontaktadresse(int(request.args.get("id")), user_id)
return jsonify(kontaktadresse.to_dict()), 200
@@ -190,6 +297,7 @@ def kontaktadresse_get():
@authorisation_required([KUNDEN_MANAGER])
def kontaktadresse_create():
"""Legt eine neue Kontaktadresse an. ID wird serverseitig vergeben."""
_require_projekt_access(int(request.json["projekt_id"]))
kontaktadresse = kontaktadressen.create_kontaktadresse(request.json)
return jsonify(kontaktadresse.to_dict()), 200
@@ -198,6 +306,7 @@ def kontaktadresse_create():
@authorisation_required([KUNDEN_MANAGER])
def kontaktadresse_update():
"""Aktualisiert eine bestehende Kontaktadresse."""
_require_projekt_access(int(request.json["projekt_id"]))
kontaktadresse = kontaktadressen.update_kontaktadresse(request.json)
return jsonify(kontaktadresse.to_dict()), 200
@@ -206,6 +315,10 @@ def kontaktadresse_update():
@authorisation_required([KUNDEN_MANAGER])
def kontaktadresse_delete():
"""Löscht eine Kontaktadresse anhand ihrer ID (Query-Param id)."""
# projekt_id der Kontaktadresse ermitteln, um die Projekt-Zuordnung
# des Nutzers zu prüfen, bevor gelöscht wird.
user_id = _get_visible_user_id()
kontaktadresse = kontaktadressen.get_kontaktadresse(int(request.args.get("id")), user_id)
kontaktadressen.delete_kontaktadresse(int(request.args.get("id")))
return jsonify({}), 200
@@ -218,17 +331,22 @@ def kontaktadresse_delete():
@app.get("/xhr/calllog/list")
@authorisation_required([MITARBEITER, KUNDEN_MANAGER])
def calllog_list():
"""Liefert Gesprächsnotizen als Liste. Optional nach projekt_id gefiltert."""
"""Liefert Gesprächsnotizen als Liste. Optional nach projekt_id gefiltert.
Für Nicht-SysAdmin/Nicht-AppAdmin nur Notizen sichtbarer Projekte."""
projekt_id = request.args.get("projekt_id", type=int)
result = [l.to_dict() for l in call_logs.get_call_logs(projekt_id)]
user_id = _get_visible_user_id()
result = [l.to_dict() for l in call_logs.get_call_logs(projekt_id, user_id)]
return jsonify(result), 200
@app.get("/xhr/calllog/get")
@authorisation_required([MITARBEITER, KUNDEN_MANAGER])
def calllog_get():
"""Liefert eine einzelne Gesprächsnotiz anhand ihrer ID (Query-Param id)."""
call_log = call_logs.get_call_log(int(request.args.get("id")))
"""Liefert eine einzelne Gesprächsnotiz anhand ihrer ID (Query-Param id).
Für Nicht-SysAdmin/Nicht-AppAdmin wird geprüft, ob der Nutzer dem
Projekt der Notiz zugeordnet ist; sonst 404."""
user_id = _get_visible_user_id()
call_log = call_logs.get_call_log(int(request.args.get("id")), user_id)
return jsonify(call_log.to_dict()), 200
@@ -236,6 +354,7 @@ def calllog_get():
@authorisation_required([KUNDEN_MANAGER])
def calllog_create():
"""Legt eine neue Gesprächsnotiz an. ID wird serverseitig vergeben."""
_require_projekt_access(int(request.json["projekt_id"]))
call_log = call_logs.create_call_log(request.json)
return jsonify(call_log.to_dict()), 200
@@ -244,6 +363,7 @@ def calllog_create():
@authorisation_required([KUNDEN_MANAGER])
def calllog_update():
"""Aktualisiert eine bestehende Gesprächsnotiz."""
_require_projekt_access(int(request.json["projekt_id"]))
call_log = call_logs.update_call_log(request.json)
return jsonify(call_log.to_dict()), 200
@@ -252,6 +372,10 @@ def calllog_update():
@authorisation_required([KUNDEN_MANAGER])
def calllog_delete():
"""Löscht eine Gesprächsnotiz anhand ihrer ID (Query-Param id)."""
# projekt_id der Notiz ermitteln, um die Projekt-Zuordnung
# des Nutzers zu prüfen, bevor gelöscht wird.
user_id = _get_visible_user_id()
call_log = call_logs.get_call_log(int(request.args.get("id")), user_id)
call_logs.delete_call_log(int(request.args.get("id")))
return jsonify({}), 200
+8
View File
@@ -34,11 +34,18 @@ export const loginscreen: m.ClosureComponent<iLoginscreen> = (vnode) => {
if (user.isSysAdmin) {
sessionStorage.setItem("CurrentUserIsSysAdmin", "1");
}
// AppAdmin: der Server prüft applikationsspezifisch via
// has_permission(appid, [APP_ADMIN]) und liefert isAppAdmin
// im Login-Response. Der Client wertet nur das Flag aus.
if (user.isAppAdmin) {
sessionStorage.setItem("CurrentUserIsAppAdmin", "1");
}
})
.then(() => {m.route.set(vnode.attrs.setnextpage)})
.catch((error) => {
sessionStorage.removeItem("CurrentUser");
sessionStorage.removeItem("CurrentUserIsSysAdmin");
sessionStorage.removeItem("CurrentUserIsAppAdmin");
showServerError(error)
})
@@ -52,6 +59,7 @@ export const loginscreen: m.ClosureComponent<iLoginscreen> = (vnode) => {
.then (() => {
sessionStorage.removeItem("CurrentUser");
sessionStorage.removeItem("CurrentUserIsSysAdmin");
sessionStorage.removeItem("CurrentUserIsAppAdmin");
// global.appglobals.currentuser = "nicht angemeldet"
// global.appglobals.currentuserid = ""
})
+1
View File
@@ -9,6 +9,7 @@ export interface iUser {
email: string
telefon: string
isSysAdmin: boolean
isAppAdmin: boolean
userapps: iUserApp[]
}
+9 -8
View File
@@ -7,6 +7,7 @@ import {iCallLog, fetchCallLogs, fetchCallLogById, createCallLogREST, updateCall
import {iKunde, fetchKunden} from "../models/kunde"
import {iProjekt, fetchProjekte, fetchProjektById} from "../models/projekt"
import {iKontaktadresse, fetchKontaktadressen} from "../models/kontaktadresse"
import {showServerError} from "./auth/authmessages"
// Aktualisierungsintervall für die Dauer-Anzeige in Millisekunden.
const DAUER_UPDATE_INTERVALL = 1000
@@ -97,7 +98,7 @@ export const call_log_liste: m.ClosureComponent = (vnode) => {
// gesetzt ist.
fetchCallLogs(filterProjektId !== null ? filterProjektId : undefined).then((result) => {
treenodes = result.map((log: iCallLog) => new Treenode(log, true, []))
})
}).catch((e) => showServerError(e))
// Projektnamen und Kundennamen asynchron auflösen, falls
// nicht in den Route-Params enthalten.
if (filterProjektId !== null) {
@@ -106,7 +107,7 @@ export const call_log_liste: m.ClosureComponent = (vnode) => {
projektName = projekt.projektname
}
kundenName = projekt.kunden_name
})
}).catch((e) => showServerError(e))
}
},
view: (vnode) => {
@@ -202,7 +203,7 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
currentLog = result
projektVoreingestellt = true
ansprechpartnerVoreingestellt = currentLog.name_ansprechpartner !== ""
})
}).catch((e) => showServerError(e))
} else {
projektVoreingestellt = routeParams && routeParams.projekt_id ? parseInt(routeParams.projekt_id) !== -1 : false
currentLog.projekt_id = routeParams && routeParams.projekt_id ? parseInt(routeParams.projekt_id) : -1
@@ -230,9 +231,9 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
}, DAUER_UPDATE_INTERVALL)
}
// Alle Listen asynchron laden, für Select-Optionen.
fetchKunden().then((result) => { kundenListe = result })
fetchProjekte().then((result) => { projektListe = result })
fetchKontaktadressen().then((result) => { kontaktadressenListe = result })
fetchKunden().then((result) => { kundenListe = result }).catch((e) => showServerError(e))
fetchProjekte().then((result) => { projektListe = result }).catch((e) => showServerError(e))
fetchKontaktadressen().then((result) => { kontaktadressenListe = result }).catch((e) => showServerError(e))
},
onremove: (vnode) => {
if (timerId != null) { window.clearInterval(timerId) }
@@ -285,9 +286,9 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
erledigt: formData.get("erledigt") === "on"
}
if (currentLog.id === -1) {
createCallLogREST(gespeichertesLog).then(() => m.route.set("/calllogliste", {projekt_id: currentLog.projekt_id, projekt_name: currentLog.projekt_name}))
createCallLogREST(gespeichertesLog).then(() => m.route.set("/calllogliste", {projekt_id: currentLog.projekt_id, projekt_name: currentLog.projekt_name})).catch((e) => showServerError(e))
} else {
updateCallLogREST(gespeichertesLog).then(() => m.route.set("/calllogliste", {projekt_id: currentLog.projekt_id, projekt_name: currentLog.projekt_name}))
updateCallLogREST(gespeichertesLog).then(() => m.route.set("/calllogliste", {projekt_id: currentLog.projekt_id, projekt_name: currentLog.projekt_name})).catch((e) => showServerError(e))
}
return true
}
+6 -5
View File
@@ -4,6 +4,7 @@ import { Input, iInput, Textarea, iTextarea} from "./widgets/inputs"
import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2"
import {iKontakt, fetchKontakte, fetchKontaktById, createKontaktREST, updateKontaktREST} from "../models/kontakt"
import {iKunde, fetchKundeById} from "../models/kunde"
import {showServerError} from "./auth/authmessages"
export const kontaktliste: m.ClosureComponent = (vnode) => {
let treenodes: iTreenode[] = []
@@ -69,12 +70,12 @@ export const kontaktliste: m.ClosureComponent = (vnode) => {
// kunden_id erfolgt serverseitig, sofern ein Filter gesetzt ist.
fetchKontakte(filterKundenId !== null ? filterKundenId : undefined).then((result) => {
treenodes = result.map((kontakt: iKontakt) => new Treenode(kontakt, true, []))
})
}).catch((e) => showServerError(e))
// Kundennamen asynchron auflösen, falls nicht in Route-Params.
if (filterKundenId !== null && kundenName === "") {
fetchKundeById(filterKundenId).then((kunde: iKunde) => {
kundenName = kunde.name
})
}).catch((e) => showServerError(e))
}
},
view: (vnode) => {
@@ -138,7 +139,7 @@ export const Kontakt: m.ClosureComponent = (vnode) => {
// Änderungen anderer Nutzer nicht zu überschreiben.
fetchKontaktById(id).then((result) => {
currentKontakt = result
})
}).catch((e) => showServerError(e))
} else {
// Neuanlegen: kunden_id und kunden_name aus Route-Params
// (voreingestellt aus der Kontaktliste).
@@ -161,9 +162,9 @@ export const Kontakt: m.ClosureComponent = (vnode) => {
notiz: (formData.get("notiz") as string) || ""
}
if (currentKontakt.id === -1) {
createKontaktREST(gespeicherterKontakt).then(() => m.route.set("/kontaktliste", {kunden_id: currentKontakt.kunden_id, kunden_name: currentKontakt.kunden_name}))
createKontaktREST(gespeicherterKontakt).then(() => m.route.set("/kontaktliste", {kunden_id: currentKontakt.kunden_id, kunden_name: currentKontakt.kunden_name})).catch((e) => showServerError(e))
} else {
updateKontaktREST(gespeicherterKontakt).then(() => m.route.set("/kontaktliste", {kunden_id: currentKontakt.kunden_id, kunden_name: currentKontakt.kunden_name}))
updateKontaktREST(gespeicherterKontakt).then(() => m.route.set("/kontaktliste", {kunden_id: currentKontakt.kunden_id, kunden_name: currentKontakt.kunden_name})).catch((e) => showServerError(e))
}
return true
}
+6 -5
View File
@@ -4,6 +4,7 @@ import { Input, iInput, Textarea, iTextarea} from "./widgets/inputs"
import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2"
import {iKontaktadresse, fetchKontaktadressen, fetchKontaktadresseById, createKontaktadresseREST, updateKontaktadresseREST} from "../models/kontaktadresse"
import {iProjekt, fetchProjektById} from "../models/projekt"
import {showServerError} from "./auth/authmessages"
export const kontaktadresseliste: m.ClosureComponent = (vnode) => {
let treenodes: iTreenode[] = []
@@ -96,12 +97,12 @@ export const kontaktadresseliste: m.ClosureComponent = (vnode) => {
// gesetzt ist.
fetchKontaktadressen(filterProjektId !== null ? filterProjektId : undefined).then((result) => {
treenodes = result.map((kontaktadresse: iKontaktadresse) => new Treenode(kontaktadresse, true, []))
})
}).catch((e) => showServerError(e))
// Projektnamen asynchron auflösen, falls nicht in Route-Params.
if (filterProjektId !== null && projektName === "") {
fetchProjektById(filterProjektId).then((projekt: iProjekt) => {
projektName = projekt.projektname
})
}).catch((e) => showServerError(e))
}
},
view: (vnode) => {
@@ -174,7 +175,7 @@ export const Kontaktadresse: m.ClosureComponent = (vnode) => {
// Kontaktadresse frisch vom Server holen.
fetchKontaktadresseById(id).then((result) => {
currentKontaktadresse = result
})
}).catch((e) => showServerError(e))
} else {
// Neuanlegen: projekt_id und projekt_name aus Route-Params.
currentKontaktadresse.projekt_id = routeParams && routeParams.projekt_id ? parseInt(routeParams.projekt_id) : -1
@@ -201,9 +202,9 @@ export const Kontaktadresse: m.ClosureComponent = (vnode) => {
notiz: (formData.get("notiz") as string) || ""
}
if (currentKontaktadresse.id === -1) {
createKontaktadresseREST(gespeicherteKontaktadresse).then(() => m.route.set("/kontaktadresseliste", {projekt_id: currentKontaktadresse.projekt_id, projekt_name: currentKontaktadresse.projekt_name}))
createKontaktadresseREST(gespeicherteKontaktadresse).then(() => m.route.set("/kontaktadresseliste", {projekt_id: currentKontaktadresse.projekt_id, projekt_name: currentKontaktadresse.projekt_name})).catch((e) => showServerError(e))
} else {
updateKontaktadresseREST(gespeicherteKontaktadresse).then(() => m.route.set("/kontaktadresseliste", {projekt_id: currentKontaktadresse.projekt_id, projekt_name: currentKontaktadresse.projekt_name}))
updateKontaktadresseREST(gespeicherteKontaktadresse).then(() => m.route.set("/kontaktadresseliste", {projekt_id: currentKontaktadresse.projekt_id, projekt_name: currentKontaktadresse.projekt_name})).catch((e) => showServerError(e))
}
return true
}
+5 -4
View File
@@ -3,6 +3,7 @@ import {Form, iForm} from "./widgets/form"
import { Input, iInput} from "./widgets/inputs"
import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2"
import {iKunde, fetchKunden, fetchKundeById, createKundeREST, updateKundeREST} from "../models/kunde"
import {showServerError} from "./auth/authmessages"
export const kunde: m.ClosureComponent = (vnode) => {
let currentKunde: iKunde = {
@@ -23,7 +24,7 @@ export const kunde: m.ClosureComponent = (vnode) => {
// Änderungen anderer Nutzer nicht zu überschreiben.
fetchKundeById(id).then((result) => {
currentKunde = result
})
}).catch((e) => showServerError(e))
}
},
view: (vnode) => {
@@ -38,9 +39,9 @@ export const kunde: m.ClosureComponent = (vnode) => {
land: (formData.get("land") as string) || ""
}
if (currentKunde.id === -1) {
createKundeREST(gespeicherterKunde).then(() => m.route.set("/kundenliste"))
createKundeREST(gespeicherterKunde).then(() => m.route.set("/kundenliste")).catch((e) => showServerError(e))
} else {
updateKundeREST(gespeicherterKunde).then(() => m.route.set("/kundenliste"))
updateKundeREST(gespeicherterKunde).then(() => m.route.set("/kundenliste")).catch((e) => showServerError(e))
}
return true
}
@@ -130,7 +131,7 @@ export const kundenliste: m.ClosureComponent = (vnode) => {
// Promise-Auflösung automatisch ein Redraw aus.
fetchKunden().then((kunden: iKunde[]) => {
treenodes = kunden.map((kunde: iKunde) => new Treenode(kunde, true, []))
})
}).catch((e) => showServerError(e))
},
view: (vnode) => {
return m("div.w3-container.w3-auto", {
+14 -7
View File
@@ -4,6 +4,7 @@ import { Input, iInput} from "./widgets/inputs"
import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2"
import {iProjekt, fetchProjekte, fetchProjektById, createProjektREST, updateProjektREST} from "../models/projekt"
import {iKunde, fetchKundeById} from "../models/kunde"
import {showServerError} from "./auth/authmessages"
export const projektliste: m.ClosureComponent = (vnode) => {
let treenodes: iTreenode[] = []
@@ -106,12 +107,12 @@ export const projektliste: m.ClosureComponent = (vnode) => {
// kunden_id erfolgt serverseitig, sofern ein Filter gesetzt ist.
fetchProjekte(filterKundenId !== null ? filterKundenId : undefined).then((result) => {
treenodes = result.map((projekt: iProjekt) => new Treenode(projekt, true, []))
})
}).catch((e) => showServerError(e))
// Kundennamen asynchron auflösen, falls nicht in Route-Params.
if (filterKundenId !== null && kundenName === "") {
fetchKundeById(filterKundenId).then((kunde: iKunde) => {
kundenName = kunde.name
})
}).catch((e) => showServerError(e))
}
},
view: (vnode) => {
@@ -171,7 +172,7 @@ export const Projekt: m.ClosureComponent = (vnode) => {
// Änderungen anderer Nutzer nicht zu überschreiben.
fetchProjektById(id).then((result) => {
currentProjekt = result
})
}).catch((e) => showServerError(e))
} else {
// Neuanlegen: kunden_id und kunden_name aus Route-Params
// (voreingestellt aus der Projektliste).
@@ -192,9 +193,9 @@ export const Projekt: m.ClosureComponent = (vnode) => {
startdatum: (formData.get("startdatum") as string) || ""
}
if (currentProjekt.id === -1) {
createProjektREST(gespeichertesProjekt).then(() => m.route.set("/projektliste", {kunden_id: currentProjekt.kunden_id, kunden_name: currentProjekt.kunden_name}))
createProjektREST(gespeichertesProjekt).then(() => m.route.set("/projektliste", {kunden_id: currentProjekt.kunden_id, kunden_name: currentProjekt.kunden_name})).catch((e) => showServerError(e))
} else {
updateProjektREST(gespeichertesProjekt).then(() => m.route.set("/projektliste", {kunden_id: currentProjekt.kunden_id, kunden_name: currentProjekt.kunden_name}))
updateProjektREST(gespeichertesProjekt).then(() => m.route.set("/projektliste", {kunden_id: currentProjekt.kunden_id, kunden_name: currentProjekt.kunden_name})).catch((e) => showServerError(e))
}
return true
}
@@ -207,9 +208,15 @@ export const Projekt: m.ClosureComponent = (vnode) => {
? "Neues Projekt für " + currentProjekt.kunden_name
: "Projekt-Stammblatt"),
currentProjekt.id !== -1
? m("button.w3-button.w3-theme",
? [
m("button.w3-button.w3-theme",
{onclick: () => m.route.set("/kontaktadresseliste", {projekt_id: currentProjekt.id})},
m("i.material-icons", "contact_page"), " Kontaktadressen")
m("i.material-icons", "contact_page"), " Kontaktadressen"),
m("button.w3-button.w3-theme",
{style: {'margin-left': '4px'},
onclick: () => m.route.set("/projektuserliste", {projekt_id: currentProjekt.id})},
m("i.material-icons", "group"), " Nutzerberechtigungen")
]
: null
),
m("div", {style: {flex: '1', 'min-height': '0'}},
+459
View File
@@ -0,0 +1,459 @@
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, iProjektUserWithProjekt, fetchProjektUsers, fetchAllProjektUsers, addProjektUserREST, removeProjektUserREST} from "../models/projekt_user"
import {iProjekt, fetchProjektById, fetchProjekte} from "../models/projekt"
import {iUser} from "./auth/models/users"
import {showServerError} from "./auth/authmessages"
/**
* Übersicht "Projekt-Zugriffe": Zeigt alle Projekte als Top-Level-Knoten
* mit ihren jeweils zugeordneten Nutzern als Child-Knoten. Der SysAdmin und der AppAdmin
* können Nutzer zuordnen und entfernen.
*
* Wird die Komponente mit einer projekt_id in den Route-Params aufgerufen,
* zeigt sie nur die Nutzer-Liste für dieses eine Projekt (Detail-Sicht,
* aufgerufen aus der Projekt-Maske heraus).
*/
export const projektuserliste: m.ClosureComponent = () => {
let treenodes: iTreenode[] = []
let selectedNode: iTreenode | null = null
let projektId: number = -1
let projektName: string = ""
let projektUsers: iProjektUser[] = []
// Alle Projekte für den Übersichtsmodus.
let projekte: iProjekt[] = []
// Alle Projekt-User-Zuordnungen (inkl. projektname) für den Übersichtsmodus.
let allZuordnungen: iProjektUserWithProjekt[] = []
// True, wenn die Detail-Sicht (Nutzer-Liste für ein Projekt) aktiv ist.
let detailMode: boolean = false
// 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("span", 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)
}
/**
* Entfernt einen Nutzer aus einem Projekt (Übersichtsmodus).
*/
const removeUserOverview = (zuordnung: iProjektUserWithProjekt, e: Event) => {
e.stopPropagation()
removeProjektUserREST(zuordnung.projekt_id, zuordnung.user_id).then(() => {
loadOverviewData()
}).catch((e) => showServerError(e))
}
/**
* Columnmapper für Top-Level (Projekt-Zeilen im Übersichtsmodus).
*/
const overviewProjektColumnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
let projekt: iProjekt = node.nodevalue
return [
m("span", m("strong", projekt.projektname)),
m("span", projekt.kunden_name),
m("span.w3-small.w3-opacity", node.children.length + " Nutzer")
]
}
/**
* Columnmapper für Child-Level (Nutzer-Zeilen im Übersichtsmodus).
* Nur Anzeige — keine Buttons.
*/
const overviewNutzerColumnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
let z: iProjektUserWithProjekt = node.nodevalue
return [
m("span", m("strong", z.userid)),
m("span", z.username + " — " + z.email),
m("span", "")
]
}
// Der Treetable verwendet denselben columnmapper für alle Levels.
// Über die nodevalue-Eigenschaft wird zwischen Projekt- und Nutzer-
// Zeilen unterschieden.
const overviewColumnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
if (node.nodevalue && node.nodevalue.user_id !== undefined) {
return overviewNutzerColumnmapper(node)
}
return overviewProjektColumnmapper(node)
}
const overviewFilterFunction = (node: iTreenode, searchvalue: string): boolean => {
let searchLower = searchvalue.toLowerCase()
let nv = node.nodevalue
if (nv && nv.user_id !== undefined) {
// Nutzer-Zeile: nach userid, username filtern.
return (nv.userid || "").toLowerCase().includes(searchLower) ||
(nv.username || "").toLowerCase().includes(searchLower)
}
// Projekt-Zeile: nach projektname, kunden_name filtern.
// Bei Treffer in Child-Nutzern das Projekt ebenfalls anzeigen.
if ((nv.projektname || "").toLowerCase().includes(searchLower) ||
(nv.kunden_name || "").toLowerCase().includes(searchLower)) {
return true
}
return node.children.some((child) => {
let z = child.nodevalue
return (z.userid || "").toLowerCase().includes(searchLower) ||
(z.username || "").toLowerCase().includes(searchLower)
})
}
/**
* Lädt alle Projekte und alle Zuordnungen und baut den
* Treenode-Baum (Projekt → Nutzer) auf.
*/
const loadOverviewData = () => {
Promise.all([
fetchProjekte(),
fetchAllProjektUsers(),
m.request({method: "GET", url: "/auth/getusers"})
]).then(([projekteResult, zuordnungenResult, usersResult]) => {
projekte = projekteResult as iProjekt[]
allZuordnungen = zuordnungenResult as iProjektUserWithProjekt[]
allUsers = usersResult as iUser[]
buildOverviewTreenodes()
}).catch((e) => showServerError(e))
}
/**
* Baut den Treenode-Baum für den Übersichtsmodus:
* Top-Level = alle Projekte, Child-Nodes = zugeordnete Nutzer.
*/
const buildOverviewTreenodes = () => {
treenodes = projekte.map((p: iProjekt) => {
const nutzerNodes = allZuordnungen
.filter((z) => z.projekt_id === p.id)
.map((z) => new Treenode(z, true, []))
return new Treenode(p, true, nutzerNodes)
})
}
/**
* Öffnet das Modal zum Hinzufügen von Nutzern.
* Im Detail-Modus für das aktuelle Projekt, im Übersichts-Modus
* für das selektierte Projekt.
*/
const openAddModal = () => {
if (allUsers.length === 0) {
loadAllUsers()
}
m.mount(gridMountpoints.modalhook, addUserModal)
}
/**
* Liefert die projekt_id, für die das Add-Modal aktuell gilt:
* im Detail-Modus die Route-Param projektId, im Übersichts-Modus
* die projekt_id der selektierten Projekt-Zeile.
*/
const getModalProjektId = (): number => {
if (detailMode) {
return projektId
}
// Übersichtsmodus: selektierte Projekt-Zeile.
if (selectedNode && selectedNode.nodevalue &&
selectedNode.nodevalue.user_id === undefined) {
return (selectedNode.nodevalue as iProjekt).id
}
return -1
}
/**
* Liefert die bereits zugeordneten Nutzer für das Modal:
* im Detail-Modus projektUsers, im Übersichts-Modus aus
* allZuordnungen gefiltert.
*/
const getModalAssignedIds = (): Set<number> => {
if (detailMode) {
return new Set(projektUsers.map((pu) => pu.user_id))
}
const pid = getModalProjektId()
return new Set(
allZuordnungen
.filter((z) => z.projekt_id === pid)
.map((z) => z.user_id)
)
}
/**
* 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 = getModalAssignedIds()
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.isAppAdmin) 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 modalProjektId = getModalProjektId()
const promises: Promise<void>[] = []
selectedIds.forEach((uid: number) => {
promises.push(addProjektUserREST(modalProjektId, uid))
})
Promise.all(promises).then(() => {
closeModal()
if (detailMode) {
loadProjektUsers()
} else {
loadOverviewData()
}
}).catch((e) => {
showServerError(e)
if (detailMode) {
loadProjektUsers()
} else {
loadOverviewData()
}
})
}
/**
* 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 : ""
detailMode = projektId !== -1
if (detailMode) {
// Detail-Sicht: Nutzer-Liste für ein konkretes Projekt.
if (projektName === "") {
fetchProjektById(projektId).then((projekt: iProjekt) => {
projektName = projekt.projektname
}).catch((e) => showServerError(e))
}
loadAllUsers()
loadProjektUsers()
} else {
// Übersichts-Sicht: alle Projekte + Zuordnungen + Nutzer laden.
loadOverviewData()
}
},
view: (vnode) => {
return m("div.w3-container.w3-auto",
{
style: {
display: 'flex',
'flex-direction': 'column',
height: 'inherit',
'overflow-y': 'inherit'
}
},
m("h1", detailMode
? (projektName
? "Nutzerberechtigungen für Projekt: " + projektName
: "Nutzerberechtigungen")
: "Projekt-Zugriffe — Übersicht"),
m("div", {style: {flex: '1', 'overflow-y': 'inherit'}},
detailMode
? 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")
)
)
: m(Treetable,
{
treenodes: treenodes,
columnmapper: overviewColumnmapper,
filter: overviewFilterFunction,
onRowMarked: handleRowMarked,
isSelected: (node: iTreenode) => node === selectedNode
} as iTreetable
)
)
)
}
}
}
+313
View File
@@ -0,0 +1,313 @@
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 {iProjektUserWithProjekt, fetchAllProjektUsers, addProjektUserREST, removeProjektUserREST} from "../models/projekt_user"
import {iProjekt, fetchProjekte} from "../models/projekt"
import {iUser} from "./auth/models/users"
import {showServerError} from "./auth/authmessages"
/**
* Übersicht "Nutzer-Zugriffe": Zeigt alle Nutzer (ohne SysAdmin/AppAdmin)
* als Top-Level-Knoten mit ihren jeweils zugeordneten Projekten als
* Child-Knoten. Der AppAdmin kann Projekte zuordnen und entfernen.
*/
export const userprojektliste: m.ClosureComponent = () => {
let treenodes: iTreenode[] = []
let selectedNode: iTreenode | null = null
// Alle Nutzer aus der Auth-DB.
let allUsers: iUser[] = []
// Alle Projekt-User-Zuordnungen (inkl. projektname).
let allZuordnungen: iProjektUserWithProjekt[] = []
// Alle Projekte (für das Hinzufügen-Modal).
let allProjekte: iProjekt[] = []
const handleRowMarked = (node: iTreenode) => {
selectedNode = node
}
/**
* Lädt alle Nutzer, alle Projekte und alle Zuordnungen und baut
* den Treenode-Baum (Nutzer → Projekte) auf.
*/
const loadData = () => {
Promise.all([
m.request({method: "GET", url: "/auth/getusers"}),
fetchProjekte(),
fetchAllProjektUsers()
]).then(([usersResult, projekteResult, zuordnungenResult]) => {
allUsers = usersResult as iUser[]
allProjekte = projekteResult as iProjekt[]
allZuordnungen = zuordnungenResult as iProjektUserWithProjekt[]
buildTreenodes()
}).catch((e) => showServerError(e))
}
/**
* Baut den Treenode-Baum: Top-Level = Nutzer (ohne SysAdmin/AppAdmin),
* Child-Nodes = zugeordnete Projekte.
*/
const buildTreenodes = () => {
// Nur Nutzer ohne SysAdmin/AppAdmin-Rolle anzeigen.
const nutzer = allUsers.filter((u: iUser) => {
if (u.isSysAdmin) return false
if (u.isAppAdmin) return false
return true
}).sort((a: iUser, b: iUser) => {
return (a.username || "").localeCompare(b.username || "")
})
treenodes = nutzer.map((u: iUser) => {
// Zugeordnete Projekte für diesen Nutzer.
const projektNodes = allZuordnungen
.filter((z) => z.user_id === u.id)
.map((z) => new Treenode(z, true, []))
return new Treenode(u, true, projektNodes)
})
}
/**
* Entfernt ein Projekt von einem Nutzer.
*/
const removeProjekt = (zuordnung: iProjektUserWithProjekt, e: Event) => {
e.stopPropagation()
removeProjektUserREST(zuordnung.projekt_id, zuordnung.user_id).then(() => {
loadData()
}).catch((e) => showServerError(e))
}
/**
* Columnmapper für Top-Level (Nutzer-Zeilen).
*/
const nutzerColumnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
let u: iUser = node.nodevalue
return [
m("span", m("strong", u.userid)),
m("span", u.username),
m("span", u.email),
m("span.w3-small.w3-opacity", node.children.length + " Projekt(e)")
]
}
/**
* Columnmapper für Child-Level (Projekt-Zeilen).
* Nur Anzeige — keine Buttons.
*/
const projektColumnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
let z: iProjektUserWithProjekt = node.nodevalue
return [
m("span", m("strong", z.projektname)),
m("span", ""),
m("span", ""),
m("span", "")
]
}
// Der Treetable verwendet pro Level denselben columnmapper. Da Nutzer-
// und Projekt-Zeilen unterschiedlich aussehen, wird über die
// nodevalue-Eigenschaft zwischen ihnen unterschieden.
const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
// Wenn die nodevalue eine projekt_id-Eigenschaft hat, ist es
// eine Projekt-Zuordnung (Child-Knoten), sonst ein Nutzer (Top-Level).
if (node.nodevalue && node.nodevalue.projekt_id !== undefined) {
return projektColumnmapper(node)
}
return nutzerColumnmapper(node)
}
const filterFunction = (node: iTreenode, searchvalue: string): boolean => {
let searchLower = searchvalue.toLowerCase()
let nv = node.nodevalue
if (nv && nv.projekt_id !== undefined) {
// Projekt-Zeile: nach projektname filtern.
return (nv.projektname || "").toLowerCase().includes(searchLower)
}
// Nutzer-Zeile: nach userid, username, email filtern.
// Bei Suche auch Treffer in den Child-Projekten anzeigen.
if ((nv.userid || "").toLowerCase().includes(searchLower) ||
(nv.username || "").toLowerCase().includes(searchLower) ||
(nv.email || "").toLowerCase().includes(searchLower)) {
return true
}
// Prüfen, ob ein Child-Projekt den Suchtext enthält.
return node.children.some((child) => {
let z = child.nodevalue
return (z.projektname || "").toLowerCase().includes(searchLower)
})
}
/**
* Öffnet das Modal zum Hinzufügen von Projekten zu einem Nutzer.
*/
const openAddModal = (user: iUser) => {
m.mount(gridMountpoints.modalhook, {
view: () => m(addProjektModal, {user: user})
})
}
/**
* Modal zum Hinzufügen mehrerer Projekte zu einem Nutzer.
* Zeigt eine durchsuchbare Checkbox-Liste aller Projekte, die dem
* Nutzer noch nicht zugeordnet sind.
*/
const addProjektModal: m.ClosureComponent<{user: iUser}> = () => {
let selectedIds: Set<number> = new Set()
let search: string = ""
const getAvailableProjekte = (): iProjekt[] => {
const assignedIds = new Set(
allZuordnungen
.filter((z) => z.user_id === vnode_userId)
.map((z) => z.projekt_id)
)
const searchLower = search.toLowerCase()
return allProjekte.filter((p: iProjekt) => {
if (assignedIds.has(p.id)) return false
if (searchLower === "") return true
return (p.projektname || "").toLowerCase().includes(searchLower) ||
(p.kunden_name || "").toLowerCase().includes(searchLower)
}).sort((a: iProjekt, b: iProjekt) => {
return (a.projektname || "").localeCompare(b.projektname || "")
})
}
// Die user_id wird in der Closure gehalten, da der Closure-
// Parameter vom Typ m.ClosureComponent ist und attrs nur im
// view/oninit verfügbar sind.
let vnode_userId: number = -1
const closeModal = () => {
m.mount(gridMountpoints.modalhook, null)
}
const confirmAdd = () => {
if (selectedIds.size === 0) {
closeModal()
return
}
const promises: Promise<void>[] = []
selectedIds.forEach((pid: number) => {
promises.push(addProjektUserREST(pid, vnode_userId))
})
Promise.all(promises).then(() => {
closeModal()
loadData()
}).catch((e) => {
showServerError(e)
loadData()
})
}
const toggleProjekt = (projektId: number) => {
if (selectedIds.has(projektId)) {
selectedIds.delete(projektId)
} else {
selectedIds.add(projektId)
}
}
return {
oninit: (vnode) => {
vnode_userId = vnode.attrs.user.id
},
view: (vnode) => {
const user = vnode.attrs.user
const availableProjekte = getAvailableProjekte()
return m(ModalView,
{
title: "Projekt zuordnen",
type: ModalType.MESSAGE,
width: '500px'
} as iModalView,
m("div.w3-padding-16",
m("p", "Wählen Sie die Projekte aus, die dem Nutzer \"" + user.username + "\" zugeordnet werden sollen:"),
m("label", "Suche"),
m("input.w3-input.w3-border",
{
type: "text",
value: search,
placeholder: "Nach Projektname oder Kunde suchen",
style: {'margin-bottom': '8px'},
oninput: (e: Event) => {
search = (e.target as HTMLInputElement).value
}
}
),
m("div",
{
style: {
'max-height': '300px',
'overflow-y': 'auto',
'margin-top': '8px',
border: '1px solid #ccc'
}
},
availableProjekte.length === 0
? m("div.w3-padding-small", "Keine weiteren Projekte verfügbar")
: availableProjekte.map((p: iProjekt) =>
m("label.w3-block.w3-hover-light-grey.w3-text-left",
{
style: {'border-bottom': '1px solid #eee', padding: '4px 8px'},
onclick: () => toggleProjekt(p.id)
},
m("input",
{
type: "checkbox",
checked: selectedIds.has(p.id),
style: {'margin-right': '8px'},
onclick: (e: Event) => e.stopPropagation(),
onchange: (e: Event) => toggleProjekt(p.id)
}
),
m("strong", p.projektname),
" (" + p.kunden_name + ")"
)
)
),
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) => {
loadData()
},
view: (vnode) => {
return m("div.w3-container.w3-auto",
{
style: {
display: 'flex',
'flex-direction': 'column',
height: 'inherit',
'overflow-y': 'inherit'
}
},
m("h1", "Nutzer-Zugriffe — Übersicht"),
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
)
)
)
}
}
}
+26 -5
View File
@@ -51,6 +51,13 @@ export const Treetable: m.ClosureComponent<iTreetable> = (vnode) => {
}
return {
view: (vnode) => {
// Prüfen, ob mindestens ein Top-Level-Node Kinder hat.
// Wird benötigt, um bei kinderlosen Top-Level-Nodes den
// Platzhalter-Pfeil nur dann zu rendern, wenn es Geschwister
// mit Kindern (und damit Pfeil) gibt.
const topHasSiblingsWithChildren = vnode.attrs.treenodes.some(
(tn: iTreenode) => tn.hasChildren
)
return m("div.w3-border-theme", {style: {height: '99%', overflow: 'hidden', 'border-style': 'solid', 'border-width': '1px'}},
m("div.w3-bar.w3-theme",
m(TopbarButton, {
@@ -80,7 +87,8 @@ export const Treetable: m.ClosureComponent<iTreetable> = (vnode) => {
columnmapper1: vnode.attrs.columnmapper,
onRowMarked: onRowMarked,
isSelected: isSelected,
isEven: ((z % 2) == 0)} as iTreerow)
isEven: ((z % 2) == 0),
hasSiblingsWithChildren: topHasSiblingsWithChildren} as iTreerow)
}
else {
return null
@@ -101,6 +109,11 @@ interface iTreerow {
onRowMarked: (node: iTreenode) => void
isSelected?: (node: iTreenode) => boolean
isEven?: boolean
/** True, wenn mindestens ein Geschwister-Node auf demselben Level
* Kinder hat. Wird verwendet, um bei kinderlosen Top-Level-Nodes
* den Platzhalter-Pfeil nur dann zu rendern, wenn es Nodes mit
* Pfeil gibt, an denen der Text ausgerichtet werden kann. */
hasSiblingsWithChildren?: boolean
}
let Treerow: m.ClosureComponent<iTreerow> = (vnode) => {
@@ -110,6 +123,10 @@ let Treerow: m.ClosureComponent<iTreerow> = (vnode) => {
// Zeile selektiert ist; in diesem Fall wird der Hintergrund der
// gesamten <tr>-Zeile eingefärbt.
const rowSelected = vnode.attrs.isSelected ? vnode.attrs.isSelected(vnode.attrs.treenode) : false
// Prüfen, ob mindestens ein Child-Node selbst Kinder hat.
const childHasSiblingsWithChildren = vnode.attrs.treenode.children.some(
(tn: iTreenode) => tn.hasChildren
)
return [
m("tr.w3-border-theme", {onmouseup: () => vnode.attrs.onRowMarked(vnode.attrs.treenode),
onkeyup: () => vnode.attrs.onRowMarked(vnode.attrs.treenode),
@@ -136,9 +153,12 @@ let Treerow: m.ClosureComponent<iTreerow> = (vnode) => {
// Buttons eingerückt werden, damit die Einrückungs-Level auch bei "kinderlosen" Treenodes
// denen der Trennodes mit Kindern entsprechen
: vnode.attrs.level == 0
? null // Wenn der kinderlos Treenode allerdings sirekt auf Level 0 ist, soll nocht
// eingerückt werden, da es bei Level 0 keine Eltern-Nodes mit Kindern und
// demzufolge mit "expand_more/expand_less"-Button gibt.
? (vnode.attrs.hasSiblingsWithChildren
? m("span", {
style: {'margin-left': '0px',
'margin-right': '32px'}
})
: null)
: m(
"span", {
style: {'margin-left': '24px',
@@ -165,7 +185,8 @@ let Treerow: m.ClosureComponent<iTreerow> = (vnode) => {
columnmapper1: vnode.attrs.columnmapper1,
onRowMarked: vnode.attrs.onRowMarked,
isSelected: vnode.attrs.isSelected,
isEven: ((z % 2) == 0)
isEven: ((z % 2) == 0),
hasSiblingsWithChildren: childHasSiblingsWithChildren
} as iTreerow)
})
: null
+35 -2
View File
@@ -5,6 +5,8 @@ import { appmanagementpage } from "./Components/auth/appmanagementpage"
import { changepasswordscreen, loginscreen } from "./Components/auth/login"
import {kunde, kundenliste} from "./Components/kunde"
import { Projekt, projektliste } from "./Components/projekt"
import { projektuserliste } from "./Components/projekt_userliste"
import { userprojektliste } from "./Components/user_projektliste"
import { Call_Log, call_log_liste } from "./Components/call_log"
import { Kontakt, kontaktliste } from "./Components/kontakt"
import { Kontaktadresse, kontaktadresseliste } from "./Components/kontaktadresse"
@@ -62,6 +64,7 @@ function logout() {
console.log(result)
sessionStorage.removeItem("CurrentUser");
sessionStorage.removeItem("CurrentUserIsSysAdmin");
sessionStorage.removeItem("CurrentUserIsAppAdmin");
headerdata.username = "Nicht angemeldet"
if (result['logout'] == true) {m.route.set("/blankpage")}
})
@@ -100,19 +103,46 @@ const knopf: m.Component = {
return m("button", {onclick: healthcheck}, "Aktualisieren")
}
}
const menu: m.Component = {
const menu: m.ClosureComponent = () => {
let showProjektBerechtigungen = false
return {
oninit: (vnode) => {
showProjektBerechtigungen = false
},
view: (vnode) => {
return m(menubar, [
m("a.w3-button.w3-bar-item", {href: "#!/kundenliste"}, "Kunden"),
m("a.w3-button.w3-bar-item", {href: "#!/projektliste"}, "Projekte"),
m("a.w3-button.w3-bar-item", {href: "#!/calllogliste"}, "Gesprächsnotizen"),
// m("a.w3-button.w3-bar-item", {onclick: healthcheck}, "Aktualisieren"),
m("div.w3-dropdown-hover",
m("button.w3-button", "Optionen"),
m("div.w3-dropdown-content.w3-bar-block.w3-card-4",
m("a.w3-button.w3-bar-item", {href: "#!/taglist"}, "Tags"),
m("a.w3-button.w3-bar-item", {href: "#!/default"}, "Datenexport"),
m("a.w3-button.w3-bar-item", {href: "#!/default"}, "Datenimport"),
// Projekt-Berechtigungen nur für SysAdmin/AppAdmin sichtbar.
// Als Toggle-Button realisiert, da verschachtelte w3-dropdown-hover
// innerhalb eines offenen Dropdowns nicht zuverlässig funktionieren.
(sessionStorage.getItem("CurrentUserIsSysAdmin") || sessionStorage.getItem("CurrentUserIsAppAdmin"))
? [
m("button.w3-button.w3-bar-item",
{
onclick: () => {showProjektBerechtigungen = !showProjektBerechtigungen}
},
m("span", showProjektBerechtigungen ? "Projekt-Berechtigungen \u25B2" : "Projekt-Berechtigungen \u25BC")
),
showProjektBerechtigungen
? m("div.w3-bar-block",
m("a.w3-button.w3-bar-item",
{href: "#!/projektuserliste", style: {'padding-left': '32px'}},
"Projekt-Zugriffe"),
m("a.w3-button.w3-bar-item",
{href: "#!/userprojektliste", style: {'padding-left': '32px'}},
"Nutzer-Zugriffe")
)
: null
]
: null,
)
),
m("div.w3-button.w3-right", {onclick: logout}, "Logout"),
@@ -127,6 +157,7 @@ const menu: m.Component = {
])
}
}
}
let mountpoint = document.getElementById("body")
@@ -152,6 +183,8 @@ m.route(gridMountpoints.main, "/blankpage", {
"/kunde": kunde,
"/projektliste": projektliste,
"/projekt": Projekt,
"/projektuserliste": projektuserliste,
"/userprojektliste": userprojektliste,
"/calllogliste": call_log_liste,
"/call_log": Call_Log,
"/kontaktliste": kontaktliste,
+83
View File
@@ -0,0 +1,83 @@
import m from "mithril"
/**
* Datenmodell für eine Projekt-User-Zuordnung (Sichtbarkeit).
*
* Die Felder id, projekt_id und user_id sind die ORM-Spalten der
* Verknüpfungstabelle 'projekt_users'. Die Felder userid, username und
* email werden serverseitig aus der Auth-Datenbank aufgelöst und im JSON
* ergänzt.
*/
export interface iProjektUser {
id: number
projekt_id: number
user_id: number
userid: string
username: string
email: string
}
/**
* Erweitertes Interface für die Übersichtssicht "Nutzer-Zugriffe".
* Enthält zusätzlich den Projektnamen, der serverseitig aus der
* Projekt-Relationship aufgelöst wird.
*/
export interface iProjektUserWithProjekt extends iProjektUser {
projektname: string
}
/* ------------------------------------------------------------------ */
/* REST-API-Aufrufe (Client-Seite) */
/* ------------------------------------------------------------------ */
/* Die folgenden Funktionen kapseln die Kommunikation mit den */
/* serverseitigen REST-Endpunkten (/xhr/projektuser/*). */
/* ------------------------------------------------------------------ */
/**
* Basis-Pfad der ProjektUser-REST-Endpunkte.
*/
const PROJEKTUSER_REST_BASE = "/xhr/projektuser"
/**
* Lädt die Liste der einem Projekt zugeordneten Nutzer vom Server.
*/
export function fetchProjektUsers(projektId: number): Promise<iProjektUser[]> {
return m.request({
method: "GET",
url: PROJEKTUSER_REST_BASE + "/list",
params: {projekt_id: projektId}
}).then((result) => result as iProjektUser[])
}
/**
* Ordnet einen Nutzer einem Projekt zu.
*/
export function addProjektUserREST(projektId: number, userId: number): Promise<void> {
return m.request({
method: "POST",
url: PROJEKTUSER_REST_BASE + "/add",
body: {projekt_id: projektId, user_id: userId}
}).then(() => undefined)
}
/**
* Entfernt die Zuordnung eines Nutzers zu einem Projekt.
*/
export function removeProjektUserREST(projektId: number, userId: number): Promise<void> {
return m.request({
method: "DELETE",
url: PROJEKTUSER_REST_BASE + "/remove",
params: {projekt_id: projektId, user_id: userId}
}).then(() => undefined)
}
/**
* Lädt alle Projekt-User-Zuordnungen inkl. projektname und Nutzerdaten.
* Nur für AppAdmin/SysAdmin.
*/
export function fetchAllProjektUsers(): Promise<iProjektUserWithProjekt[]> {
return m.request({
method: "GET",
url: PROJEKTUSER_REST_BASE + "/listall"
}).then((result) => result as iProjektUserWithProjekt[])
}