snapshot: preserve role management and user lifecycle controls

This commit is contained in:
Md Bayazid Bostame
2026-03-26 10:07:49 +01:00
parent 438334bd92
commit b585287004
17 changed files with 1137 additions and 273 deletions

View File

@@ -58,6 +58,7 @@ TEMPLATES = [
'django.template.context_processors.request', 'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth', 'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages', 'django.contrib.messages.context_processors.messages',
'workflows.context_processors.role_context',
], ],
}, },
}, },

File diff suppressed because it is too large Load Diff

View File

@@ -4,3 +4,6 @@ from django.apps import AppConfig
class WorkflowsConfig(AppConfig): class WorkflowsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField' default_auto_field = 'django.db.models.BigAutoField'
name = 'workflows' name = 'workflows'
def ready(self):
from . import signals # noqa: F401

View File

@@ -0,0 +1,5 @@
from .roles import template_role_context
def role_context(request):
return template_role_context(getattr(request, 'user', None))

View File

@@ -1,11 +1,13 @@
from django import forms from django import forms
from pathlib import Path from pathlib import Path
from datetime import timedelta from datetime import timedelta
from django.contrib.auth import get_user_model
from django.utils import timezone from django.utils import timezone
from django.utils.translation import get_language, gettext as _ from django.utils.translation import get_language, gettext as _
from .form_builder import apply_form_field_config from .form_builder import apply_form_field_config
from .models import EmployeeProfile, FormOption, OffboardingRequest, OnboardingRequest, WorkflowConfig from .models import EmployeeProfile, FormOption, OffboardingRequest, OnboardingRequest, WorkflowConfig
from .roles import ROLE_ADMIN, ROLE_GROUP_NAMES, ROLE_IT_STAFF, ROLE_LABELS, ROLE_STAFF, ROLE_SUPER_ADMIN, assign_user_role
YES_NO_CHOICES = [('', '--'), ('ja', 'Ja'), ('nein', 'Nein')] YES_NO_CHOICES = [('', '--'), ('ja', 'Ja'), ('nein', 'Nein')]
@@ -96,6 +98,60 @@ HARDWARE_EXTRA_CHOICES = [('Smartphone', 'Smartphone'), ('Anderes', 'Anderes')]
SOFTWARE_EXTRA_CHOICES = [('Adobe Acrobat Pro (Abonnement: Zusätzliche Kosten)', 'Adobe Acrobat Pro (Abonnement: Zusätzliche Kosten)'), ('Anderes', 'Anderes')] SOFTWARE_EXTRA_CHOICES = [('Adobe Acrobat Pro (Abonnement: Zusätzliche Kosten)', 'Adobe Acrobat Pro (Abonnement: Zusätzliche Kosten)'), ('Anderes', 'Anderes')]
class UserManagementCreateForm(forms.Form):
first_name = forms.CharField(label=_('Vorname'), max_length=150, required=False)
last_name = forms.CharField(label=_('Nachname'), max_length=150, required=False)
username = forms.CharField(label=_('Benutzername'), max_length=150)
email = forms.EmailField(label=_('E-Mail-Adresse'))
role_key = forms.ChoiceField(label=_('Rolle'))
password1 = forms.CharField(label=_('Passwort'), widget=forms.PasswordInput())
password2 = forms.CharField(label=_('Passwort bestätigen'), widget=forms.PasswordInput())
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['role_key'].choices = [
(role_key, str(ROLE_LABELS[role_key]))
for role_key in (ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF, ROLE_STAFF)
]
def clean_username(self):
username = (self.cleaned_data.get('username') or '').strip()
user_model = get_user_model()
if user_model.objects.filter(username=username).exists():
raise forms.ValidationError(_('Dieser Benutzername ist bereits vergeben.'))
return username
def clean_email(self):
return (self.cleaned_data.get('email') or '').strip().lower()
def clean_role_key(self):
role_key = (self.cleaned_data.get('role_key') or '').strip()
if role_key not in ROLE_GROUP_NAMES:
raise forms.ValidationError(_('Ungültige Rolle.'))
return role_key
def clean(self):
cleaned = super().clean()
password1 = cleaned.get('password1')
password2 = cleaned.get('password2')
if password1 and password2 and password1 != password2:
self.add_error('password2', _('Die Passwörter stimmen nicht überein.'))
return cleaned
def save(self):
user_model = get_user_model()
user = user_model.objects.create_user(
username=self.cleaned_data['username'],
email=self.cleaned_data['email'],
password=self.cleaned_data['password1'],
first_name=self.cleaned_data.get('first_name', ''),
last_name=self.cleaned_data.get('last_name', ''),
is_active=True,
)
assign_user_role(user, self.cleaned_data['role_key'])
return user
class OnboardingRequestForm(forms.ModelForm): class OnboardingRequestForm(forms.ModelForm):
first_name = forms.CharField(label='Vorname', required=False) first_name = forms.CharField(label='Vorname', required=False)
last_name = forms.CharField(label='Nachname', required=False) last_name = forms.CharField(label='Nachname', required=False)

View File

@@ -1,6 +1,7 @@
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from workflows.roles import ROLE_STAFF, ROLE_SUPER_ADMIN, assign_user_role, ensure_role_groups
DEFAULT_USERS = [ DEFAULT_USERS = [
{ {
@@ -43,6 +44,8 @@ class Command(BaseCommand):
is_staff=item['is_staff'], is_staff=item['is_staff'],
is_superuser=item['is_superuser'], is_superuser=item['is_superuser'],
) )
ensure_role_groups()
assign_user_role(user, ROLE_SUPER_ADMIN if item['username'] == 'admin_test' else ROLE_STAFF)
self.stdout.write(f'created {user.username}') self.stdout.write(f'created {user.username}')
self.stdout.write(self.style.SUCCESS('initial users created')) self.stdout.write(self.style.SUCCESS('initial users created'))

127
backend/workflows/roles.py Normal file
View File

@@ -0,0 +1,127 @@
from __future__ import annotations
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from django.utils.translation import gettext_lazy as _
ROLE_SUPER_ADMIN = 'super_admin'
ROLE_ADMIN = 'admin'
ROLE_IT_STAFF = 'it_staff'
ROLE_STAFF = 'staff'
ROLE_GROUP_NAMES = {
ROLE_SUPER_ADMIN: 'Super Admin',
ROLE_ADMIN: 'Admin',
ROLE_IT_STAFF: 'IT Staff',
ROLE_STAFF: 'Staff',
}
ROLE_LABELS = {
ROLE_SUPER_ADMIN: _('Super Admin'),
ROLE_ADMIN: _('Admin'),
ROLE_IT_STAFF: _('IT Staff'),
ROLE_STAFF: _('Mitarbeiter'),
}
CAPABILITIES = {
'manage_users': {ROLE_SUPER_ADMIN},
'access_requests_dashboard': {ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF},
'run_intro_session': {ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF},
'generate_intro_pdfs': {ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF},
'retry_requests': {ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF},
'delete_requests': {ROLE_SUPER_ADMIN, ROLE_ADMIN},
'manage_integrations': {ROLE_SUPER_ADMIN, ROLE_ADMIN},
'manage_welcome_emails': {ROLE_SUPER_ADMIN, ROLE_ADMIN},
'manage_builders': {ROLE_SUPER_ADMIN, ROLE_ADMIN},
'view_audit_log': {ROLE_SUPER_ADMIN, ROLE_ADMIN},
'manage_backups': {ROLE_SUPER_ADMIN, ROLE_ADMIN},
'view_docs': {ROLE_SUPER_ADMIN, ROLE_ADMIN},
'access_django_admin_link': {ROLE_SUPER_ADMIN},
}
def ensure_role_groups() -> None:
for name in ROLE_GROUP_NAMES.values():
Group.objects.get_or_create(name=name)
def assign_user_role(user, role_key: str) -> None:
ensure_role_groups()
if role_key not in ROLE_GROUP_NAMES:
raise ValueError(f'Unknown role: {role_key}')
role_groups = Group.objects.filter(name__in=ROLE_GROUP_NAMES.values())
user.groups.remove(*role_groups)
user.groups.add(Group.objects.get(name=ROLE_GROUP_NAMES[role_key]))
is_super_admin = role_key == ROLE_SUPER_ADMIN
user.is_staff = is_super_admin
user.is_superuser = is_super_admin
user.save(update_fields=['is_staff', 'is_superuser'])
def ensure_bootstrap_role_assignments() -> None:
user_model = get_user_model()
bootstrap_roles = {
'admin_test': ROLE_SUPER_ADMIN,
'user_test': ROLE_STAFF,
}
role_group_names = set(ROLE_GROUP_NAMES.values())
for username, role_key in bootstrap_roles.items():
try:
user = user_model.objects.get(username=username)
except user_model.DoesNotExist:
continue
if user.groups.filter(name__in=role_group_names).exists():
continue
assign_user_role(user, role_key)
def get_user_role_key(user) -> str:
if not getattr(user, 'is_authenticated', False):
return ROLE_STAFF
if getattr(user, 'is_superuser', False):
return ROLE_SUPER_ADMIN
group_names = set(user.groups.values_list('name', flat=True))
for role_key in (ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF, ROLE_STAFF):
if ROLE_GROUP_NAMES[role_key] in group_names:
return role_key
if getattr(user, 'is_staff', False):
return ROLE_ADMIN
return ROLE_STAFF
def get_user_role_label(user) -> str:
return str(ROLE_LABELS[get_user_role_key(user)])
def user_has_capability(user, capability: str) -> bool:
if not getattr(user, 'is_authenticated', False):
return False
if getattr(user, 'is_superuser', False):
return True
allowed_roles = CAPABILITIES.get(capability, set())
return get_user_role_key(user) in allowed_roles
def template_role_context(user) -> dict[str, object]:
role_key = get_user_role_key(user)
return {
'role_key': role_key,
'role_label': str(ROLE_LABELS[role_key]),
'can_manage_users': user_has_capability(user, 'manage_users'),
'can_access_requests_dashboard': user_has_capability(user, 'access_requests_dashboard'),
'can_run_intro_session': user_has_capability(user, 'run_intro_session'),
'can_generate_intro_pdfs': user_has_capability(user, 'generate_intro_pdfs'),
'can_retry_requests': user_has_capability(user, 'retry_requests'),
'can_delete_requests': user_has_capability(user, 'delete_requests'),
'can_manage_integrations': user_has_capability(user, 'manage_integrations'),
'can_manage_welcome_emails': user_has_capability(user, 'manage_welcome_emails'),
'can_manage_builders': user_has_capability(user, 'manage_builders'),
'can_view_audit_log': user_has_capability(user, 'view_audit_log'),
'can_manage_backups': user_has_capability(user, 'manage_backups'),
'can_view_docs': user_has_capability(user, 'view_docs'),
'can_access_django_admin_link': user_has_capability(user, 'access_django_admin_link'),
}

View File

@@ -0,0 +1,12 @@
from django.db.models.signals import post_migrate
from django.dispatch import receiver
from .roles import ensure_bootstrap_role_assignments, ensure_role_groups
@receiver(post_migrate)
def workflows_post_migrate(sender, **kwargs):
if getattr(sender, 'name', '') != 'workflows':
return
ensure_role_groups()
ensure_bootstrap_role_assignments()

View File

@@ -1,5 +1,6 @@
body { margin: 0; font-family: Arial, sans-serif; background: #f4f8ff; color: #0f172a; padding: 20px; } body { margin: 0; font-family: Arial, sans-serif; background: #f4f8ff; color: #0f172a; padding: 20px; }
[hidden] { display: none !important; } [hidden] { display: none !important; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
.shell { max-width: 1100px; margin: 0 auto; background: #fff; border: 1px solid #d8e3f0; border-radius: 14px; padding: 16px; } .shell { max-width: 1100px; margin: 0 auto; background: #fff; border: 1px solid #d8e3f0; border-radius: 14px; padding: 16px; }
h1 { margin: 12px 0 6px; color: #000078; } h1 { margin: 12px 0 6px; color: #000078; }
.sub { margin: 0 0 12px; color: #54657c; } .sub { margin: 0 0 12px; color: #54657c; }

View File

@@ -52,6 +52,7 @@
<li><code>/backend/config/</code>: Django settings, WSGI, URL config</li> <li><code>/backend/config/</code>: Django settings, WSGI, URL config</li>
<li><code>/backend/workflows/</code>: application logic, views, models, tasks, templates, static assets</li> <li><code>/backend/workflows/</code>: application logic, views, models, tasks, templates, static assets</li>
<li><code>/backend/workflows/templates/workflows/base_shell.html</code>: standard page shell for new staff-facing pages</li> <li><code>/backend/workflows/templates/workflows/base_shell.html</code>: standard page shell for new staff-facing pages</li>
<li><code>/backend/workflows/roles.py</code>: centralized role names, capability matrix, and template permission helpers</li>
<li>Rule: all interactive app pages should extend <code>base_shell.html</code>; do not rebuild topbar/frame logic in page-local templates.</li> <li>Rule: all interactive app pages should extend <code>base_shell.html</code>; do not rebuild topbar/frame logic in page-local templates.</li>
<li><code>/backend/media/templates/</code>: PDF HTML templates and letterhead source files</li> <li><code>/backend/media/templates/</code>: PDF HTML templates and letterhead source files</li>
<li><code>/backend/media/pdfs/</code>: generated PDF outputs on host volume</li> <li><code>/backend/media/pdfs/</code>: generated PDF outputs on host volume</li>
@@ -99,6 +100,19 @@ docker compose exec -T web python manage.py check</code></pre>
<li>Fresh boot sequence runs migrations automatically in <code>entrypoint-web.sh</code>.</li> <li>Fresh boot sequence runs migrations automatically in <code>entrypoint-web.sh</code>.</li>
</ul> </ul>
<h3>Role and Permission Model</h3>
<ul>
<li>Stable Django group names: <code>Super Admin</code>, <code>Admin</code>, <code>IT Staff</code>, <code>Staff</code>.</li>
<li>Groups are created automatically through a <code>post_migrate</code> hook in <code>workflows.signals</code>.</li>
<li>Capability checks are centralized in <code>workflows.roles.CAPABILITIES</code>.</li>
<li>Use <code>_require_capability(...)</code> in views instead of flat <code>is_staff</code> checks.</li>
<li>Templates receive permission flags from <code>workflows.context_processors.role_context</code>.</li>
<li>Super-admin-only user management lives at <code>/admin-tools/users/</code> and is the preferred path for normal role assignment, account activation, password-reset mail dispatch, and controlled user deletion.</li>
<li>Backward-compatibility rule: authenticated legacy users with <code>is_staff=True</code> but no explicit role group currently fall back to the <code>Admin</code> capability set.</li>
<li><code>superuser</code> accounts resolve to <code>Super Admin</code>.</li>
<li>When adding a new operational page or action, define the capability in <code>roles.py</code>, gate the view, and hide the UI affordance when the capability is absent.</li>
</ul>
<h2 id="translations">6) Translation Workflow</h2> <h2 id="translations">6) Translation Workflow</h2>
<h3>Standard Django i18n path</h3> <h3>Standard Django i18n path</h3>
<pre><code>make i18n-update-en <pre><code>make i18n-update-en

View File

@@ -35,7 +35,7 @@
<h1>{% trans "TUBCO Onboarding & Offboarding Portal" %}</h1> <h1>{% trans "TUBCO Onboarding & Offboarding Portal" %}</h1>
<p>{% trans "Zentrale Arbeitsfläche für Anfragen, PDF-Generierung, E-Mail-Workflows und Ablage in Nextcloud." %}</p> <p>{% trans "Zentrale Arbeitsfläche für Anfragen, PDF-Generierung, E-Mail-Workflows und Ablage in Nextcloud." %}</p>
<div class="status-row"> <div class="status-row">
<span class="status-pill status-pill-neutral">{% trans "Rolle:" %} {% if request.user.is_staff %}{% trans "Admin" %}{% else %}{% trans "Mitarbeiter" %}{% endif %}</span> <span class="status-pill status-pill-neutral">{% trans "Rolle:" %} {{ role_label }}</span>
<span class="status-pill {% if nextcloud_enabled %}ok{% else %}warn{% endif %}"> <span class="status-pill {% if nextcloud_enabled %}ok{% else %}warn{% endif %}">
{% trans "Nextcloud:" %} {% if nextcloud_enabled %}{% trans "aktiv" %}{% else %}{% trans "inaktiv" %}{% endif %} {% trans "Nextcloud:" %} {% if nextcloud_enabled %}{% trans "aktiv" %}{% else %}{% trans "inaktiv" %}{% endif %}
</span> </span>
@@ -88,6 +88,7 @@
</div> </div>
</section> </section>
{% if can_access_requests_dashboard %}
<section class="app-card"> <section class="app-card">
<div> <div>
<div class="top-line"><div class="accent">APP</div></div> <div class="top-line"><div class="accent">APP</div></div>
@@ -103,34 +104,51 @@
<a class="btn btn-secondary" href="/requests/">{% trans "Dashboard öffnen" %}</a> <a class="btn btn-secondary" href="/requests/">{% trans "Dashboard öffnen" %}</a>
</div> </div>
</section> </section>
{% endif %}
</div> </div>
{% if request.user.is_staff %} {% if can_manage_users or can_manage_integrations or can_view_audit_log or can_manage_backups or can_manage_welcome_emails or can_manage_builders or can_view_docs or can_access_django_admin_link %}
<div class="section-head"> <div class="section-head">
<h2>{% trans "Admin Apps" %}</h2> <h2>{% trans "Admin Apps" %}</h2>
<p>{% trans "Konfiguration, Tests und Steuerung." %}</p> <p>{% trans "Konfiguration, Tests und Steuerung." %}</p>
</div> </div>
<div class="admin-grid"> <div class="admin-grid">
{% if can_manage_integrations %}
<section class="admin-card"> <section class="admin-card">
<h3>{% trans "Integrationen" %}</h3> <h3>{% trans "Integrationen" %}</h3>
<p>{% trans "Nextcloud- und E-Mail-Setup." %}</p> <p>{% trans "Nextcloud- und E-Mail-Setup." %}</p>
<a class="btn btn-secondary" href="/admin-tools/integrations/?kind=nextcloud">{% trans "Öffnen" %}</a> <a class="btn btn-secondary" href="/admin-tools/integrations/?kind=nextcloud">{% trans "Öffnen" %}</a>
</section> </section>
{% endif %}
{% if can_manage_users %}
<section class="admin-card">
<h3>{% trans "Benutzer & Rollen" %}</h3>
<p>{% trans "Benutzer anlegen, Rollen zuweisen und Zugriffe steuern." %}</p>
<a class="btn btn-secondary" href="/admin-tools/users/">{% trans "Öffnen" %}</a>
</section>
{% endif %}
{% if can_view_audit_log %}
<section class="admin-card"> <section class="admin-card">
<h3>{% trans "Audit Log" %}</h3> <h3>{% trans "Audit Log" %}</h3>
<p>{% trans "Wichtige Admin-Aktionen nachvollziehen und prüfen." %}</p> <p>{% trans "Wichtige Admin-Aktionen nachvollziehen und prüfen." %}</p>
<a class="btn btn-secondary" href="/admin-tools/audit-log/">{% trans "Öffnen" %}</a> <a class="btn btn-secondary" href="/admin-tools/audit-log/">{% trans "Öffnen" %}</a>
</section> </section>
{% endif %}
{% if can_manage_backups %}
<section class="admin-card"> <section class="admin-card">
<h3>{% trans "Backup & Recovery" %}</h3> <h3>{% trans "Backup & Recovery" %}</h3>
<p>{% trans "Backups erstellen und sicher verifizieren." %}</p> <p>{% trans "Backups erstellen und sicher verifizieren." %}</p>
<a class="btn btn-secondary" href="/admin-tools/backups/">{% trans "Öffnen" %}</a> <a class="btn btn-secondary" href="/admin-tools/backups/">{% trans "Öffnen" %}</a>
</section> </section>
{% endif %}
{% if can_manage_welcome_emails %}
<section class="admin-card"> <section class="admin-card">
<h3>{% trans "Welcome E-Mails" %}</h3> <h3>{% trans "Welcome E-Mails" %}</h3>
<p>{% trans "Geplante Welcome Mails verwalten." %}</p> <p>{% trans "Geplante Welcome Mails verwalten." %}</p>
<a class="btn btn-secondary" href="/admin-tools/welcome-emails/">{% trans "Öffnen" %}</a> <a class="btn btn-secondary" href="/admin-tools/welcome-emails/">{% trans "Öffnen" %}</a>
</section> </section>
{% endif %}
{% if can_manage_builders %}
<section class="admin-card"> <section class="admin-card">
<h3>{% trans "Form Builder" %}</h3> <h3>{% trans "Form Builder" %}</h3>
<p>{% trans "Felder, Schritte und Optionen verwalten." %}</p> <p>{% trans "Felder, Schritte und Optionen verwalten." %}</p>
@@ -141,16 +159,21 @@
<p>{% trans "Checklistenpunkte für das Einweisungsprotokoll konfigurieren." %}</p> <p>{% trans "Checklistenpunkte für das Einweisungsprotokoll konfigurieren." %}</p>
<a class="btn btn-secondary" href="/admin-tools/intro-builder/">{% trans "Öffnen" %}</a> <a class="btn btn-secondary" href="/admin-tools/intro-builder/">{% trans "Öffnen" %}</a>
</section> </section>
{% endif %}
{% if can_view_docs %}
<section class="admin-card"> <section class="admin-card">
<h3>{% trans "Handbook" %}</h3> <h3>{% trans "Handbook" %}</h3>
<p>{% trans "Project wiki and developer documentation in one place." %}</p> <p>{% trans "Project wiki and developer documentation in one place." %}</p>
<a class="btn btn-secondary" href="/admin-tools/handbook/">{% trans "Öffnen" %}</a> <a class="btn btn-secondary" href="/admin-tools/handbook/">{% trans "Öffnen" %}</a>
</section> </section>
{% endif %}
{% if can_access_django_admin_link %}
<section class="admin-card"> <section class="admin-card">
<h3>{% trans "Django Admin" %}</h3> <h3>{% trans "Django Admin" %}</h3>
<p>{% trans "Vollständige Datenverwaltung." %}</p> <p>{% trans "Vollständige Datenverwaltung." %}</p>
<a class="btn btn-secondary" href="/admin/">{% trans "Öffnen" %}</a> <a class="btn btn-secondary" href="/admin/">{% trans "Öffnen" %}</a>
</section> </section>
{% endif %}
</div> </div>
{% endif %} {% endif %}

View File

@@ -172,9 +172,13 @@
<h2 id="admin">9) Admin Apps (Home)</h2> <h2 id="admin">9) Admin Apps (Home)</h2>
<ul> <ul>
<li><strong>Rollenmodell:</strong> the app now uses four named roles: <code>Super Admin</code>, <code>Admin</code>, <code>IT Staff</code>, and <code>Staff</code>.</li>
<li><strong>Zugriffslogik:</strong> page visibility and critical actions are controlled by capability checks, not only by <code>is_staff</code>.</li>
<li><strong>Fallback-Verhalten:</strong> legacy staff users without an explicit role group currently fall back to <code>Admin</code> access so existing operations do not break during rollout.</li>
<li><strong>Form Builder:</strong> manage field visibility/order/options.</li> <li><strong>Form Builder:</strong> manage field visibility/order/options.</li>
<li><strong>Einweisungs-Builder:</strong> manage custom checklist items for the intro PDF and live introduction checklist, including section, visibility, and conditional display logic.</li> <li><strong>Einweisungs-Builder:</strong> manage custom checklist items for the intro PDF and live introduction checklist, including section, visibility, and conditional display logic.</li>
<li><strong>Integrations:</strong> Nextcloud, SMTP, default routing addresses, notification rules, workflow rules, and remote backup target settings.</li> <li><strong>Integrations:</strong> Nextcloud, SMTP, default routing addresses, notification rules, workflow rules, and remote backup target settings.</li>
<li><strong>Benutzer &amp; Rollen:</strong> super-admin-only page for creating users, assigning roles, activating/deactivating access, sending password-reset links, and deleting accounts when appropriate.</li>
<li><strong>Welcome Emails:</strong> scheduled jobs, pause/resume/cancel/trigger now.</li> <li><strong>Welcome Emails:</strong> scheduled jobs, pause/resume/cancel/trigger now.</li>
<li><strong>Audit Log:</strong> staff-only trace of important admin changes such as builder edits, settings updates, PDF generation, welcome-email operations, and request deletions. Supports filtering by action, user, and date range.</li> <li><strong>Audit Log:</strong> staff-only trace of important admin changes such as builder edits, settings updates, PDF generation, welcome-email operations, and request deletions. Supports filtering by action, user, and date range.</li>
<li><strong>Requests Dashboard:</strong> search records, open PDFs, delete records (single/bulk for staff).</li> <li><strong>Requests Dashboard:</strong> search records, open PDFs, delete records (single/bulk for staff).</li>
@@ -223,6 +227,7 @@
<h2 id="hardening">11) Security & Reliability Hardening (Current)</h2> <h2 id="hardening">11) Security & Reliability Hardening (Current)</h2>
<ul> <ul>
<li><strong>Rollen &amp; Berechtigungen:</strong> operational areas such as Integrationen, Builders, Audit Log, Backup &amp; Recovery, request delete/retry, and intro-session actions are now protected by capability-based role checks.</li>
<li><strong>Cookie + header hardening:</strong> HTTPOnly cookies, SameSite cookies, <code>X-Content-Type-Options: nosniff</code>, stricter referrer policy, and frame protection.</li> <li><strong>Cookie + header hardening:</strong> HTTPOnly cookies, SameSite cookies, <code>X-Content-Type-Options: nosniff</code>, stricter referrer policy, and frame protection.</li>
<li><strong>Optional secure-cookie mode:</strong> can be enabled via environment for HTTPS deployments.</li> <li><strong>Optional secure-cookie mode:</strong> can be enabled via environment for HTTPS deployments.</li>
<li><strong>Upload guards:</strong> server-side upload size limits plus signature image magic-byte validation for PNG/JPEG.</li> <li><strong>Upload guards:</strong> server-side upload size limits plus signature image magic-byte validation for PNG/JPEG.</li>

View File

@@ -167,7 +167,7 @@
</div> </div>
</form> </form>
</div> </div>
{% if request.user.is_staff %} {% if can_delete_requests %}
<div class="control-stack"> <div class="control-stack">
<form method="post" action="/requests/" id="bulk-delete-form" data-confirm="{% trans 'Ausgewählte Einträge wirklich löschen?' %}"> <form method="post" action="/requests/" id="bulk-delete-form" data-confirm="{% trans 'Ausgewählte Einträge wirklich löschen?' %}">
{% csrf_token %} {% csrf_token %}
@@ -184,19 +184,19 @@
<table> <table>
<thead> <thead>
<tr> <tr>
{% if request.user.is_staff %}<th class="select-col"><input type="checkbox" id="select-all" aria-label="Alle auswählen" /></th>{% endif %} {% if can_delete_requests %}<th class="select-col"><input type="checkbox" id="select-all" aria-label="Alle auswählen" /></th>{% endif %}
<th>{% trans "Typ" %}</th> <th>{% trans "Typ" %}</th>
<th>{% trans "Person" %}</th> <th>{% trans "Person" %}</th>
<th>{% trans "E-Mail" %}</th> <th>{% trans "E-Mail" %}</th>
<th>{% trans "Dokument" %}</th> <th>{% trans "Dokument" %}</th>
{% if request.user.is_staff %}<th>{% trans "Einweisung" %}</th>{% endif %} {% if can_run_intro_session or can_generate_intro_pdfs %}<th>{% trans "Einweisung" %}</th>{% endif %}
{% if request.user.is_staff %}<th>{% trans "Aktion" %}</th>{% endif %} {% if can_retry_requests or can_delete_requests or can_access_requests_dashboard %}<th>{% trans "Aktion" %}</th>{% endif %}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for row in rows %} {% for row in rows %}
<tr> <tr>
{% if request.user.is_staff %} {% if can_delete_requests %}
<td class="select-col"> <td class="select-col">
<input type="checkbox" class="row-select" name="selected_requests" value="{{ row.kind_slug }}:{{ row.id }}" aria-label="{{ row.kind }} {{ row.name }}" form="bulk-delete-form" /> <input type="checkbox" class="row-select" name="selected_requests" value="{{ row.kind_slug }}:{{ row.id }}" aria-label="{{ row.kind }} {{ row.name }}" form="bulk-delete-form" />
</td> </td>
@@ -225,7 +225,7 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
</td> </td>
{% if request.user.is_staff %} {% if can_run_intro_session or can_generate_intro_pdfs %}
<td class="actions-cell intro-panel"> <td class="actions-cell intro-panel">
{% if row.kind_slug == 'onboarding' %} {% if row.kind_slug == 'onboarding' %}
<details> <details>
@@ -234,12 +234,15 @@
<div class="intro-group"> <div class="intro-group">
<div class="intro-group-title">{% trans "Live-Protokoll" %}</div> <div class="intro-group-title">{% trans "Live-Protokoll" %}</div>
<div class="intro-actions"> <div class="intro-actions">
{% if can_run_intro_session %}
<a class="btn btn-secondary" href="/requests/onboarding/{{ row.id }}/intro-session/">{% trans "Einweisung öffnen" %}</a> <a class="btn btn-secondary" href="/requests/onboarding/{{ row.id }}/intro-session/">{% trans "Einweisung öffnen" %}</a>
{% if row.intro_session and row.intro_session.exported_pdf_url %} {% endif %}
{% if can_run_intro_session and row.intro_session and row.intro_session.exported_pdf_url %}
<a class="btn btn-secondary" href="{{ row.intro_session.exported_pdf_url }}" target="_blank" rel="noopener">{% trans "Live-Protokoll öffnen" %}</a> <a class="btn btn-secondary" href="{{ row.intro_session.exported_pdf_url }}" target="_blank" rel="noopener">{% trans "Live-Protokoll öffnen" %}</a>
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% if can_generate_intro_pdfs %}
<div class="intro-group"> <div class="intro-group">
<div class="intro-group-title">{% trans "Standard-Einweisungs-PDF" %}</div> <div class="intro-group-title">{% trans "Standard-Einweisungs-PDF" %}</div>
<div class="intro-actions"> <div class="intro-actions">
@@ -257,6 +260,7 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% endif %}
</div> </div>
{% if row.intro_session %} {% if row.intro_session %}
<div class="intro-meta">{% trans "Status:" %} {{ row.intro_session.get_status_display }}</div> <div class="intro-meta">{% trans "Status:" %} {{ row.intro_session.get_status_display }}</div>
@@ -266,24 +270,28 @@
<span class="person-meta">{% trans "Nicht relevant" %}</span> <span class="person-meta">{% trans "Nicht relevant" %}</span>
{% endif %} {% endif %}
</td> </td>
{% endif %}
{% if can_retry_requests or can_delete_requests or can_access_requests_dashboard %}
<td class="actions-cell"> <td class="actions-cell">
<a class="btn btn-secondary" href="/requests/timeline/{{ row.kind_slug }}/{{ row.id }}/">{% trans "Timeline" %}</a> <a class="btn btn-secondary" href="/requests/timeline/{{ row.kind_slug }}/{{ row.id }}/">{% trans "Timeline" %}</a>
{% if row.status_key == 'failed' %} {% if can_retry_requests and row.status_key == 'failed' %}
<form method="post" action="/requests/retry/{{ row.kind_slug }}/{{ row.id }}/" class="inline-delete" data-confirm="{% trans 'Eintrag erneut verarbeiten?' %}"> <form method="post" action="/requests/retry/{{ row.kind_slug }}/{{ row.id }}/" class="inline-delete" data-confirm="{% trans 'Eintrag erneut verarbeiten?' %}">
{% csrf_token %} {% csrf_token %}
<button class="btn btn-secondary" type="submit">{% trans "Erneut versuchen" %}</button> <button class="btn btn-secondary" type="submit">{% trans "Erneut versuchen" %}</button>
</form> </form>
{% endif %} {% endif %}
{% if can_delete_requests %}
<form method="post" action="/requests/" class="inline-delete" data-confirm="{% trans 'Eintrag wirklich löschen?' %}"> <form method="post" action="/requests/" class="inline-delete" data-confirm="{% trans 'Eintrag wirklich löschen?' %}">
{% csrf_token %} {% csrf_token %}
<button class="btn btn-secondary" type="submit" name="single_delete" value="{{ row.kind_slug }}:{{ row.id }}">{% trans "Löschen" %}</button> <button class="btn btn-secondary" type="submit" name="single_delete" value="{{ row.kind_slug }}:{{ row.id }}">{% trans "Löschen" %}</button>
</form> </form>
{% endif %}
</td> </td>
{% endif %} {% endif %}
</tr> </tr>
{% empty %} {% empty %}
<tr> <tr>
<td colspan="{% if request.user.is_staff %}8{% else %}5{% endif %}" class="empty-state">{% trans "Noch keine Vorgänge vorhanden." %}</td> <td colspan="{{ column_count }}" class="empty-state">{% trans "Noch keine Vorgänge vorhanden." %}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View File

@@ -0,0 +1,151 @@
{% extends 'workflows/base_shell.html' %}
{% load static i18n %}
{% block title %}{% trans "Benutzer & Rollen" %}{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'workflows/css/admin_tools.css' %}" />
{% endblock %}
{% block shell_body %}
{% include 'workflows/includes/app_header.html' with header_show_lang=1 header_show_home=1 header_inside_shell=1 %}
<div class="toolbar">
<div>
<h1>{% trans "Benutzer & Rollen" %}</h1>
<p class="sub">{% trans "Super Admins verwalten Benutzerkonten, Rollen und den aktiven Zugriff." %}</p>
</div>
</div>
{% include 'workflows/includes/messages.html' %}
<section class="card">
<h2>{% trans "Benutzer anlegen" %}</h2>
<form method="post" action="{% url 'create_user_from_admin' %}">
{% csrf_token %}
<div class="grid">
<div>
<label for="{{ create_form.first_name.id_for_label }}">{{ create_form.first_name.label }}</label>
{{ create_form.first_name }}
{% for error in create_form.first_name.errors %}<div class="hint">{{ error }}</div>{% endfor %}
</div>
<div>
<label for="{{ create_form.last_name.id_for_label }}">{{ create_form.last_name.label }}</label>
{{ create_form.last_name }}
{% for error in create_form.last_name.errors %}<div class="hint">{{ error }}</div>{% endfor %}
</div>
<div>
<label for="{{ create_form.username.id_for_label }}">{{ create_form.username.label }}</label>
{{ create_form.username }}
{% for error in create_form.username.errors %}<div class="hint">{{ error }}</div>{% endfor %}
</div>
<div>
<label for="{{ create_form.email.id_for_label }}">{{ create_form.email.label }}</label>
{{ create_form.email }}
{% for error in create_form.email.errors %}<div class="hint">{{ error }}</div>{% endfor %}
</div>
<div>
<label for="{{ create_form.role_key.id_for_label }}">{{ create_form.role_key.label }}</label>
{{ create_form.role_key }}
{% for error in create_form.role_key.errors %}<div class="hint">{{ error }}</div>{% endfor %}
</div>
<div>
<label for="{{ create_form.password1.id_for_label }}">{{ create_form.password1.label }}</label>
{{ create_form.password1 }}
{% for error in create_form.password1.errors %}<div class="hint">{{ error }}</div>{% endfor %}
</div>
<div>
<label for="{{ create_form.password2.id_for_label }}">{{ create_form.password2.label }}</label>
{{ create_form.password2 }}
{% for error in create_form.password2.errors %}<div class="hint">{{ error }}</div>{% endfor %}
</div>
</div>
{% for error in create_form.non_field_errors %}<div class="hint">{{ error }}</div>{% endfor %}
<div class="actions">
<button class="btn btn-primary" type="submit">{% trans "Benutzer erstellen" %}</button>
</div>
</form>
</section>
<section class="card">
<div class="toolbar">
<div>
<h2>{% trans "Benutzerübersicht" %}</h2>
<p class="sub">{% trans "Rollen ändern, Zugriffe sperren oder ein neues Passwort setzen." %}</p>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Benutzername" %}</th>
<th>{% trans "E-Mail" %}</th>
<th>{% trans "Rolle" %}</th>
<th>{% trans "Aktiv" %}</th>
<th>{% trans "Letzte Anmeldung" %}</th>
<th>{% trans "Neues Passwort" %}</th>
<th>{% trans "Aktionen" %}</th>
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr>
<td>
<strong>{{ row.display_name|default:row.user.username }}</strong>
{% if row.user == request.user %}
<div class="mini">{% trans "Sie selbst" %}</div>
{% endif %}
</td>
<td>{{ row.user.username }}</td>
<td>{{ row.user.email|default:"-" }}</td>
<td>
<label class="sr-only" for="role_key_{{ row.user.id }}">{% trans "Rolle" %}</label>
<select id="role_key_{{ row.user.id }}" name="role_key" form="user-form-{{ row.user.id }}">
{% for role_key, role_label in role_choices %}
<option value="{{ role_key }}"{% if role_key == row.role_key %} selected{% endif %}>{{ role_label }}</option>
{% endfor %}
</select>
</td>
<td>
<label class="check-row" for="is_active_{{ row.user.id }}">
<input id="is_active_{{ row.user.id }}" type="checkbox" name="is_active" form="user-form-{{ row.user.id }}"{% if row.user.is_active %} checked{% endif %} />
<span>{% if row.user.is_active %}{% trans "aktiv" %}{% else %}{% trans "inaktiv" %}{% endif %}</span>
</label>
</td>
<td>{{ row.user.last_login|date:"d.m.Y H:i"|default:"-" }}</td>
<td>
<label class="sr-only" for="new_password_{{ row.user.id }}">{% trans "Neues Passwort" %}</label>
<input id="new_password_{{ row.user.id }}" type="password" name="new_password" form="user-form-{{ row.user.id }}" placeholder="{% trans 'Optional' %}" />
</td>
<td class="actions">
<form id="user-form-{{ row.user.id }}" method="post" action="{% url 'update_user_from_admin' row.user.id %}">
{% csrf_token %}
</form>
<button class="btn btn-secondary" type="submit" form="user-form-{{ row.user.id }}">{% trans "Speichern" %}</button>
{% if row.user.email %}
<form method="post" action="{% url 'send_password_reset_from_admin' row.user.id %}">
{% csrf_token %}
<button class="btn btn-secondary" type="submit">{% trans "Reset-Link senden" %}</button>
</form>
{% else %}
<span class="mini">{% trans "Keine E-Mail" %}</span>
{% endif %}
{% if row.user != request.user %}
<form method="post" action="{% url 'delete_user_from_admin' row.user.id %}">
{% csrf_token %}
<button class="btn btn-secondary" type="submit" data-confirm="1" data-confirm-message="{% trans 'Benutzer wirklich löschen?' %}">{% trans "Löschen" %}</button>
</form>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="8">{% trans "Es sind noch keine Benutzer vorhanden." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<p class="hint">{% trans "Hinweis: Der aktuell angemeldete Super Admin kann sich hier nicht selbst deaktivieren oder auf eine niedrigere Rolle setzen." %}</p>
</section>
{% endblock %}

View File

@@ -30,6 +30,11 @@ urlpatterns = [
path('admin-tools/welcome-emails/<int:schedule_id>/resume/', views.resume_welcome_email, name='resume_welcome_email'), path('admin-tools/welcome-emails/<int:schedule_id>/resume/', views.resume_welcome_email, name='resume_welcome_email'),
path('admin-tools/welcome-emails/<int:schedule_id>/cancel/', views.cancel_welcome_email, name='cancel_welcome_email'), path('admin-tools/welcome-emails/<int:schedule_id>/cancel/', views.cancel_welcome_email, name='cancel_welcome_email'),
path('admin-tools/handbook/', views.handbook_page, name='handbook_page'), path('admin-tools/handbook/', views.handbook_page, name='handbook_page'),
path('admin-tools/users/', views.user_management_page, name='user_management_page'),
path('admin-tools/users/create/', views.create_user_from_admin, name='create_user_from_admin'),
path('admin-tools/users/<int:user_id>/update/', views.update_user_from_admin, name='update_user_from_admin'),
path('admin-tools/users/<int:user_id>/send-password-reset/', views.send_password_reset_from_admin, name='send_password_reset_from_admin'),
path('admin-tools/users/<int:user_id>/delete/', views.delete_user_from_admin, name='delete_user_from_admin'),
path('admin-tools/wiki/', views.project_wiki_page, name='project_wiki_page'), path('admin-tools/wiki/', views.project_wiki_page, name='project_wiki_page'),
path('admin-tools/developer-handbook/', views.developer_handbook_page, name='developer_handbook_page'), path('admin-tools/developer-handbook/', views.developer_handbook_page, name='developer_handbook_page'),
path('admin-tools/release-checklist/', views.release_checklist_page, name='release_checklist_page'), path('admin-tools/release-checklist/', views.release_checklist_page, name='release_checklist_page'),

View File

@@ -2,6 +2,7 @@ from pathlib import Path
from datetime import timedelta from datetime import timedelta
from tempfile import NamedTemporaryFile from tempfile import NamedTemporaryFile
import json import json
from functools import wraps
from celery import current_app from celery import current_app
from django.conf import settings from django.conf import settings
@@ -10,16 +11,21 @@ from django.db import IntegrityError
from django.db.models import Q from django.db.models import Q
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import login_required, user_passes_test from django.contrib.auth import get_user_model
from django.contrib.auth.decorators import login_required
from django.contrib.auth.tokens import default_token_generator
from django.http import JsonResponse from django.http import JsonResponse
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
from django.views.decorators.csrf import ensure_csrf_cookie from django.views.decorators.csrf import ensure_csrf_cookie
from django.utils import timezone from django.utils import timezone
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode
from django.utils.translation import gettext as _, gettext_lazy from django.utils.translation import gettext as _, gettext_lazy
from django.utils.translation import get_language, override from django.utils.translation import get_language, override
from django.urls import reverse
from .backup_ops import create_backup_bundle, delete_backup_bundle, list_backup_bundles, verify_backup_bundle from .backup_ops import create_backup_bundle, delete_backup_bundle, list_backup_bundles, verify_backup_bundle
from .forms import OffboardingRequestForm, OnboardingRequestForm from .forms import OffboardingRequestForm, OnboardingRequestForm, UserManagementCreateForm
from .form_builder import ( from .form_builder import (
DEFAULT_FIELD_ORDER, DEFAULT_FIELD_ORDER,
LOCKED_FIELD_RULES, LOCKED_FIELD_RULES,
@@ -30,6 +36,7 @@ from .form_builder import (
) )
from .models import AdminAuditLog, EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, ScheduledWelcomeEmail, SystemEmailConfig, WorkflowConfig from .models import AdminAuditLog, EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, ScheduledWelcomeEmail, SystemEmailConfig, WorkflowConfig
from .emailing import send_system_email from .emailing import send_system_email
from .roles import ROLE_GROUP_NAMES, ROLE_LABELS, ROLE_SUPER_ADMIN, assign_user_role, get_user_role_key, get_user_role_label, user_has_capability
from .services import get_email_test_redirect, is_email_test_mode, is_nextcloud_enabled, upload_to_nextcloud from .services import get_email_test_redirect, is_email_test_mode, is_nextcloud_enabled, upload_to_nextcloud
from .tasks import ( from .tasks import (
DEFAULT_NOTIFICATION_TEMPLATES, DEFAULT_NOTIFICATION_TEMPLATES,
@@ -112,8 +119,19 @@ def healthz(request):
) )
def _is_staff(user) -> bool: def _require_capability(capability: str):
return user.is_authenticated and user.is_staff def decorator(view_func):
@wraps(view_func)
@login_required
def wrapped(request, *args, **kwargs):
if not user_has_capability(request.user, capability):
messages.error(request, _('Sie haben keine Berechtigung für diese Aktion.'))
return redirect('home')
return view_func(request, *args, **kwargs)
return wrapped
return decorator
def _display_user_name(user) -> str: def _display_user_name(user) -> str:
@@ -218,6 +236,10 @@ def _audit_action_label(action: str) -> str:
'mail_settings_saved': _('Mail-Einstellungen gespeichert'), 'mail_settings_saved': _('Mail-Einstellungen gespeichert'),
'email_routing_saved': _('E-Mail-Routing gespeichert'), 'email_routing_saved': _('E-Mail-Routing gespeichert'),
'notification_rules_saved': _('Benachrichtigungsregeln gespeichert'), 'notification_rules_saved': _('Benachrichtigungsregeln gespeichert'),
'user_created': _('Benutzer erstellt'),
'user_updated': _('Benutzer aktualisiert'),
'user_password_reset_sent': _('Passwort-Reset-Link versendet'),
'user_deleted': _('Benutzer gelöscht'),
'backup_created': _('Backup erstellt'), 'backup_created': _('Backup erstellt'),
'backup_verified': _('Backup verifiziert'), 'backup_verified': _('Backup verifiziert'),
'backup_deleted': _('Backup gelöscht'), 'backup_deleted': _('Backup gelöscht'),
@@ -311,36 +333,220 @@ def home(request):
'nextcloud_enabled': is_nextcloud_enabled(), 'nextcloud_enabled': is_nextcloud_enabled(),
'email_test_mode': is_email_test_mode(), 'email_test_mode': is_email_test_mode(),
'workflow_config': config, 'workflow_config': config,
'role_label': get_user_role_label(request.user),
}, },
) )
@login_required def _user_management_rows():
@user_passes_test(_is_staff) user_model = get_user_model()
role_order = {
ROLE_SUPER_ADMIN: 0,
'admin': 1,
'it_staff': 2,
'staff': 3,
}
rows = []
for user in user_model.objects.all().order_by('-is_active', 'username'):
role_key = get_user_role_key(user)
rows.append(
{
'user': user,
'role_key': role_key,
'role_label': str(ROLE_LABELS[role_key]),
'role_sort': role_order.get(role_key, 99),
'display_name': _display_user_name(user),
}
)
rows.sort(key=lambda item: (not item['user'].is_active, item['role_sort'], item['user'].username.lower()))
return rows
def _render_user_management(request, create_form=None, status_code: int = 200):
return render(
request,
'workflows/user_management.html',
{
'create_form': create_form or UserManagementCreateForm(),
'rows': _user_management_rows(),
'role_choices': [(key, str(ROLE_LABELS[key])) for key in ROLE_GROUP_NAMES],
},
status=status_code,
)
def _super_admin_user_count() -> int:
user_model = get_user_model()
return sum(1 for user in user_model.objects.all() if get_user_role_key(user) == ROLE_SUPER_ADMIN and user.is_active)
def _would_remove_last_super_admin(user, new_role_key: str | None = None, new_is_active: bool | None = None, deleting: bool = False) -> bool:
if get_user_role_key(user) != ROLE_SUPER_ADMIN or not user.is_active:
return False
if _super_admin_user_count() > 1:
return False
if deleting:
return True
if new_role_key is not None and new_role_key != ROLE_SUPER_ADMIN:
return True
if new_is_active is not None and not new_is_active:
return True
return False
@_require_capability('manage_users')
def user_management_page(request):
return _render_user_management(request)
@_require_capability('manage_users')
@require_POST
def create_user_from_admin(request):
form = UserManagementCreateForm(request.POST)
if not form.is_valid():
messages.error(request, _('Benutzer konnte nicht erstellt werden. Bitte prüfen Sie die Eingaben.'))
return _render_user_management(request, create_form=form, status_code=400)
user = form.save()
_audit(
request,
'user_created',
target_type='user',
target_id=user.id,
target_label=_display_user_name(user),
details={'username': user.username, 'role': get_user_role_key(user)},
)
messages.success(request, _('Benutzer wurde erstellt: %(username)s') % {'username': user.username})
return redirect('user_management_page')
@_require_capability('manage_users')
@require_POST
def update_user_from_admin(request, user_id: int):
user_model = get_user_model()
target_user = get_object_or_404(user_model, id=user_id)
role_key = (request.POST.get('role_key') or '').strip()
is_active = request.POST.get('is_active') == 'on'
new_password = (request.POST.get('new_password') or '').strip()
if role_key not in ROLE_GROUP_NAMES:
messages.error(request, _('Ungültige Rolle.'))
return redirect('user_management_page')
if target_user == request.user and (role_key != ROLE_SUPER_ADMIN or not is_active):
messages.error(request, _('Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren oder herabstufen.'))
return redirect('user_management_page')
if _would_remove_last_super_admin(target_user, new_role_key=role_key, new_is_active=is_active):
messages.error(request, _('Der letzte aktive Super Admin kann nicht deaktiviert oder herabgestuft werden.'))
return redirect('user_management_page')
assign_user_role(target_user, role_key)
target_user.is_active = is_active
if new_password:
target_user.set_password(new_password)
target_user.save()
_audit(
request,
'user_updated',
target_type='user',
target_id=target_user.id,
target_label=_display_user_name(target_user),
details={'username': target_user.username, 'role': role_key, 'is_active': is_active, 'password_changed': bool(new_password)},
)
messages.success(request, _('Benutzer wurde aktualisiert: %(username)s') % {'username': target_user.username})
return redirect('user_management_page')
@_require_capability('manage_users')
@require_POST
def send_password_reset_from_admin(request, user_id: int):
user_model = get_user_model()
target_user = get_object_or_404(user_model, id=user_id)
email = (target_user.email or '').strip()
if not email:
messages.error(request, _('Für diesen Benutzer ist keine E-Mail-Adresse hinterlegt.'))
return redirect('user_management_page')
uid = urlsafe_base64_encode(force_bytes(target_user.pk))
token = default_token_generator.make_token(target_user)
reset_path = reverse('password_reset_confirm', kwargs={'uidb64': uid, 'token': token})
reset_url = request.build_absolute_uri(reset_path)
send_system_email(
subject=_('Passwort zurücksetzen für %(username)s') % {'username': target_user.username},
body=_(
'Hallo %(name)s,\n\n'
'für Ihr Konto wurde ein Link zum Zurücksetzen des Passworts erstellt.\n'
'Bitte öffnen Sie den folgenden Link:\n'
'%(url)s\n\n'
'Wenn Sie diese Anfrage nicht erwartet haben, können Sie diese E-Mail ignorieren.'
) % {
'name': _display_user_name(target_user),
'url': reset_url,
},
to=[email],
)
_audit(
request,
'user_password_reset_sent',
target_type='user',
target_id=target_user.id,
target_label=_display_user_name(target_user),
details={'username': target_user.username, 'email': email},
)
messages.success(request, _('Passwort-Reset-Link wurde versendet: %(username)s') % {'username': target_user.username})
return redirect('user_management_page')
@_require_capability('manage_users')
@require_POST
def delete_user_from_admin(request, user_id: int):
user_model = get_user_model()
target_user = get_object_or_404(user_model, id=user_id)
if target_user == request.user:
messages.error(request, _('Der aktuell angemeldete Super Admin kann sich hier nicht selbst löschen.'))
return redirect('user_management_page')
if _would_remove_last_super_admin(target_user, deleting=True):
messages.error(request, _('Der letzte aktive Super Admin kann nicht gelöscht werden.'))
return redirect('user_management_page')
target_label = _display_user_name(target_user)
username = target_user.username
target_user.delete()
_audit(
request,
'user_deleted',
target_type='user',
target_label=target_label,
details={'username': username},
)
messages.success(request, _('Benutzer wurde gelöscht: %(username)s') % {'username': username})
return redirect('user_management_page')
@_require_capability('view_docs')
def handbook_page(request): def handbook_page(request):
return render(request, 'workflows/handbook.html') return render(request, 'workflows/handbook.html')
@login_required @_require_capability('view_docs')
@user_passes_test(_is_staff)
def project_wiki_page(request): def project_wiki_page(request):
return render(request, 'workflows/project_wiki.html') return render(request, 'workflows/project_wiki.html')
@login_required @_require_capability('view_docs')
@user_passes_test(_is_staff)
def developer_handbook_page(request): def developer_handbook_page(request):
return render(request, 'workflows/developer_handbook.html') return render(request, 'workflows/developer_handbook.html')
@login_required @_require_capability('view_docs')
@user_passes_test(_is_staff)
def release_checklist_page(request): def release_checklist_page(request):
return render(request, 'workflows/release_checklist.html') return render(request, 'workflows/release_checklist.html')
@login_required @_require_capability('view_audit_log')
@user_passes_test(_is_staff)
def audit_log_page(request): def audit_log_page(request):
action = (request.GET.get('action') or '').strip() action = (request.GET.get('action') or '').strip()
user_query = (request.GET.get('user') or '').strip() user_query = (request.GET.get('user') or '').strip()
@@ -380,8 +586,7 @@ def audit_log_page(request):
) )
@login_required @_require_capability('manage_backups')
@user_passes_test(_is_staff)
def backup_recovery_page(request): def backup_recovery_page(request):
return render( return render(
request, request,
@@ -392,8 +597,7 @@ def backup_recovery_page(request):
) )
@login_required @_require_capability('manage_backups')
@user_passes_test(_is_staff)
@require_POST @require_POST
def create_backup_from_admin(request): def create_backup_from_admin(request):
try: try:
@@ -411,8 +615,7 @@ def create_backup_from_admin(request):
return redirect('backup_recovery_page') return redirect('backup_recovery_page')
@login_required @_require_capability('manage_backups')
@user_passes_test(_is_staff)
@require_POST @require_POST
def verify_backup_from_admin(request, backup_name: str): def verify_backup_from_admin(request, backup_name: str):
try: try:
@@ -430,8 +633,7 @@ def verify_backup_from_admin(request, backup_name: str):
return redirect('backup_recovery_page') return redirect('backup_recovery_page')
@login_required @_require_capability('manage_backups')
@user_passes_test(_is_staff)
@require_POST @require_POST
def delete_backup_from_admin(request, backup_name: str): def delete_backup_from_admin(request, backup_name: str):
try: try:
@@ -449,8 +651,7 @@ def delete_backup_from_admin(request, backup_name: str):
return redirect('backup_recovery_page') return redirect('backup_recovery_page')
@login_required @_require_capability('access_requests_dashboard')
@user_passes_test(_is_staff)
def request_timeline_page(request, kind: str, request_id: int): def request_timeline_page(request, kind: str, request_id: int):
if kind == 'onboarding': if kind == 'onboarding':
obj = get_object_or_404(OnboardingRequest, id=request_id) obj = get_object_or_404(OnboardingRequest, id=request_id)
@@ -569,8 +770,12 @@ def request_timeline_page(request, kind: str, request_id: int):
@login_required @login_required
def requests_dashboard(request): def requests_dashboard(request):
if not user_has_capability(request.user, 'access_requests_dashboard'):
messages.error(request, _('Sie haben keine Berechtigung für diese Aktion.'))
return redirect('home')
if request.method == 'POST': if request.method == 'POST':
if not request.user.is_staff: if not user_has_capability(request.user, 'delete_requests'):
messages.error(request, _('Sie haben keine Berechtigung für diese Aktion.')) messages.error(request, _('Sie haben keine Berechtigung für diese Aktion.'))
return redirect('requests_dashboard') return redirect('requests_dashboard')
@@ -765,6 +970,13 @@ def requests_dashboard(request):
{'value': 'failed', 'label': _request_status_label('failed', language_code)}, {'value': 'failed', 'label': _request_status_label('failed', language_code)},
] ]
has_filters = any([search_query, type_filter, status_filter, department_filter, date_from, date_to]) has_filters = any([search_query, type_filter, status_filter, department_filter, date_from, date_to])
column_count = 4
if user_has_capability(request.user, 'delete_requests'):
column_count += 1
if user_has_capability(request.user, 'run_intro_session') or user_has_capability(request.user, 'generate_intro_pdfs'):
column_count += 1
if user_has_capability(request.user, 'access_requests_dashboard'):
column_count += 1
return render( return render(
request, request,
'workflows/requests_dashboard.html', 'workflows/requests_dashboard.html',
@@ -779,6 +991,7 @@ def requests_dashboard(request):
'departments': departments, 'departments': departments,
'status_choices': status_choices, 'status_choices': status_choices,
'has_filters': has_filters, 'has_filters': has_filters,
'column_count': column_count,
'onboarding_total': onboarding_total, 'onboarding_total': onboarding_total,
'offboarding_total': offboarding_total, 'offboarding_total': offboarding_total,
'combined_total': onboarding_total + offboarding_total, 'combined_total': onboarding_total + offboarding_total,
@@ -838,8 +1051,7 @@ def onboarding_success(request, request_id: int):
return render(request, 'workflows/onboarding_success.html', {'obj': obj, 'pdf_url': pdf_url}) return render(request, 'workflows/onboarding_success.html', {'obj': obj, 'pdf_url': pdf_url})
@login_required @_require_capability('generate_intro_pdfs')
@user_passes_test(_is_staff)
@require_POST @require_POST
def generate_onboarding_intro_pdf(request, request_id: int): def generate_onboarding_intro_pdf(request, request_id: int):
obj = get_object_or_404(OnboardingRequest, id=request_id) obj = get_object_or_404(OnboardingRequest, id=request_id)
@@ -851,8 +1063,7 @@ def generate_onboarding_intro_pdf(request, request_id: int):
return redirect('requests_dashboard') return redirect('requests_dashboard')
@login_required @_require_capability('generate_intro_pdfs')
@user_passes_test(_is_staff)
@require_POST @require_POST
def generate_onboarding_intro_session_pdf(request, request_id: int): def generate_onboarding_intro_session_pdf(request, request_id: int):
onboarding = get_object_or_404(OnboardingRequest, id=request_id) onboarding = get_object_or_404(OnboardingRequest, id=request_id)
@@ -869,8 +1080,7 @@ def generate_onboarding_intro_session_pdf(request, request_id: int):
return redirect('onboarding_intro_session_page', request_id=request_id) return redirect('onboarding_intro_session_page', request_id=request_id)
@login_required @_require_capability('run_intro_session')
@user_passes_test(_is_staff)
def onboarding_intro_session_page(request, request_id: int): def onboarding_intro_session_page(request, request_id: int):
onboarding = get_object_or_404(OnboardingRequest, id=request_id) onboarding = get_object_or_404(OnboardingRequest, id=request_id)
session, _created = OnboardingIntroductionSession.objects.get_or_create(onboarding_request=onboarding) session, _created = OnboardingIntroductionSession.objects.get_or_create(onboarding_request=onboarding)
@@ -1024,8 +1234,7 @@ def offboarding_success(request, request_id: int):
return render(request, 'workflows/offboarding_success.html', {'obj': obj, 'pdf_url': pdf_url}) return render(request, 'workflows/offboarding_success.html', {'obj': obj, 'pdf_url': pdf_url})
@login_required @_require_capability('manage_builders')
@user_passes_test(_is_staff)
def form_builder_page(request): def form_builder_page(request):
language_code = get_language() language_code = get_language()
form_type = request.GET.get('form_type', 'onboarding') form_type = request.GET.get('form_type', 'onboarding')
@@ -1200,8 +1409,7 @@ def form_builder_page(request):
) )
@login_required @_require_capability('manage_builders')
@user_passes_test(_is_staff)
def intro_builder_page(request): def intro_builder_page(request):
if request.method == 'POST': if request.method == 'POST':
delete_id = (request.POST.get('delete_item_id') or '').strip() delete_id = (request.POST.get('delete_item_id') or '').strip()
@@ -1311,8 +1519,7 @@ def intro_builder_page(request):
) )
@login_required @_require_capability('manage_integrations')
@user_passes_test(_is_staff)
def integrations_setup_page(request): def integrations_setup_page(request):
config, _ = WorkflowConfig.objects.get_or_create(name='Default') config, _ = WorkflowConfig.objects.get_or_create(name='Default')
kind = (request.GET.get('kind') or 'nextcloud').strip().lower() kind = (request.GET.get('kind') or 'nextcloud').strip().lower()
@@ -1342,8 +1549,7 @@ def integrations_setup_page(request):
) )
@login_required @_require_capability('manage_welcome_emails')
@user_passes_test(_is_staff)
def welcome_emails_page(request): def welcome_emails_page(request):
rows = ScheduledWelcomeEmail.objects.select_related('onboarding_request').order_by('-send_at', '-id')[:200] rows = ScheduledWelcomeEmail.objects.select_related('onboarding_request').order_by('-send_at', '-id')[:200]
config, _ = WorkflowConfig.objects.get_or_create(name='Default') config, _ = WorkflowConfig.objects.get_or_create(name='Default')
@@ -1373,8 +1579,7 @@ def welcome_emails_page(request):
) )
@login_required @_require_capability('manage_welcome_emails')
@user_passes_test(_is_staff)
@require_POST @require_POST
def trigger_welcome_email_now(request, schedule_id: int): def trigger_welcome_email_now(request, schedule_id: int):
scheduled = ScheduledWelcomeEmail.objects.filter(id=schedule_id).first() scheduled = ScheduledWelcomeEmail.objects.filter(id=schedule_id).first()
@@ -1395,8 +1600,7 @@ def trigger_welcome_email_now(request, schedule_id: int):
return redirect('welcome_emails_page') return redirect('welcome_emails_page')
@login_required @_require_capability('manage_welcome_emails')
@user_passes_test(_is_staff)
@require_POST @require_POST
def save_welcome_email_settings(request): def save_welcome_email_settings(request):
config, _ = WorkflowConfig.objects.get_or_create(name='Default') config, _ = WorkflowConfig.objects.get_or_create(name='Default')
@@ -1498,8 +1702,7 @@ def _parse_selected_schedule_ids(raw: str) -> list[int]:
return parsed return parsed
@login_required @_require_capability('manage_welcome_emails')
@user_passes_test(_is_staff)
@require_POST @require_POST
def bulk_welcome_email_action(request): def bulk_welcome_email_action(request):
action = (request.POST.get('bulk_action') or '').strip().lower() action = (request.POST.get('bulk_action') or '').strip().lower()
@@ -1569,8 +1772,7 @@ def bulk_welcome_email_action(request):
return redirect('welcome_emails_page') return redirect('welcome_emails_page')
@login_required @_require_capability('manage_welcome_emails')
@user_passes_test(_is_staff)
@require_POST @require_POST
def pause_welcome_email(request, schedule_id: int): def pause_welcome_email(request, schedule_id: int):
scheduled = ScheduledWelcomeEmail.objects.filter(id=schedule_id).first() scheduled = ScheduledWelcomeEmail.objects.filter(id=schedule_id).first()
@@ -1589,8 +1791,7 @@ def pause_welcome_email(request, schedule_id: int):
return redirect('welcome_emails_page') return redirect('welcome_emails_page')
@login_required @_require_capability('manage_welcome_emails')
@user_passes_test(_is_staff)
@require_POST @require_POST
def resume_welcome_email(request, schedule_id: int): def resume_welcome_email(request, schedule_id: int):
scheduled = ScheduledWelcomeEmail.objects.filter(id=schedule_id).first() scheduled = ScheduledWelcomeEmail.objects.filter(id=schedule_id).first()
@@ -1612,8 +1813,7 @@ def resume_welcome_email(request, schedule_id: int):
return redirect('welcome_emails_page') return redirect('welcome_emails_page')
@login_required @_require_capability('manage_welcome_emails')
@user_passes_test(_is_staff)
@require_POST @require_POST
def cancel_welcome_email(request, schedule_id: int): def cancel_welcome_email(request, schedule_id: int):
scheduled = ScheduledWelcomeEmail.objects.filter(id=schedule_id).first() scheduled = ScheduledWelcomeEmail.objects.filter(id=schedule_id).first()
@@ -1633,8 +1833,7 @@ def cancel_welcome_email(request, schedule_id: int):
return redirect('welcome_emails_page') return redirect('welcome_emails_page')
@login_required @_require_capability('manage_builders')
@user_passes_test(_is_staff)
@require_POST @require_POST
def form_builder_save_order(request): def form_builder_save_order(request):
try: try:
@@ -1698,8 +1897,7 @@ def form_builder_save_order(request):
return JsonResponse({'ok': True, 'saved_count': len(configs)}) return JsonResponse({'ok': True, 'saved_count': len(configs)})
@login_required @_require_capability('manage_integrations')
@user_passes_test(_is_staff)
@require_POST @require_POST
def send_test_email(request): def send_test_email(request):
mode = 'TEST_MODE_ON' if is_email_test_mode() else 'TEST_MODE_OFF' mode = 'TEST_MODE_ON' if is_email_test_mode() else 'TEST_MODE_OFF'
@@ -1718,8 +1916,7 @@ def send_test_email(request):
return _redirect_back(request, 'home') return _redirect_back(request, 'home')
@login_required @_require_capability('manage_integrations')
@user_passes_test(_is_staff)
@require_POST @require_POST
def nextcloud_test_upload(request): def nextcloud_test_upload(request):
filename = f"nextcloud_test_{timezone.now().strftime('%Y%m%d_%H%M%S')}.txt" filename = f"nextcloud_test_{timezone.now().strftime('%Y%m%d_%H%M%S')}.txt"
@@ -1751,8 +1948,7 @@ def nextcloud_test_upload(request):
return _redirect_back(request, 'home') return _redirect_back(request, 'home')
@login_required @_require_capability('manage_integrations')
@user_passes_test(_is_staff)
@require_POST @require_POST
def toggle_nextcloud_enabled(request): def toggle_nextcloud_enabled(request):
config, _ = WorkflowConfig.objects.get_or_create(name='Default') config, _ = WorkflowConfig.objects.get_or_create(name='Default')
@@ -1766,8 +1962,7 @@ def toggle_nextcloud_enabled(request):
return _redirect_back(request, 'home') return _redirect_back(request, 'home')
@login_required @_require_capability('manage_integrations')
@user_passes_test(_is_staff)
@require_POST @require_POST
def toggle_email_mode(request): def toggle_email_mode(request):
config, _ = WorkflowConfig.objects.get_or_create(name='Default') config, _ = WorkflowConfig.objects.get_or_create(name='Default')
@@ -1781,8 +1976,7 @@ def toggle_email_mode(request):
return _redirect_back(request, 'home') return _redirect_back(request, 'home')
@login_required @_require_capability('manage_integrations')
@user_passes_test(_is_staff)
@require_POST @require_POST
def save_integrations_settings(request): def save_integrations_settings(request):
config, _ = WorkflowConfig.objects.get_or_create(name='Default') config, _ = WorkflowConfig.objects.get_or_create(name='Default')
@@ -1820,8 +2014,7 @@ def save_integrations_settings(request):
return redirect('home') return redirect('home')
@login_required @_require_capability('manage_integrations')
@user_passes_test(_is_staff)
@require_POST @require_POST
def save_nextcloud_settings(request): def save_nextcloud_settings(request):
config, _ = WorkflowConfig.objects.get_or_create(name='Default') config, _ = WorkflowConfig.objects.get_or_create(name='Default')
@@ -1846,8 +2039,7 @@ def save_nextcloud_settings(request):
return redirect('/admin-tools/integrations/?kind=nextcloud') return redirect('/admin-tools/integrations/?kind=nextcloud')
@login_required @_require_capability('manage_integrations')
@user_passes_test(_is_staff)
@require_POST @require_POST
def save_workflow_rules(request): def save_workflow_rules(request):
config, _ = WorkflowConfig.objects.get_or_create(name='Default') config, _ = WorkflowConfig.objects.get_or_create(name='Default')
@@ -1877,8 +2069,7 @@ def save_workflow_rules(request):
return redirect('/admin-tools/integrations/?kind=rules') return redirect('/admin-tools/integrations/?kind=rules')
@login_required @_require_capability('manage_integrations')
@user_passes_test(_is_staff)
@require_POST @require_POST
def save_backup_settings(request): def save_backup_settings(request):
config, _ = WorkflowConfig.objects.get_or_create(name='Default') config, _ = WorkflowConfig.objects.get_or_create(name='Default')
@@ -1930,8 +2121,7 @@ def save_backup_settings(request):
return redirect('/admin-tools/integrations/?kind=backup') return redirect('/admin-tools/integrations/?kind=backup')
@login_required @_require_capability('manage_integrations')
@user_passes_test(_is_staff)
@require_POST @require_POST
def save_mail_settings(request): def save_mail_settings(request):
config, _ = WorkflowConfig.objects.get_or_create(name='Default') config, _ = WorkflowConfig.objects.get_or_create(name='Default')
@@ -1971,8 +2161,7 @@ def save_mail_settings(request):
return redirect('/admin-tools/integrations/?kind=mail') return redirect('/admin-tools/integrations/?kind=mail')
@login_required @_require_capability('manage_integrations')
@user_passes_test(_is_staff)
@require_POST @require_POST
def save_email_routing_settings(request): def save_email_routing_settings(request):
config, _ = WorkflowConfig.objects.get_or_create(name='Default') config, _ = WorkflowConfig.objects.get_or_create(name='Default')
@@ -2039,8 +2228,7 @@ def save_email_routing_settings(request):
return redirect('/admin-tools/integrations/?kind=emails') return redirect('/admin-tools/integrations/?kind=emails')
@login_required @_require_capability('manage_integrations')
@user_passes_test(_is_staff)
@require_POST @require_POST
def save_notification_rules(request): def save_notification_rules(request):
rule_ids = request.POST.getlist('rule_ids') rule_ids = request.POST.getlist('rule_ids')
@@ -2103,8 +2291,7 @@ def save_notification_rules(request):
return redirect('/admin-tools/integrations/?kind=emails') return redirect('/admin-tools/integrations/?kind=emails')
@login_required @_require_capability('delete_requests')
@user_passes_test(_is_staff)
@require_POST @require_POST
def delete_request_from_dashboard(request, kind: str, request_id: int): def delete_request_from_dashboard(request, kind: str, request_id: int):
if kind == 'onboarding': if kind == 'onboarding':
@@ -2122,8 +2309,7 @@ def delete_request_from_dashboard(request, kind: str, request_id: int):
return redirect('requests_dashboard') return redirect('requests_dashboard')
@login_required @_require_capability('retry_requests')
@user_passes_test(_is_staff)
@require_POST @require_POST
def retry_request_from_dashboard(request, kind: str, request_id: int): def retry_request_from_dashboard(request, kind: str, request_id: int):
if kind == 'onboarding': if kind == 'onboarding':