snapshot: preserve auth invite flow and password reset UX cleanup

This commit is contained in:
Md Bayazid Bostame
2026-03-26 10:34:31 +01:00
parent b585287004
commit af10a5fdee
17 changed files with 635 additions and 170 deletions

View File

@@ -1,9 +1,10 @@
from django import forms
from pathlib import Path
from datetime import timedelta
from django.contrib.auth import get_user_model
from django.contrib.auth import get_user_model, password_validation
from django.contrib.auth.forms import AuthenticationForm, PasswordResetForm, SetPasswordForm
from django.utils import timezone
from django.utils.translation import get_language, gettext as _
from django.utils.translation import get_language, gettext as _, gettext_lazy
from .form_builder import apply_form_field_config
from .models import EmployeeProfile, FormOption, OffboardingRequest, OnboardingRequest, WorkflowConfig
@@ -98,14 +99,35 @@ 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')]
class AppAuthenticationForm(AuthenticationForm):
username = forms.CharField(label=gettext_lazy('Benutzername'))
password = forms.CharField(label=gettext_lazy('Passwort'), strip=False, widget=forms.PasswordInput(attrs={'autocomplete': 'current-password'}))
class AppPasswordResetForm(PasswordResetForm):
email = forms.EmailField(label=gettext_lazy('E-Mail-Adresse'))
class AppSetPasswordForm(SetPasswordForm):
new_password1 = forms.CharField(
label=gettext_lazy('Neues Passwort'),
strip=False,
widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}),
help_text=password_validation.password_validators_help_text_html(),
)
new_password2 = forms.CharField(
label=gettext_lazy('Neues Passwort bestätigen'),
strip=False,
widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}),
)
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)
@@ -130,20 +152,12 @@ class UserManagementCreateForm(forms.Form):
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'],
password=None,
first_name=self.cleaned_data.get('first_name', ''),
last_name=self.cleaned_data.get('last_name', ''),
is_active=True,

View File

@@ -0,0 +1,22 @@
{% extends 'workflows/base_shell.html' %}
{% load static i18n %}
{% block title %}{% trans "Passwort gespeichert" %}{% endblock %}
{% block shell_header %}
{% include 'workflows/includes/app_header.html' with header_inside_shell=1 %}
{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'workflows/css/login.css' %}" />
{% endblock %}
{% block shell_body %}
<section class="login-shell-body">
<div class="login-card">
<h1>{% trans "Passwort gespeichert" %}</h1>
<p>{% trans "Ihr Passwort wurde erfolgreich gesetzt. Sie können sich jetzt mit Ihrem Benutzerkonto anmelden." %}</p>
<a class="btn btn-primary" href="/accounts/login/">{% trans "Zur Anmeldung" %}</a>
</div>
</section>
{% endblock %}

View File

@@ -0,0 +1,45 @@
{% extends 'workflows/base_shell.html' %}
{% load static i18n %}
{% block title %}{% trans "Passwort festlegen" %}{% endblock %}
{% block shell_header %}
{% include 'workflows/includes/app_header.html' with header_inside_shell=1 %}
{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'workflows/css/login.css' %}" />
{% endblock %}
{% block shell_body %}
<section class="login-shell-body">
<div class="login-card">
{% if validlink %}
<h1>{% trans "Passwort festlegen" %}</h1>
<p>{% trans "Bitte vergeben Sie jetzt ein neues Passwort für Ihr Konto." %}</p>
<form method="post">
{% csrf_token %}
{% if form.errors %}
<div class="app-alert app-alert-error" role="alert" aria-live="assertive">
<div class="app-alert-body">
<strong>{% trans "Passwort konnte nicht gespeichert werden" %}</strong><br />
<span>{% trans "Bitte prüfen Sie die beiden Passwortfelder und versuchen Sie es erneut." %}</span>
</div>
</div>
{% endif %}
<div class="field{% if form.new_password1.errors %} has-error{% endif %}">
{{ form.new_password1.label_tag }}{{ form.new_password1 }}
</div>
<div class="field{% if form.new_password2.errors %} has-error{% endif %}">
{{ form.new_password2.label_tag }}{{ form.new_password2 }}
</div>
<button class="btn btn-primary" type="submit">{% trans "Passwort speichern" %}</button>
</form>
{% else %}
<h1>{% trans "Link ungültig" %}</h1>
<p>{% trans "Dieser Link ist nicht mehr gültig. Bitte fordern Sie einen neuen Passwort-Link an." %}</p>
<a class="btn btn-primary" href="/accounts/login/">{% trans "Zur Anmeldung" %}</a>
{% endif %}
</div>
</section>
{% endblock %}

View File

@@ -0,0 +1,22 @@
{% extends 'workflows/base_shell.html' %}
{% load static i18n %}
{% block title %}{% trans "E-Mail versendet" %}{% endblock %}
{% block shell_header %}
{% include 'workflows/includes/app_header.html' with header_inside_shell=1 %}
{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'workflows/css/login.css' %}" />
{% endblock %}
{% block shell_body %}
<section class="login-shell-body">
<div class="login-card">
<h1>{% trans "E-Mail versendet" %}</h1>
<p>{% trans "Wenn ein passendes Konto existiert, wurde ein Passwort-Link an die hinterlegte E-Mail-Adresse verschickt." %}</p>
<a class="btn btn-primary" href="/accounts/login/">{% trans "Zur Anmeldung" %}</a>
</div>
</section>
{% endblock %}

View File

@@ -0,0 +1,28 @@
{% extends 'workflows/base_shell.html' %}
{% load static i18n %}
{% block title %}{% trans "Passwort zurücksetzen" %}{% endblock %}
{% block shell_header %}
{% include 'workflows/includes/app_header.html' with header_inside_shell=1 %}
{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'workflows/css/login.css' %}" />
{% endblock %}
{% block shell_body %}
<section class="login-shell-body">
<div class="login-card">
<h1>{% trans "Passwort zurücksetzen" %}</h1>
<p>{% trans "Geben Sie Ihre E-Mail-Adresse ein. Wenn ein Konto vorhanden ist, erhalten Sie einen Passwort-Link." %}</p>
<form method="post">
{% csrf_token %}
<div class="field{% if form.email.errors %} has-error{% endif %}">
{{ form.email.label_tag }}{{ form.email }}
</div>
<button class="btn btn-primary" type="submit">{% trans "Link anfordern" %}</button>
</form>
</div>
</section>
{% endblock %}

View File

@@ -0,0 +1,39 @@
{% extends 'workflows/base_shell.html' %}
{% load static i18n %}
{% block title %}{% trans "Anmeldung" %}{% endblock %}
{% block shell_header %}
{% include 'workflows/includes/app_header.html' with header_show_lang=1 header_inside_shell=1 %}
{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'workflows/css/login.css' %}" />
{% endblock %}
{% block shell_body %}
<section class="login-shell-body">
<div class="login-card">
<h1>{% trans "Anmeldung" %}</h1>
<p>{% trans "Bitte melden Sie sich mit Ihrem Benutzerkonto an." %}</p>
<form method="post" action="/accounts/login/">
{% csrf_token %}
{% if next %}
<input type="hidden" name="next" value="{{ next }}" />
{% endif %}
{% if form.errors %}
<div class="app-alert app-alert-error" role="alert" aria-live="assertive">
<div class="app-alert-body">
<strong>{% trans "Anmeldung fehlgeschlagen" %}</strong><br />
<span>{% trans "Benutzername oder Passwort sind nicht korrekt. Bitte versuchen Sie es erneut." %}</span>
</div>
</div>
{% endif %}
<div class="field{% if form.errors %} has-error{% endif %}">{{ form.username.label_tag }}{{ form.username }}</div>
<div class="field{% if form.errors %} has-error{% endif %}">{{ form.password.label_tag }}{{ form.password }}</div>
<button class="btn btn-primary" type="submit">{% trans "Anmelden" %}</button>
</form>
</div>
</section>
{% endblock %}

View File

@@ -0,0 +1,22 @@
{% extends 'workflows/base_shell.html' %}
{% load static i18n %}
{% block title %}{% trans "Passwort gespeichert" %}{% endblock %}
{% block shell_header %}
{% include 'workflows/includes/app_header.html' with header_show_lang=1 header_inside_shell=1 %}
{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'workflows/css/login.css' %}" />
{% endblock %}
{% block shell_body %}
<section class="login-shell-body">
<div class="login-card">
<h1>{% trans "Passwort gespeichert" %}</h1>
<p>{% trans "Ihr Passwort wurde erfolgreich gesetzt. Sie können sich jetzt mit Ihrem Benutzerkonto anmelden." %}</p>
<a class="btn btn-primary" href="/accounts/login/">{% trans "Zur Anmeldung" %}</a>
</div>
</section>
{% endblock %}

View File

@@ -0,0 +1,47 @@
{% extends 'workflows/base_shell.html' %}
{% load static i18n %}
{% block title %}{% trans "Passwort festlegen" %}{% endblock %}
{% block shell_header %}
{% include 'workflows/includes/app_header.html' with header_show_lang=1 header_inside_shell=1 %}
{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'workflows/css/login.css' %}" />
{% endblock %}
{% block shell_body %}
<section class="login-shell-body">
<div class="login-card">
{% if validlink %}
<h1>{% trans "Passwort festlegen" %}</h1>
<p>{% trans "Bitte vergeben Sie jetzt ein neues Passwort für Ihr Konto." %}</p>
<form method="post">
{% csrf_token %}
{% if form.errors %}
<div class="app-alert app-alert-error" role="alert" aria-live="assertive">
<div class="app-alert-body">
<strong>{% trans "Passwort konnte nicht gespeichert werden" %}</strong><br />
<span>{% trans "Bitte prüfen Sie die beiden Passwortfelder und versuchen Sie es erneut." %}</span>
</div>
</div>
{% endif %}
<div class="field{% if form.new_password1.errors %} has-error{% endif %}">
{{ form.new_password1.label_tag }}{{ form.new_password1 }}
{% for error in form.new_password1.errors %}<div class="hint">{{ error }}</div>{% endfor %}
</div>
<div class="field{% if form.new_password2.errors %} has-error{% endif %}">
{{ form.new_password2.label_tag }}{{ form.new_password2 }}
{% for error in form.new_password2.errors %}<div class="hint">{{ error }}</div>{% endfor %}
</div>
<button class="btn btn-primary" type="submit">{% trans "Passwort speichern" %}</button>
</form>
{% else %}
<h1>{% trans "Link ungültig" %}</h1>
<p>{% trans "Dieser Link ist nicht mehr gültig. Bitte fordern Sie einen neuen Passwort-Link an." %}</p>
<a class="btn btn-primary" href="/accounts/login/">{% trans "Zur Anmeldung" %}</a>
{% endif %}
</div>
</section>
{% endblock %}

View File

@@ -0,0 +1,22 @@
{% extends 'workflows/base_shell.html' %}
{% load static i18n %}
{% block title %}{% trans "E-Mail versendet" %}{% endblock %}
{% block shell_header %}
{% include 'workflows/includes/app_header.html' with header_show_lang=1 header_inside_shell=1 %}
{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'workflows/css/login.css' %}" />
{% endblock %}
{% block shell_body %}
<section class="login-shell-body">
<div class="login-card">
<h1>{% trans "E-Mail versendet" %}</h1>
<p>{% trans "Wenn ein passendes Konto existiert, wurde ein Passwort-Link an die hinterlegte E-Mail-Adresse verschickt." %}</p>
<a class="btn btn-primary" href="/accounts/login/">{% trans "Zur Anmeldung" %}</a>
</div>
</section>
{% endblock %}

View File

@@ -0,0 +1,29 @@
{% extends 'workflows/base_shell.html' %}
{% load static i18n %}
{% block title %}{% trans "Passwort zurücksetzen" %}{% endblock %}
{% block shell_header %}
{% include 'workflows/includes/app_header.html' with header_show_lang=1 header_inside_shell=1 %}
{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'workflows/css/login.css' %}" />
{% endblock %}
{% block shell_body %}
<section class="login-shell-body">
<div class="login-card">
<h1>{% trans "Passwort zurücksetzen" %}</h1>
<p>{% trans "Geben Sie Ihre E-Mail-Adresse ein. Wenn ein Konto vorhanden ist, erhalten Sie einen Passwort-Link." %}</p>
<form method="post">
{% csrf_token %}
<div class="field{% if form.email.errors %} has-error{% endif %}">
{{ form.email.label_tag }}{{ form.email }}
{% for error in form.email.errors %}<div class="hint">{{ error }}</div>{% endfor %}
</div>
<button class="btn btn-primary" type="submit">{% trans "Link anfordern" %}</button>
</form>
</div>
</section>
{% endblock %}

View File

@@ -107,7 +107,7 @@ docker compose exec -T web python manage.py check</code></pre>
<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>Super-admin-only user management lives at <code>/admin-tools/users/</code> and is the preferred path for normal role assignment, account activation, invitation mail dispatch, 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>

View File

@@ -178,7 +178,7 @@
<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>Benutzer &amp; Rollen:</strong> super-admin-only page for creating users, assigning roles, activating/deactivating access, sending access or password-reset links by email, 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>

View File

@@ -20,6 +20,7 @@
<section class="card">
<h2>{% trans "Benutzer anlegen" %}</h2>
<p class="sub">{% trans "Nach dem Anlegen wird automatisch eine Zugangseinladung mit Passwort-Link per E-Mail versendet." %}</p>
<form method="post" action="{% url 'create_user_from_admin' %}">
{% csrf_token %}
<div class="grid">
@@ -48,20 +49,10 @@
{{ 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>
<button class="btn btn-primary" type="submit">{% trans "Benutzer anlegen und einladen" %}</button>
</div>
</form>
</section>

View File

@@ -394,6 +394,44 @@ def _would_remove_last_super_admin(user, new_role_key: str | None = None, new_is
return False
def _send_user_access_email(request, target_user, *, invitation: bool) -> None:
email = (target_user.email or '').strip()
if not email:
raise ValueError(_('Für diesen Benutzer ist keine E-Mail-Adresse hinterlegt.'))
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)
if invitation:
subject = _('Zugangseinladung für %(username)s') % {'username': target_user.username}
body = _(
'Hallo %(name)s,\n\n'
'für Sie wurde ein Benutzerkonto im TUBCO Onboarding- und Offboarding-Portal angelegt.\n'
'Bitte öffnen Sie den folgenden Link, um Ihr Passwort zu setzen:\n'
'%(url)s\n\n'
'Wenn Sie diese Einladung nicht erwartet haben, melden Sie sich bitte bei Ihrem Administrator.'
) % {
'name': _display_user_name(target_user),
'url': reset_url,
}
else:
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,
}
send_system_email(subject=subject, body=body, to=[email])
@_require_capability('manage_users')
def user_management_page(request):
return _render_user_management(request)
@@ -408,15 +446,16 @@ def create_user_from_admin(request):
return _render_user_management(request, create_form=form, status_code=400)
user = form.save()
_send_user_access_email(request, user, invitation=True)
_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)},
details={'username': user.username, 'role': get_user_role_key(user), 'invitation_sent': True},
)
messages.success(request, _('Benutzer wurde erstellt: %(username)s') % {'username': user.username})
messages.success(request, _('Benutzer wurde erstellt und eingeladen: %(username)s') % {'username': user.username})
return redirect('user_management_page')
@@ -463,37 +502,18 @@ def update_user_from_admin(request, user_id: int):
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.'))
try:
_send_user_access_email(request, target_user, invitation=False)
except ValueError as exc:
messages.error(request, str(exc))
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},
details={'username': target_user.username, 'email': target_user.email},
)
messages.success(request, _('Passwort-Reset-Link wurde versendet: %(username)s') % {'username': target_user.username})
return redirect('user_management_page')