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

@@ -52,6 +52,7 @@
<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/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><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>
@@ -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>
</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>
<h3>Standard Django i18n path</h3>
<pre><code>make i18n-update-en

View File

@@ -35,7 +35,7 @@
<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>
<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 %}">
{% trans "Nextcloud:" %} {% if nextcloud_enabled %}{% trans "aktiv" %}{% else %}{% trans "inaktiv" %}{% endif %}
</span>
@@ -88,6 +88,7 @@
</div>
</section>
{% if can_access_requests_dashboard %}
<section class="app-card">
<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>
</div>
</section>
{% endif %}
</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">
<h2>{% trans "Admin Apps" %}</h2>
<p>{% trans "Konfiguration, Tests und Steuerung." %}</p>
</div>
<div class="admin-grid">
{% if can_manage_integrations %}
<section class="admin-card">
<h3>{% trans "Integrationen" %}</h3>
<p>{% trans "Nextcloud- und E-Mail-Setup." %}</p>
<a class="btn btn-secondary" href="/admin-tools/integrations/?kind=nextcloud">{% trans "Öffnen" %}</a>
</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">
<h3>{% trans "Audit Log" %}</h3>
<p>{% trans "Wichtige Admin-Aktionen nachvollziehen und prüfen." %}</p>
<a class="btn btn-secondary" href="/admin-tools/audit-log/">{% trans "Öffnen" %}</a>
</section>
{% endif %}
{% if can_manage_backups %}
<section class="admin-card">
<h3>{% trans "Backup & Recovery" %}</h3>
<p>{% trans "Backups erstellen und sicher verifizieren." %}</p>
<a class="btn btn-secondary" href="/admin-tools/backups/">{% trans "Öffnen" %}</a>
</section>
{% endif %}
{% if can_manage_welcome_emails %}
<section class="admin-card">
<h3>{% trans "Welcome E-Mails" %}</h3>
<p>{% trans "Geplante Welcome Mails verwalten." %}</p>
<a class="btn btn-secondary" href="/admin-tools/welcome-emails/">{% trans "Öffnen" %}</a>
</section>
{% endif %}
{% if can_manage_builders %}
<section class="admin-card">
<h3>{% trans "Form Builder" %}</h3>
<p>{% trans "Felder, Schritte und Optionen verwalten." %}</p>
@@ -141,16 +159,21 @@
<p>{% trans "Checklistenpunkte für das Einweisungsprotokoll konfigurieren." %}</p>
<a class="btn btn-secondary" href="/admin-tools/intro-builder/">{% trans "Öffnen" %}</a>
</section>
{% endif %}
{% if can_view_docs %}
<section class="admin-card">
<h3>{% trans "Handbook" %}</h3>
<p>{% trans "Project wiki and developer documentation in one place." %}</p>
<a class="btn btn-secondary" href="/admin-tools/handbook/">{% trans "Öffnen" %}</a>
</section>
{% endif %}
{% if can_access_django_admin_link %}
<section class="admin-card">
<h3>{% trans "Django Admin" %}</h3>
<p>{% trans "Vollständige Datenverwaltung." %}</p>
<a class="btn btn-secondary" href="/admin/">{% trans "Öffnen" %}</a>
</section>
{% endif %}
</div>
{% endif %}

View File

@@ -172,9 +172,13 @@
<h2 id="admin">9) Admin Apps (Home)</h2>
<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>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>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>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>
@@ -223,6 +227,7 @@
<h2 id="hardening">11) Security & Reliability Hardening (Current)</h2>
<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>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>

View File

@@ -167,7 +167,7 @@
</div>
</form>
</div>
{% if request.user.is_staff %}
{% if can_delete_requests %}
<div class="control-stack">
<form method="post" action="/requests/" id="bulk-delete-form" data-confirm="{% trans 'Ausgewählte Einträge wirklich löschen?' %}">
{% csrf_token %}
@@ -184,19 +184,19 @@
<table>
<thead>
<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 "Person" %}</th>
<th>{% trans "E-Mail" %}</th>
<th>{% trans "Dokument" %}</th>
{% if request.user.is_staff %}<th>{% trans "Einweisung" %}</th>{% endif %}
{% if request.user.is_staff %}<th>{% trans "Aktion" %}</th>{% endif %}
{% if can_run_intro_session or can_generate_intro_pdfs %}<th>{% trans "Einweisung" %}</th>{% endif %}
{% if can_retry_requests or can_delete_requests or can_access_requests_dashboard %}<th>{% trans "Aktion" %}</th>{% endif %}
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr>
{% if request.user.is_staff %}
{% if can_delete_requests %}
<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" />
</td>
@@ -225,7 +225,7 @@
{% endif %}
{% endif %}
</td>
{% if request.user.is_staff %}
{% if can_run_intro_session or can_generate_intro_pdfs %}
<td class="actions-cell intro-panel">
{% if row.kind_slug == 'onboarding' %}
<details>
@@ -234,12 +234,15 @@
<div class="intro-group">
<div class="intro-group-title">{% trans "Live-Protokoll" %}</div>
<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>
{% 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>
{% endif %}
</div>
</div>
{% if can_generate_intro_pdfs %}
<div class="intro-group">
<div class="intro-group-title">{% trans "Standard-Einweisungs-PDF" %}</div>
<div class="intro-actions">
@@ -257,6 +260,7 @@
{% endif %}
</div>
</div>
{% endif %}
</div>
{% if row.intro_session %}
<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>
{% endif %}
</td>
{% endif %}
{% if can_retry_requests or can_delete_requests or can_access_requests_dashboard %}
<td class="actions-cell">
<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?' %}">
{% csrf_token %}
<button class="btn btn-secondary" type="submit">{% trans "Erneut versuchen" %}</button>
</form>
{% endif %}
{% if can_delete_requests %}
<form method="post" action="/requests/" class="inline-delete" data-confirm="{% trans 'Eintrag wirklich löschen?' %}">
{% csrf_token %}
<button class="btn btn-secondary" type="submit" name="single_delete" value="{{ row.kind_slug }}:{{ row.id }}">{% trans "Löschen" %}</button>
</form>
{% endif %}
</td>
{% endif %}
</tr>
{% empty %}
<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>
{% endfor %}
</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 %}