snapshot: preserve totp account security baseline

This commit is contained in:
Md Bayazid Bostame
2026-03-27 02:46:40 +01:00
parent 358a71230d
commit c679488437
18 changed files with 1723 additions and 786 deletions

View File

@@ -16,6 +16,7 @@ Current branch roles:
3. Start as single-tenant configurable, not full multi-tenant.
4. Make branding and document identity admin-managed, not code-managed.
5. Add new business apps only after the core platform layer is standardized.
6. Prefer inline editing for lightweight profile and configuration data, but keep explicit forms for sensitive or high-risk settings.
## Product Layers
@@ -213,3 +214,35 @@ This is the first productization slice because it gives:
- keep migrations backward-compatible
- update both wiki and developer handbook for every architecture change
- snapshot at the end of each major phase
## Shared UI Pattern: Inline Editing
Use inline editing as a platform pattern where it improves speed without weakening clarity or safety.
Good candidates:
- user profile and contact data
- company config sections
- branding text and non-sensitive metadata
- low-risk app-registry metadata
Do not use it by default for:
- credentials and secrets
- integrations with side effects
- destructive actions
- multi-step workflow forms
- settings that need heavy validation or confirmation
Preferred implementation style:
- section-level inline editing
- explicit `Bearbeiten`, `Speichern`, `Abbrechen`
- no noisy per-field autosave
- clear view mode and edit mode separation
Reason:
- keeps Workdock faster and more product-grade
- avoids large admin-style forms for simple edits
- still preserves reliable validation and safer change boundaries

File diff suppressed because it is too large Load Diff

View File

@@ -349,6 +349,16 @@ def ensure_portal_app_configs() -> None:
'visible_to_staff': visibility.get(ROLE_STAFF, False),
},
)
normalize_portal_app_sort_orders()
def normalize_portal_app_sort_orders() -> None:
for section_key, _label in PortalAppConfig.SECTION_CHOICES:
configs = list(PortalAppConfig.objects.filter(section=section_key).order_by('sort_order', 'key'))
for position, config in enumerate(configs):
if config.sort_order != position:
config.sort_order = position
config.save(update_fields=['sort_order'])
def get_portal_app_registry_rows() -> list[dict[str, object]]:

View File

@@ -3,6 +3,7 @@ from pathlib import Path
from datetime import timedelta
from django.contrib.auth import get_user_model, password_validation
from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm, PasswordResetForm, SetPasswordForm
from django.core.exceptions import ValidationError
from django.utils import timezone
from django.utils.translation import get_language, gettext as _, gettext_lazy
@@ -10,6 +11,7 @@ from .branding import get_company_email_domain
from .form_builder import apply_form_field_config
from .models import EmployeeProfile, FormOption, OffboardingRequest, OnboardingRequest, PortalBranding, PortalCompanyConfig, PortalTrialConfig, UserProfile, WorkflowConfig
from .roles import ROLE_ADMIN, ROLE_GROUP_NAMES, ROLE_IT_STAFF, ROLE_LABELS, ROLE_PLATFORM_OWNER, ROLE_STAFF, ROLE_SUPER_ADMIN, assign_user_role
from .totp import normalize_totp_token, verify_totp_token
YES_NO_CHOICES = [('', '--'), ('ja', 'Ja'), ('nein', 'Nein')]
@@ -103,6 +105,38 @@ SOFTWARE_EXTRA_CHOICES = [('Adobe Acrobat Pro (Abonnement: Zusätzliche Kosten)'
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'}))
otp_code = forms.CharField(
label=gettext_lazy('TOTP-Code'),
required=False,
max_length=12,
widget=forms.TextInput(attrs={'autocomplete': 'one-time-code', 'inputmode': 'numeric'}),
)
error_messages = {
**AuthenticationForm.error_messages,
'invalid_otp': gettext_lazy('Der TOTP-Code ist ungültig.'),
'missing_otp': gettext_lazy('Bitte geben Sie Ihren TOTP-Code ein.'),
}
def clean(self):
cleaned_data = super().clean()
user = self.get_user()
if not user:
return cleaned_data
profile, _ = UserProfile.objects.get_or_create(user=user)
if profile.totp_enabled:
otp_code = normalize_totp_token(cleaned_data.get('otp_code'))
if not otp_code:
raise ValidationError(
self.error_messages['missing_otp'],
code='missing_otp',
)
if not profile.totp_secret or not verify_totp_token(profile.totp_secret, otp_code, for_time=int(timezone.now().timestamp())):
raise ValidationError(
self.error_messages['invalid_otp'],
code='invalid_otp',
)
return cleaned_data
class AppPasswordResetForm(PasswordResetForm):
@@ -221,6 +255,71 @@ class AccountDetailsForm(forms.Form):
return self.user, self.profile
class AccountTOTPEnableForm(forms.Form):
current_password = forms.CharField(
label=gettext_lazy('Aktuelles Passwort'),
strip=False,
widget=forms.PasswordInput(attrs={'autocomplete': 'current-password'}),
)
verification_code = forms.CharField(
label=gettext_lazy('TOTP-Code'),
max_length=12,
widget=forms.TextInput(attrs={'autocomplete': 'one-time-code', 'inputmode': 'numeric'}),
)
def __init__(self, *args, user=None, secret: str = '', **kwargs):
super().__init__(*args, **kwargs)
self.user = user
self.secret = secret
def clean_current_password(self):
password = self.cleaned_data.get('current_password') or ''
if not self.user or not self.user.check_password(password):
raise ValidationError(_('Das aktuelle Passwort ist nicht korrekt.'))
return password
def clean_verification_code(self):
code = normalize_totp_token(self.cleaned_data.get('verification_code'))
if not code:
raise ValidationError(_('Bitte geben Sie einen gültigen TOTP-Code ein.'))
if not self.secret or not verify_totp_token(self.secret, code, for_time=int(timezone.now().timestamp())):
raise ValidationError(_('Der TOTP-Code ist ungültig.'))
return code
class AccountTOTPDisableForm(forms.Form):
current_password = forms.CharField(
label=gettext_lazy('Aktuelles Passwort'),
strip=False,
widget=forms.PasswordInput(attrs={'autocomplete': 'current-password'}),
)
verification_code = forms.CharField(
label=gettext_lazy('TOTP-Code'),
max_length=12,
widget=forms.TextInput(attrs={'autocomplete': 'one-time-code', 'inputmode': 'numeric'}),
)
def __init__(self, *args, user=None, profile=None, **kwargs):
super().__init__(*args, **kwargs)
self.user = user
self.profile = profile
def clean_current_password(self):
password = self.cleaned_data.get('current_password') or ''
if not self.user or not self.user.check_password(password):
raise ValidationError(_('Das aktuelle Passwort ist nicht korrekt.'))
return password
def clean_verification_code(self):
code = normalize_totp_token(self.cleaned_data.get('verification_code'))
if not code:
raise ValidationError(_('Bitte geben Sie einen gültigen TOTP-Code ein.'))
secret = getattr(self.profile, 'totp_secret', '') or ''
if not secret or not verify_totp_token(secret, code, for_time=int(timezone.now().timestamp())):
raise ValidationError(_('Der TOTP-Code ist ungültig.'))
return code
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)

View File

@@ -0,0 +1,26 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('workflows', '0048_userprofile'),
]
operations = [
migrations.AddField(
model_name='userprofile',
name='totp_confirmed_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='userprofile',
name='totp_enabled',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='userprofile',
name='totp_secret',
field=models.CharField(blank=True, default='', max_length=64),
),
]

View File

@@ -2,6 +2,7 @@ from django.conf import settings
from django.core.validators import FileExtensionValidator
from django.db import models
from django.utils.translation import get_language
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
@@ -39,6 +40,9 @@ class UserProfile(models.Model):
department = models.CharField(max_length=255, blank=True, default='')
location = models.CharField(max_length=255, blank=True, default='')
contact_notes = models.CharField(max_length=255, blank=True, default='')
totp_secret = models.CharField(max_length=64, blank=True, default='')
totp_enabled = models.BooleanField(default=False)
totp_confirmed_at = models.DateTimeField(null=True, blank=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
@@ -48,6 +52,18 @@ class UserProfile(models.Model):
def __str__(self) -> str:
return getattr(self.user, 'username', '') or str(self.user_id)
def disable_totp(self) -> None:
self.totp_secret = ''
self.totp_enabled = False
self.totp_confirmed_at = None
self.save(update_fields=['totp_secret', 'totp_enabled', 'totp_confirmed_at', 'updated_at'])
def enable_totp(self, secret: str) -> None:
self.totp_secret = secret
self.totp_enabled = True
self.totp_confirmed_at = timezone.now()
self.save(update_fields=['totp_secret', 'totp_enabled', 'totp_confirmed_at', 'updated_at'])
class PortalBranding(models.Model):
name = models.CharField(max_length=80, default='Default', unique=True)

View File

@@ -298,6 +298,34 @@ body {
margin-bottom: 18px;
}
.account-totp-card {
margin-bottom: 18px;
padding: 18px;
border-radius: 18px;
border: 1px solid #dbe5f2;
background:
radial-gradient(circle at top right, rgba(30, 64, 175, 0.08), transparent 28%),
#f9fbff;
}
.account-totp-card h3 {
margin: 0 0 6px;
color: #132238;
font-size: 18px;
}
.account-secret {
display: block;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
line-height: 1.55;
word-break: break-all;
}
.account-totp-form {
margin-top: 14px;
}
.account-action-card {
display: grid;
gap: 6px;

View File

@@ -12,6 +12,19 @@ h1 { margin: 12px 0 6px; color: #000078; }
.branding-block-head { margin-bottom: 12px; }
.branding-block-head h2 { margin: 0; color: #17345e; font-size: 18px; }
.branding-block-head p { margin: 4px 0 0; color: #60738d; font-size: 13px; }
.branding-inline-head, .company-inline-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 14px; }
.branding-inline-trigger, .company-inline-trigger { min-width: 112px; }
.branding-inline-view.is-hidden, .branding-inline-form.is-hidden, .company-inline-view.is-hidden, .company-inline-form.is-hidden { display: none; }
.branding-inline-value, .company-inline-value { min-height: 40px; padding: 10px 12px; border: 1px solid #d9e4f1; border-radius: 10px; background: rgba(248,251,255,0.92); color: #18335b; line-height: 1.45; word-break: break-word; }
.branding-inline-actions, .company-inline-actions { display: flex; gap: 10px; margin-top: 14px; }
.branding-inline-error, .company-inline-error { margin-top: 6px; color: #ab1e1e; font-size: 12px; line-height: 1.4; }
.company-inline-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 14px; }
.company-inline-trigger { min-width: 112px; }
.company-inline-view.is-hidden, .company-inline-form.is-hidden { display: none; }
.company-inline-value { min-height: 40px; padding: 10px 12px; border: 1px solid #d9e4f1; border-radius: 10px; background: rgba(248,251,255,0.92); color: #18335b; line-height: 1.45; word-break: break-word; }
.company-inline-actions { display: flex; gap: 10px; margin-top: 14px; }
.company-inline-error { margin-top: 6px; color: #ab1e1e; font-size: 12px; line-height: 1.4; }
.field.has-error input, .field.has-error select, .field.has-error textarea { border-color: #e3a3a3; background: #fffafa; box-shadow: 0 0 0 4px rgba(185, 28, 28, 0.06); }
.lang-pairs { align-items: start; }
.lang-block { border: 1px solid #d9e4f1; border-radius: 14px; background: rgba(255,255,255,0.82); padding: 12px; }
.lang-block h3 { margin: 0 0 10px; color: #223b63; font-size: 15px; }
@@ -146,14 +159,18 @@ th { background: #f6f9ff; color: #334155; }
.app-registry-card { border: 1px solid #d9e4f1; border-radius: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(246,250,255,0.95)); padding: 16px; box-shadow: inset 0 1px 0 rgba(255,255,255,0.94); transition: border-color 180ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 220ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 180ms cubic-bezier(0.2, 0.8, 0.2, 1); }
.app-registry-card:hover { transform: translateY(-1px); box-shadow: 0 12px 24px rgba(16, 32, 57, 0.06); border-color: #c9d8eb; }
.app-registry-card.is-disabled { opacity: 0.84; }
.app-registry-card.is-dragging { opacity: 0.55; transform: rotate(0.4deg); box-shadow: 0 18px 28px rgba(16, 32, 57, 0.14); }
.app-registry-card[hidden] { display: none !important; }
.app-registry-card-head { display: flex; justify-content: space-between; align-items: start; gap: 14px; margin-bottom: 14px; }
.app-registry-card-title-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 4px; }
.app-registry-card-title-row h2 { margin: 0; color: #17345e; font-size: 19px; }
.app-registry-card-copy { margin: 8px 0 0; color: #60738d; max-width: 760px; }
.app-registry-summary { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(260px, 0.9fr); gap: 16px; align-items: center; list-style: none; cursor: pointer; }
.app-registry-summary { display: grid; grid-template-columns: 28px minmax(0, 1.5fr) minmax(260px, 0.9fr); gap: 16px; align-items: center; list-style: none; cursor: pointer; }
.app-registry-summary::-webkit-details-marker { display: none; }
.app-registry-summary::marker { display: none; }
.app-registry-drag-handle { display: inline-flex; align-items: center; justify-content: center; width: 28px; min-height: 42px; border-radius: 10px; border: 1px dashed #cbd7e6; background: #f8fbff; color: #5f6f85; font-size: 15px; letter-spacing: 0.04em; cursor: grab; user-select: none; }
.app-registry-card.is-dragging .app-registry-drag-handle { cursor: grabbing; }
.app-registry-card.drag-disabled .app-registry-drag-handle { opacity: 0.4; cursor: not-allowed; border-style: solid; }
.app-registry-summary-main { min-width: 0; }
.app-registry-summary-meta { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; align-items: center; }
.app-registry-card-grid { display: grid; grid-template-columns: repeat(2, minmax(260px, 1fr)); gap: 12px; align-items: start; }
@@ -187,6 +204,8 @@ th { background: #f6f9ff; color: #334155; }
.actions { white-space: nowrap; }
@media (max-width: 760px) {
.grid { grid-template-columns: 1fr; }
.branding-inline-head, .company-inline-head { flex-direction: column; }
.branding-inline-actions, .company-inline-actions { flex-direction: column; }
.trial-summary-grid { grid-template-columns: 1fr 1fr; }
.trial-expired-shell { padding: 20px 16px 28px; }
.trial-expired-card { padding: 18px; }
@@ -200,3 +219,9 @@ th { background: #f6f9ff; color: #334155; }
.app-registry-copy-panel { grid-column: auto; }
.app-registry-savebar { align-items: stretch; flex-direction: column; }
}
.app-registry-groups { display: grid; gap: 18px; }
.app-registry-group { border: 1px solid #d7e3f0; border-radius: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(244,248,255,0.95)); padding: 14px; box-shadow: inset 0 1px 0 rgba(255,255,255,0.94); }
.app-registry-group-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; margin-bottom: 14px; }
.app-registry-group-head h2 { margin: 0; color: #17345e; font-size: 18px; }
.app-registry-group-body { display: grid; gap: 14px; }
.app-registry-group[hidden] { display: none !important; }

View File

@@ -180,12 +180,100 @@
<span>{% trans "Aktualisieren Sie Ihr Passwort direkt im Konto." %}</span>
</a>
<div class="account-action-card{% if account_user_profile.totp_enabled %}{% else %} account-action-card-muted{% endif %}">
<strong>{% trans "TOTP" %}</strong>
<span>
{% if account_user_profile.totp_enabled %}
{% trans "Zweiter Faktor ist aktiv und wird bei der Anmeldung geprüft." %}
{% else %}
{% trans "Standardmäßig deaktiviert. Kann hier jederzeit aktiviert werden." %}
{% endif %}
</span>
</div>
<div class="account-action-card account-action-card-muted">
<strong>{% trans "Sitzung" %}</strong>
<span>{% trans "Sie können sich jederzeit sicher vom aktuellen Gerät abmelden." %}</span>
</div>
</div>
<div class="account-totp-card">
<div class="account-panel-head">
<div>
<h3>{% trans "Zwei-Faktor-Authentifizierung" %}</h3>
<p>{% trans "Aktivieren Sie TOTP mit einer Authenticator-App. Standardmäßig bleibt es ausgeschaltet." %}</p>
</div>
<span class="account-chip{% if not account_user_profile.totp_enabled %} account-chip-muted{% endif %}">
{% if account_user_profile.totp_enabled %}
{% trans "Aktiv" %}
{% else %}
{% trans "Aus" %}
{% endif %}
</span>
</div>
{% if account_user_profile.totp_enabled %}
<div class="account-detail-grid">
<div class="account-detail">
<span>{% trans "Status" %}</span>
<strong>{% trans "TOTP ist aktiviert." %}</strong>
</div>
<div class="account-detail">
<span>{% trans "Bestätigt am" %}</span>
<strong>{% if account_user_profile.totp_confirmed_at %}{{ account_user_profile.totp_confirmed_at|date:"d.m.Y H:i" }}{% else %}-{% endif %}</strong>
</div>
</div>
<form class="account-totp-form" method="post">
{% csrf_token %}
<input type="hidden" name="account_form" value="totp_disable" />
<div class="account-form-grid">
{% for field in totp_disable_form %}
<div class="account-form-field{% if field.errors %} has-error{% endif %}">
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% if field.errors %}
<div class="account-form-error">{{ field.errors|join:", " }}</div>
{% endif %}
</div>
{% endfor %}
</div>
<div class="account-inline-actions">
<button class="btn btn-secondary" type="submit">{% trans "TOTP deaktivieren" %}</button>
</div>
</form>
{% else %}
<div class="account-detail-grid">
<div class="account-detail">
<span>{% trans "Manueller Schlüssel" %}</span>
<strong class="account-secret">{{ totp_pending_secret }}</strong>
</div>
<div class="account-detail">
<span>{% trans "Setup-Link" %}</span>
<strong class="account-secret">{{ totp_otpauth_uri }}</strong>
</div>
</div>
<p class="mini">{% trans "Wenn Ihre App keinen QR-Code scannen kann, tragen Sie den Schlüssel oder den otpauth-Link manuell ein." %}</p>
<form class="account-totp-form" method="post">
{% csrf_token %}
<input type="hidden" name="account_form" value="totp_enable" />
<div class="account-form-grid">
{% for field in totp_enable_form %}
<div class="account-form-field{% if field.errors %} has-error{% endif %}">
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% if field.errors %}
<div class="account-form-error">{{ field.errors|join:", " }}</div>
{% endif %}
</div>
{% endfor %}
</div>
<div class="account-inline-actions">
<button class="btn btn-primary" type="submit">{% trans "TOTP aktivieren" %}</button>
</div>
</form>
{% endif %}
</div>
<div class="account-actions">
<a class="btn btn-primary" href="{% url 'password_change' %}">{% trans "Passwort ändern" %}</a>
<form method="post" action="{% url 'logout' %}">

View File

@@ -1,6 +1,7 @@
{% extends 'workflows/base_shell.html' %}
{% load static i18n %}
{% trans "Ungespeicherte Änderungen" as dirty_state_label %}
{% trans "Sortierung" as sort_label %}
{% block title %}{% trans "App Registry" %}{% endblock %}
@@ -40,150 +41,184 @@
<label for="app-registry-section">{% trans "Bereich" %}</label>
<select id="app-registry-section">
<option value="all">{% trans "Alle" %}</option>
<option value="apps">{% trans "Apps" %}</option>
<option value="platform_apps">{% trans "Platform Apps" %}</option>
<option value="admin_apps">{% trans "Admin Apps" %}</option>
<option value="app">{% trans "Apps" %}</option>
<option value="platform">{% trans "Platform Apps" %}</option>
<option value="admin">{% trans "Admin Apps" %}</option>
</select>
</div>
</section>
<div class="app-registry-cards">
{% for row in rows %}
<details class="app-registry-card{% if not row.config.is_enabled %} is-disabled{% endif %}" data-app-card
data-key="{{ row.config.key|lower }}"
data-title="{{ row.definition.title|lower }}"
data-enabled="{% if row.config.is_enabled %}1{% else %}0{% endif %}"
data-platform-only="{% if not row.config.visible_to_super_admin and not row.config.visible_to_admin and not row.config.visible_to_it_staff and not row.config.visible_to_staff %}1{% else %}0{% endif %}"
data-section="{{ row.config.section }}">
<summary class="app-registry-summary">
<div class="app-registry-summary-main">
<div class="app-registry-card-title-row">
<h2>{{ row.definition.title }}</h2>
{% if row.config.is_enabled %}
<span class="badge sent">{% trans "Aktiv" %}</span>
<div class="hint" id="app-registry-reorder-hint">{% trans "Für eine verlässliche Reihenfolge bitte ohne aktive Filter umsortieren." %}</div>
<div class="app-registry-groups">
{% for section_key, section_label in section_choices %}
<section class="app-registry-group" data-app-group="{{ section_key }}">
<div class="app-registry-group-head">
<div>
<h2>{{ section_label }}</h2>
<p class="mini">
{% if section_key == 'platform' %}
{% trans "Produktweite Steuerung und nur für die Platform sichtbare Oberflächen." %}
{% elif section_key == 'admin' %}
{% trans "Administrative Apps für Kundenrollen mit erhöhter Verantwortung." %}
{% else %}
<span class="badge cancelled">{% trans "Deaktiviert" %}</span>
{% trans "Operative Apps, die im täglichen Einsatz auf der Landing Page erscheinen." %}
{% endif %}
</div>
<div class="mini">{{ row.config.key }}</div>
<p class="app-registry-card-copy">{{ row.definition.description }}</p>
<p class="mini">{% trans "Empfohlener Standardzugriff:" %} {{ row.default_visibility_summary }}</p>
</p>
</div>
<div class="app-registry-summary-meta">
<span class="badge scheduled">
{% if row.config.section == 'platform_apps' %}
{% trans "Platform Apps" %}
{% elif row.config.section == 'admin_apps' %}
{% trans "Admin Apps" %}
{% else %}
{% trans "Apps" %}
{% endif %}
</span>
<span class="badge">{% trans "Sortierung" %}: {{ row.config.sort_order }}</span>
{% if not row.config.visible_to_super_admin and not row.config.visible_to_admin and not row.config.visible_to_it_staff and not row.config.visible_to_staff %}
<span class="badge paused">{% trans "Platform only" %}</span>
{% elif row.config.visible_to_super_admin and row.config.visible_to_admin and row.config.visible_to_it_staff and row.config.visible_to_staff %}
<span class="badge sent">{% trans "Alle Firmenrollen" %}</span>
{% else %}
<span class="badge scheduled">
{% if row.config.visible_to_super_admin %}{% trans "Super Admin" %}{% endif %}
{% if row.config.visible_to_admin %}{% if row.config.visible_to_super_admin %} + {% endif %}{% trans "Admin" %}{% endif %}
{% if row.config.visible_to_it_staff %}{% if row.config.visible_to_super_admin or row.config.visible_to_admin %} + {% endif %}{% trans "IT Staff" %}{% endif %}
{% if row.config.visible_to_staff %}{% if row.config.visible_to_super_admin or row.config.visible_to_admin or row.config.visible_to_it_staff %} + {% endif %}{% trans "Staff" %}{% endif %}
</span>
{% endif %}
</div>
</summary>
<div class="app-registry-card-grid">
<section class="app-registry-panel">
<h3>{% trans "Verfügbarkeit" %}</h3>
<div class="check-row app-registry-checks">
<label>
<input type="checkbox" name="is_enabled__{{ row.config.key }}" {% if row.config.is_enabled %}checked{% endif %} />
<span>{% trans "App aktiviert" %}</span>
</label>
</div>
<p class="hint">{% trans "Deaktivierte Apps erscheinen nicht auf der Landing Page, selbst wenn Rollen sie sehen dürften." %}</p>
</section>
<section class="app-registry-panel">
<h3>{% trans "Sichtbarkeit nach Rolle" %}</h3>
<div class="check-row app-registry-checks">
<label>
<input type="checkbox" name="visible_to_super_admin__{{ row.config.key }}" {% if row.config.visible_to_super_admin %}checked{% endif %} />
<span>{% trans "Super Admin" %}</span>
</label>
<label>
<input type="checkbox" name="visible_to_admin__{{ row.config.key }}" {% if row.config.visible_to_admin %}checked{% endif %} />
<span>{% trans "Admin" %}</span>
</label>
<label>
<input type="checkbox" name="visible_to_it_staff__{{ row.config.key }}" {% if row.config.visible_to_it_staff %}checked{% endif %} />
<span>{% trans "IT Staff" %}</span>
</label>
<label>
<input type="checkbox" name="visible_to_staff__{{ row.config.key }}" {% if row.config.visible_to_staff %}checked{% endif %} />
<span>{% trans "Staff" %}</span>
</label>
</div>
<p class="hint">{% trans "Wenn keine Firmenrolle aktiv ist, bleibt die App nur für die Platform sichtbar." %}</p>
</section>
<section class="app-registry-panel">
<h3>{% trans "Platzierung" %}</h3>
<div class="grid">
<div class="field">
<label for="section__{{ row.config.key }}">{% trans "Bereich" %}</label>
<select id="section__{{ row.config.key }}" name="section__{{ row.config.key }}">
{% for value, label in section_choices %}
<option value="{{ value }}"{% if row.config.section == value %} selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="sort_order__{{ row.config.key }}">{% trans "Reihenfolge" %}</label>
<input id="sort_order__{{ row.config.key }}" type="number" name="sort_order__{{ row.config.key }}" value="{{ row.config.sort_order }}" min="0" step="1" />
</div>
</div>
</section>
<section class="app-registry-panel app-registry-copy-panel">
<h3>{% trans "Bezeichnungen & Texte" %}</h3>
<div class="grid lang-pairs">
<div class="lang-block">
<h4>{% trans "Deutsch" %}</h4>
<div class="field">
<label for="title_override__{{ row.config.key }}">{% trans "Titel" %}</label>
<input id="title_override__{{ row.config.key }}" type="text" name="title_override__{{ row.config.key }}" value="{{ row.config.title_override }}" placeholder="{{ row.definition.title }}" />
</div>
<div class="field">
<label for="description_override__{{ row.config.key }}">{% trans "Beschreibung" %}</label>
<textarea id="description_override__{{ row.config.key }}" name="description_override__{{ row.config.key }}" rows="3" placeholder="{{ row.definition.description }}">{{ row.config.description_override }}</textarea>
</div>
<div class="field">
<label for="action_label_override__{{ row.config.key }}">{% trans "Aktionslabel" %}</label>
<input id="action_label_override__{{ row.config.key }}" type="text" name="action_label_override__{{ row.config.key }}" value="{{ row.config.action_label_override }}" placeholder="{{ row.definition.action_label }}" />
</div>
</div>
<div class="lang-block">
<h4>{% trans "English" %}</h4>
<div class="field">
<label for="title_override_en__{{ row.config.key }}">{% trans "Title" %}</label>
<input id="title_override_en__{{ row.config.key }}" type="text" name="title_override_en__{{ row.config.key }}" value="{{ row.config.title_override_en }}" placeholder="{{ row.definition.title }}" />
</div>
<div class="field">
<label for="description_override_en__{{ row.config.key }}">{% trans "Description" %}</label>
<textarea id="description_override_en__{{ row.config.key }}" name="description_override_en__{{ row.config.key }}" rows="3" placeholder="{{ row.definition.description }}">{{ row.config.description_override_en }}</textarea>
</div>
<div class="field">
<label for="action_label_override_en__{{ row.config.key }}">{% trans "Action label" %}</label>
<input id="action_label_override_en__{{ row.config.key }}" type="text" name="action_label_override_en__{{ row.config.key }}" value="{{ row.config.action_label_override_en }}" placeholder="{{ row.definition.action_label }}" />
</div>
</div>
</div>
</section>
<span class="badge scheduled" data-app-group-count>{{ section_label }}</span>
</div>
</details>
<div class="app-registry-group-body" data-app-group-body="{{ section_key }}">
{% for row in rows %}
{% if row.config.section == section_key %}
<details class="app-registry-card{% if not row.config.is_enabled %} is-disabled{% endif %}" data-app-card draggable="true"
data-key="{{ row.config.key|lower }}"
data-title="{{ row.definition.title|lower }}"
data-enabled="{% if row.config.is_enabled %}1{% else %}0{% endif %}"
data-platform-only="{% if not row.config.visible_to_super_admin and not row.config.visible_to_admin and not row.config.visible_to_it_staff and not row.config.visible_to_staff %}1{% else %}0{% endif %}"
data-section="{{ row.config.section }}">
<summary class="app-registry-summary">
<span class="app-registry-drag-handle" title="{% trans 'Ziehen zum Umordnen' %}" aria-hidden="true">⋮⋮</span>
<div class="app-registry-summary-main">
<div class="app-registry-card-title-row">
<h2>{{ row.definition.title }}</h2>
{% if row.config.is_enabled %}
<span class="badge sent">{% trans "Aktiv" %}</span>
{% else %}
<span class="badge cancelled">{% trans "Deaktiviert" %}</span>
{% endif %}
</div>
<div class="mini">{{ row.config.key }}</div>
<p class="app-registry-card-copy">{{ row.definition.description }}</p>
<p class="mini">{% trans "Empfohlener Standardzugriff:" %} {{ row.default_visibility_summary }}</p>
</div>
<div class="app-registry-summary-meta">
<span class="badge scheduled">
{% if row.config.section == 'platform' %}
{% trans "Platform Apps" %}
{% elif row.config.section == 'admin' %}
{% trans "Admin Apps" %}
{% else %}
{% trans "Apps" %}
{% endif %}
</span>
<span class="badge" data-sort-badge>{% trans "Sortierung" %}: {{ row.config.sort_order }}</span>
{% if not row.config.visible_to_super_admin and not row.config.visible_to_admin and not row.config.visible_to_it_staff and not row.config.visible_to_staff %}
<span class="badge paused">{% trans "Platform only" %}</span>
{% elif row.config.visible_to_super_admin and row.config.visible_to_admin and row.config.visible_to_it_staff and row.config.visible_to_staff %}
<span class="badge sent">{% trans "Alle Firmenrollen" %}</span>
{% else %}
<span class="badge scheduled">
{% if row.config.visible_to_super_admin %}{% trans "Super Admin" %}{% endif %}
{% if row.config.visible_to_admin %}{% if row.config.visible_to_super_admin %} + {% endif %}{% trans "Admin" %}{% endif %}
{% if row.config.visible_to_it_staff %}{% if row.config.visible_to_super_admin or row.config.visible_to_admin %} + {% endif %}{% trans "IT Staff" %}{% endif %}
{% if row.config.visible_to_staff %}{% if row.config.visible_to_super_admin or row.config.visible_to_admin or row.config.visible_to_it_staff %} + {% endif %}{% trans "Staff" %}{% endif %}
</span>
{% endif %}
</div>
</summary>
<div class="app-registry-card-grid">
<section class="app-registry-panel">
<h3>{% trans "Verfügbarkeit" %}</h3>
<div class="check-row app-registry-checks">
<label>
<input type="checkbox" name="is_enabled__{{ row.config.key }}" {% if row.config.is_enabled %}checked{% endif %} />
<span>{% trans "App aktiviert" %}</span>
</label>
</div>
<p class="hint">{% trans "Deaktivierte Apps erscheinen nicht auf der Landing Page, selbst wenn Rollen sie sehen dürften." %}</p>
</section>
<section class="app-registry-panel">
<h3>{% trans "Sichtbarkeit nach Rolle" %}</h3>
<div class="check-row app-registry-checks">
<label>
<input type="checkbox" name="visible_to_super_admin__{{ row.config.key }}" {% if row.config.visible_to_super_admin %}checked{% endif %} />
<span>{% trans "Super Admin" %}</span>
</label>
<label>
<input type="checkbox" name="visible_to_admin__{{ row.config.key }}" {% if row.config.visible_to_admin %}checked{% endif %} />
<span>{% trans "Admin" %}</span>
</label>
<label>
<input type="checkbox" name="visible_to_it_staff__{{ row.config.key }}" {% if row.config.visible_to_it_staff %}checked{% endif %} />
<span>{% trans "IT Staff" %}</span>
</label>
<label>
<input type="checkbox" name="visible_to_staff__{{ row.config.key }}" {% if row.config.visible_to_staff %}checked{% endif %} />
<span>{% trans "Staff" %}</span>
</label>
</div>
<p class="hint">{% trans "Wenn keine Firmenrolle aktiv ist, bleibt die App nur für die Platform sichtbar." %}</p>
</section>
<section class="app-registry-panel">
<h3>{% trans "Platzierung" %}</h3>
<div class="grid">
<div class="field">
<label for="section__{{ row.config.key }}">{% trans "Bereich" %}</label>
<select id="section__{{ row.config.key }}" name="section__{{ row.config.key }}">
{% for value, label in section_choices %}
<option value="{{ value }}"{% if row.config.section == value %} selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="sort_order__{{ row.config.key }}">{% trans "Reihenfolge" %}</label>
<input
id="sort_order__{{ row.config.key }}"
type="number"
name="sort_order__{{ row.config.key }}"
value="{{ row.config.sort_order }}"
min="0"
step="1"
data-sort-order-input
/>
<div class="hint">{% trans "Wird per Drag-and-drop und Bereichswechsel dynamisch neu nummeriert." %}</div>
</div>
</div>
</section>
<section class="app-registry-panel app-registry-copy-panel">
<h3>{% trans "Bezeichnungen & Texte" %}</h3>
<div class="grid lang-pairs">
<div class="lang-block">
<h4>{% trans "Deutsch" %}</h4>
<div class="field">
<label for="title_override__{{ row.config.key }}">{% trans "Titel" %}</label>
<input id="title_override__{{ row.config.key }}" type="text" name="title_override__{{ row.config.key }}" value="{{ row.config.title_override }}" placeholder="{{ row.definition.title }}" />
</div>
<div class="field">
<label for="description_override__{{ row.config.key }}">{% trans "Beschreibung" %}</label>
<textarea id="description_override__{{ row.config.key }}" name="description_override__{{ row.config.key }}" rows="3" placeholder="{{ row.definition.description }}">{{ row.config.description_override }}</textarea>
</div>
<div class="field">
<label for="action_label_override__{{ row.config.key }}">{% trans "Aktionslabel" %}</label>
<input id="action_label_override__{{ row.config.key }}" type="text" name="action_label_override__{{ row.config.key }}" value="{{ row.config.action_label_override }}" placeholder="{{ row.definition.action_label }}" />
</div>
</div>
<div class="lang-block">
<h4>{% trans "English" %}</h4>
<div class="field">
<label for="title_override_en__{{ row.config.key }}">{% trans "Title" %}</label>
<input id="title_override_en__{{ row.config.key }}" type="text" name="title_override_en__{{ row.config.key }}" value="{{ row.config.title_override_en }}" placeholder="{{ row.definition.title }}" />
</div>
<div class="field">
<label for="description_override_en__{{ row.config.key }}">{% trans "Description" %}</label>
<textarea id="description_override_en__{{ row.config.key }}" name="description_override_en__{{ row.config.key }}" rows="3" placeholder="{{ row.definition.description }}">{{ row.config.description_override_en }}</textarea>
</div>
<div class="field">
<label for="action_label_override_en__{{ row.config.key }}">{% trans "Action label" %}</label>
<input id="action_label_override_en__{{ row.config.key }}" type="text" name="action_label_override_en__{{ row.config.key }}" value="{{ row.config.action_label_override_en }}" placeholder="{{ row.definition.action_label }}" />
</div>
</div>
</div>
</section>
</div>
</details>
{% endif %}
{% endfor %}
</div>
</section>
{% endfor %}
</div>
<div class="app-registry-savebar">
@@ -204,13 +239,46 @@
const stateSelect = document.getElementById('app-registry-state');
const sectionSelect = document.getElementById('app-registry-section');
const cards = Array.from(document.querySelectorAll('[data-app-card]'));
const groups = Array.from(document.querySelectorAll('[data-app-group]'));
const groupBodies = Array.from(document.querySelectorAll('[data-app-group-body]'));
const form = document.querySelector('form[action$="app-registry/save/"], form[action*="save_portal_app_registry"]') || document.querySelector('.stack-form');
const dirtyState = document.getElementById('app-registry-dirty-state');
const reorderHint = document.getElementById('app-registry-reorder-hint');
const SECTION_ORDER = ['app', 'platform', 'admin'];
let draggedCard = null;
function cardsInDomOrder() {
return groupBodies.flatMap((body) => Array.from(body.querySelectorAll('[data-app-card]')));
}
function syncGroupContainers() {
cards.forEach((card) => {
const sectionField = card.querySelector('select[name^="section__"]');
const targetSection = sectionField ? sectionField.value : card.dataset.section;
const targetBody = document.querySelector(`[data-app-group-body="${targetSection}"]`);
if (targetBody && card.parentElement !== targetBody) {
targetBody.appendChild(card);
}
card.dataset.section = targetSection;
});
}
function updateGroupVisibility() {
groups.forEach((group) => {
const visibleCards = group.querySelectorAll('[data-app-card]:not([hidden])').length;
group.hidden = visibleCards === 0;
const countBadge = group.querySelector('[data-app-group-count]');
if (countBadge) {
countBadge.textContent = String(visibleCards);
}
});
}
function applyFilters() {
const query = (searchInput?.value || '').trim().toLowerCase();
const state = stateSelect?.value || 'all';
const section = sectionSelect?.value || 'all';
const filterActive = Boolean(query) || state !== 'all' || section !== 'all';
cards.forEach((card) => {
const matchesQuery = !query || card.dataset.key.includes(query) || card.dataset.title.includes(query);
@@ -221,20 +289,116 @@
(state === 'platform_only' && card.dataset.platformOnly === '1');
const matchesSection = section === 'all' || card.dataset.section === section;
card.hidden = !(matchesQuery && matchesState && matchesSection);
card.draggable = !filterActive;
card.classList.toggle('drag-disabled', filterActive);
});
if (reorderHint) {
reorderHint.hidden = !filterActive;
}
updateGroupVisibility();
}
function normalizeSortOrders() {
const grouped = new Map();
const orderedCards = cardsInDomOrder();
SECTION_ORDER.forEach((key) => grouped.set(key, []));
orderedCards.forEach((card) => {
const sectionField = card.querySelector('select[name^="section__"]');
const sortInput = card.querySelector('[data-sort-order-input]');
const sectionKey = sectionField ? sectionField.value : card.dataset.section;
const sortValue = sortInput ? parseInt(sortInput.value || '0', 10) : 0;
if (!grouped.has(sectionKey)) grouped.set(sectionKey, []);
grouped.get(sectionKey).push({
card,
sortInput,
sortValue: Number.isNaN(sortValue) ? 0 : sortValue,
title: (card.dataset.title || '').toLowerCase(),
});
});
grouped.forEach((items, sectionKey) => {
items
.sort((a, b) => {
const domOrder = orderedCards.indexOf(a.card) - orderedCards.indexOf(b.card);
return (a.sortValue - b.sortValue) || domOrder || a.title.localeCompare(b.title);
})
.forEach((item, index) => {
if (item.sortInput) item.sortInput.value = index;
item.card.dataset.section = sectionKey;
const badge = item.card.querySelector('[data-sort-badge]');
if (badge) badge.textContent = `{{ sort_label|escapejs }}: ${index}`;
});
});
}
function markDirty() {
if (!dirtyState) return;
dirtyState.textContent = "{{ dirty_state_label|escapejs }}";
function closestCardAfterPointer(container, clientY) {
const siblingCards = Array.from(container.querySelectorAll('[data-app-card]:not(.is-dragging)'));
return siblingCards.find((card) => {
const rect = card.getBoundingClientRect();
return clientY < rect.top + rect.height / 2;
}) || null;
}
searchInput?.addEventListener('input', applyFilters);
stateSelect?.addEventListener('change', applyFilters);
sectionSelect?.addEventListener('change', applyFilters);
form?.addEventListener('input', markDirty);
form?.addEventListener('change', markDirty);
function markDirty() {
if (dirtyState) {
dirtyState.textContent = '{{ dirty_state_label|escapejs }}';
}
}
if (form) {
form.addEventListener('change', (event) => {
if (event.target.matches('select[name^="section__"], [data-sort-order-input]')) {
syncGroupContainers();
normalizeSortOrders();
}
markDirty();
applyFilters();
});
form.addEventListener('input', () => {
markDirty();
});
}
cards.forEach((card) => {
card.addEventListener('dragstart', () => {
if (card.classList.contains('drag-disabled')) return;
draggedCard = card;
card.classList.add('is-dragging');
});
card.addEventListener('dragend', () => {
card.classList.remove('is-dragging');
draggedCard = null;
normalizeSortOrders();
markDirty();
applyFilters();
});
});
groupBodies.forEach((body) => {
body.addEventListener('dragover', (event) => {
if (!draggedCard || draggedCard.classList.contains('drag-disabled')) return;
event.preventDefault();
const nextCard = closestCardAfterPointer(body, event.clientY);
if (nextCard) {
body.insertBefore(draggedCard, nextCard);
} else {
body.appendChild(draggedCard);
}
});
});
[searchInput, stateSelect, sectionSelect].forEach((control) => {
if (!control) return;
control.addEventListener('input', applyFilters);
control.addEventListener('change', applyFilters);
});
syncGroupContainers();
normalizeSortOrders();
applyFilters();
})();
</script>

View File

@@ -26,12 +26,16 @@
<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>
<span>{% trans "Anmeldedaten oder TOTP-Code 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>
<div class="field{% if form.username.errors or form.errors %} has-error{% endif %}">{{ form.username.label_tag }}{{ form.username }}</div>
<div class="field{% if form.password.errors or form.errors %} has-error{% endif %}">{{ form.password.label_tag }}{{ form.password }}</div>
<div class="field{% if form.otp_code.errors %} has-error{% endif %}">
{{ form.otp_code.label_tag }}{{ form.otp_code }}
<div class="mini">{% trans "Nur erforderlich, wenn TOTP für Ihr Konto aktiviert ist." %}</div>
</div>
<button class="btn btn-primary" type="submit">{% trans "Anmelden" %}</button>
</form>
</div>

View File

@@ -15,71 +15,66 @@
{% include 'workflows/includes/messages.html' %}
<section class="card">
<form method="post" action="{% url 'save_portal_branding' %}" enctype="multipart/form-data" class="stack-form">
{% csrf_token %}
<div class="branding-sections">
<section class="branding-block">
<div class="branding-block-head">
<h2>{% trans "Identität" %}</h2>
<p>{% trans "Titel, Firmenname und zentrale Spracheinstellungen." %}</p>
<div class="branding-sections">
{% for section in branding_sections %}
<section class="branding-block branding-inline-block" data-branding-section="{{ section.key }}">
<div class="branding-block-head branding-inline-head">
<div>
<h2>{{ section.title }}</h2>
<p>{{ section.subtitle }}</p>
</div>
<div class="grid two">
<div class="field">
<label for="{{ form.portal_title.id_for_label }}">{{ form.portal_title.label }}</label>
{{ form.portal_title }}
</div>
<div class="field">
<label for="{{ form.company_name.id_for_label }}">{{ form.company_name.label }}</label>
{{ form.company_name }}
</div>
<div class="field">
<label for="{{ form.company_domain.id_for_label }}">{{ form.company_domain.label }}</label>
{{ form.company_domain }}
<div class="hint">{% trans "Wird für E-Mail-Vorschläge und Domain-bezogene Standardtexte verwendet, z. B. tub.co." %}</div>
</div>
<div class="field">
<label for="{{ form.default_language.id_for_label }}">{{ form.default_language.label }}</label>
{{ form.default_language }}
</div>
<div class="field field-full">
<label for="{{ form.login_subtitle.id_for_label }}">{{ form.login_subtitle.label }}</label>
{{ form.login_subtitle }}
</div>
</div>
</section>
<button
class="btn btn-secondary branding-inline-trigger"
type="button"
data-branding-edit-toggle="{{ section.key }}"
aria-expanded="{% if editing_branding_section == section.key %}true{% else %}false{% endif %}"
>
{% trans "Bearbeiten" %}
</button>
</div>
<section class="branding-block">
<div class="branding-block-head">
<h2>{% trans "Farben & Erscheinungsbild" %}</h2>
<p>{% trans "Zentrale visuelle Markenwerte und Browser-Icon." %}</p>
<div class="branding-inline-view{% if editing_branding_section == section.key %} is-hidden{% endif %}" data-branding-edit-view="{{ section.key }}">
{% if section.key == 'legal' %}
<div class="grid two lang-pairs">
<div class="lang-block">
<h3>{% trans "Deutsch" %}</h3>
{% for row in section.rows|slice:":2" %}
<div class="field{% if row.is_full %} field-full{% endif %}">
<label>{{ row.label }}</label>
<div class="branding-inline-value">{{ row.value|default:"-" }}</div>
</div>
{% endfor %}
</div>
<div class="lang-block">
<h3>{% trans "English" %}</h3>
{% for row in section.rows|slice:"2:" %}
<div class="field{% if row.is_full %} field-full{% endif %}">
<label>{{ row.label }}</label>
<div class="branding-inline-value">{{ row.value|default:"-" }}</div>
</div>
{% endfor %}
</div>
</div>
{% else %}
<div class="grid two">
<div class="field">
<label for="{{ form.primary_color.id_for_label }}">{{ form.primary_color.label }}</label>
{{ form.primary_color }}
</div>
<div class="field">
<label for="{{ form.secondary_color.id_for_label }}">{{ form.secondary_color.label }}</label>
{{ form.secondary_color }}
</div>
<div class="field">
<label for="{{ form.logo_image.id_for_label }}">{{ form.logo_image.label }}</label>
{{ form.logo_image }}
<div class="hint">{% trans "Erlaubte Formate: SVG, PNG, JPG, JPEG, WEBP. Maximal 5 MB." %}</div>
{% for error in form.logo_image.errors %}<div class="hint">{{ error }}</div>{% endfor %}
{% if branding.logo_image %}
<div class="hint">{% trans "Aktuelles Logo:" %} <a href="{{ branding.logo_image.url }}" target="_blank" rel="noopener">{% trans "öffnen" %}</a></div>
{% endif %}
</div>
<div class="field">
<label for="{{ form.favicon_image.id_for_label }}">{{ form.favicon_image.label }}</label>
{{ form.favicon_image }}
<div class="hint">{% trans "Erlaubte Formate: ICO, PNG, SVG, WEBP. Maximal 2 MB." %}</div>
{% for error in form.favicon_image.errors %}<div class="hint">{{ error }}</div>{% endfor %}
{% if branding.favicon_image %}
<div class="hint">{% trans "Aktuelles Favicon:" %} <a href="{{ branding.favicon_image.url }}" target="_blank" rel="noopener">{% trans "öffnen" %}</a></div>
{% endif %}
{% for row in section.rows %}
<div class="field{% if row.is_full %} field-full{% endif %}">
<label>{{ row.label }}</label>
<div class="branding-inline-value">
{% if row.is_file %}
{% if row.value %}
<a href="{{ row.value.url }}" target="_blank" rel="noopener">{% trans "öffnen" %}</a>
{% else %}
-
{% endif %}
{% else %}
{{ row.value|default:"-" }}
{% endif %}
</div>
{% if row.hint %}<div class="hint">{{ row.hint }}</div>{% endif %}
</div>
{% endfor %}
{% if section.key == 'appearance' %}
<div class="field field-full">
<div class="branding-preview" id="branding-preview" data-default-logo="{{ portal_logo_url }}">
<div class="branding-preview-shell">
@@ -101,78 +96,121 @@
</div>
</div>
</div>
{% endif %}
</div>
</section>
{% endif %}
</div>
<section class="branding-block">
<div class="branding-block-head">
<h2>{% trans "Kommunikation" %}</h2>
<p>{% trans "Absender, Support und PDF-Branding für ausgehende Kommunikation." %}</p>
</div>
<div class="grid two">
<div class="field">
<label for="{{ form.support_email.id_for_label }}">{{ form.support_email.label }}</label>
{{ form.support_email }}
</div>
<div class="field">
<label for="{{ form.sender_display_name.id_for_label }}">{{ form.sender_display_name.label }}</label>
{{ form.sender_display_name }}
<div class="hint">{% trans "Wird für ausgehende System-E-Mails als Anzeigename verwendet." %}</div>
</div>
<div class="field field-full">
<label for="{{ form.pdf_letterhead.id_for_label }}">{{ form.pdf_letterhead.label }}</label>
{{ form.pdf_letterhead }}
<div class="hint">{% trans "Erlaubtes Format: PDF. Maximal 10 MB." %}</div>
{% for error in form.pdf_letterhead.errors %}<div class="hint">{{ error }}</div>{% endfor %}
{% if branding.pdf_letterhead %}
<div class="hint">{% trans "Aktueller Briefkopf:" %} <a href="{{ branding.pdf_letterhead.url }}" target="_blank" rel="noopener">{% trans "öffnen" %}</a></div>
{% endif %}
</div>
</div>
</section>
<form method="post" action="{% url 'save_portal_branding' %}" enctype="multipart/form-data" class="branding-inline-form{% if editing_branding_section != section.key %} is-hidden{% endif %}" data-branding-edit-form="{{ section.key }}">
{% csrf_token %}
<input type="hidden" name="section_key" value="{{ section.key }}" />
<section class="branding-block">
<div class="branding-block-head">
<h2>{% trans "Footer & Rechtliches" %}</h2>
<p>{% trans "Gemeinsame Footer-Texte und rechtliche Hinweise für die Shell." %}</p>
</div>
{% if section.key == 'legal' %}
<div class="grid two lang-pairs">
<div class="lang-block">
<h3>{% trans "Deutsch" %}</h3>
<div class="field">
<label for="{{ form.footer_text.id_for_label }}">{{ form.footer_text.label }}</label>
{{ form.footer_text }}
</div>
<div class="field">
<label for="{{ form.legal_notice.id_for_label }}">{{ form.legal_notice.label }}</label>
{{ form.legal_notice }}
{% for row in section.rows|slice:":2" %}
<div class="field{% if row.is_full %} field-full{% endif %}{% if row.bound_field.errors %} has-error{% endif %}">
<label for="{{ row.bound_field.id_for_label }}">{{ row.label }}</label>
{{ row.bound_field }}
{% if row.bound_field.errors %}<div class="branding-inline-error">{{ row.bound_field.errors|join:", " }}</div>{% endif %}
</div>
{% endfor %}
</div>
<div class="lang-block">
<h3>{% trans "English" %}</h3>
<div class="field">
<label for="{{ form.footer_text_en.id_for_label }}">{{ form.footer_text_en.label }}</label>
{{ form.footer_text_en }}
</div>
<div class="field">
<label for="{{ form.legal_notice_en.id_for_label }}">{{ form.legal_notice_en.label }}</label>
{{ form.legal_notice_en }}
{% for row in section.rows|slice:"2:" %}
<div class="field{% if row.is_full %} field-full{% endif %}{% if row.bound_field.errors %} has-error{% endif %}">
<label for="{{ row.bound_field.id_for_label }}">{{ row.label }}</label>
{{ row.bound_field }}
{% if row.bound_field.errors %}<div class="branding-inline-error">{{ row.bound_field.errors|join:", " }}</div>{% endif %}
</div>
{% endfor %}
</div>
</div>
</section>
</div>
{% else %}
<div class="grid two">
{% for row in section.rows %}
<div class="field{% if row.is_full %} field-full{% endif %}{% if row.bound_field.errors %} has-error{% endif %}">
<label for="{{ row.bound_field.id_for_label }}">{{ row.label }}</label>
{{ row.bound_field }}
{% if row.hint %}<div class="hint">{{ row.hint }}</div>{% endif %}
{% if row.is_file and row.value %}
<div class="hint">
{% if row.name == 'logo_image' %}{% trans "Aktuelles Logo:" %}
{% elif row.name == 'favicon_image' %}{% trans "Aktuelles Favicon:" %}
{% elif row.name == 'pdf_letterhead' %}{% trans "Aktueller Briefkopf:" %}
{% endif %}
<a href="{{ row.value.url }}" target="_blank" rel="noopener">{% trans "öffnen" %}</a>
</div>
{% endif %}
{% if row.bound_field.errors %}<div class="branding-inline-error">{{ row.bound_field.errors|join:", " }}</div>{% endif %}
</div>
{% endfor %}
{% if section.key == 'appearance' %}
<div class="field field-full">
<div class="branding-preview" id="branding-preview" data-default-logo="{{ portal_logo_url }}">
<div class="branding-preview-shell">
<div class="branding-preview-header">
<img class="branding-preview-logo" id="branding-preview-logo" src="{{ portal_logo_url }}" alt="{{ portal_company_name }} Logo" />
<div class="branding-preview-copy">
<strong id="branding-preview-company">{{ branding.company_name }}</strong>
<span id="branding-preview-title">{{ branding.portal_title }}</span>
</div>
</div>
<div class="branding-preview-band">
<span class="branding-preview-chip" id="branding-preview-primary">{% trans "Primärfarbe" %}</span>
<span class="branding-preview-chip branding-preview-chip-secondary" id="branding-preview-secondary">{% trans "Sekundärfarbe" %}</span>
</div>
<div class="branding-preview-footer">
<div class="branding-preview-footer-main" id="branding-preview-footer">{{ branding.footer_text|default:branding.portal_title }}</div>
<div class="branding-preview-footer-legal" id="branding-preview-legal">{{ branding.legal_notice }}</div>
</div>
</div>
</div>
</div>
{% endif %}
</div>
{% endif %}
<div class="branding-inline-actions">
<button class="btn btn-primary" type="submit">{% trans "Speichern" %}</button>
<button class="btn btn-secondary" type="button" data-branding-edit-cancel="{{ section.key }}">{% trans "Abbrechen" %}</button>
</div>
</form>
</section>
{% endfor %}
<div class="toolbar" style="margin-top:1.25rem;">
<div class="hint">{% trans "Die aktuell gesetzte Deployment-Branding bleibt erhalten, bis hier Werte geändert oder Dateien hochgeladen werden." %}</div>
<button class="btn btn-primary" type="submit">{% trans "Branding speichern" %}</button>
</div>
</form>
</div>
</section>
{% endblock %}
{% block extra_scripts %}
<script>
(() => {
function setBrandingMode(key, editing) {
const view = document.querySelector('[data-branding-edit-view="' + key + '"]');
const form = document.querySelector('[data-branding-edit-form="' + key + '"]');
const toggle = document.querySelector('[data-branding-edit-toggle="' + key + '"]');
if (!view || !form || !toggle) return;
view.classList.toggle('is-hidden', editing);
form.classList.toggle('is-hidden', !editing);
toggle.setAttribute('aria-expanded', editing ? 'true' : 'false');
}
document.querySelectorAll('[data-branding-edit-toggle]').forEach((button) => {
const key = button.getAttribute('data-branding-edit-toggle');
button.addEventListener('click', () => setBrandingMode(key, true));
});
document.querySelectorAll('[data-branding-edit-cancel]').forEach((button) => {
const key = button.getAttribute('data-branding-edit-cancel');
button.addEventListener('click', () => setBrandingMode(key, false));
});
const byId = (id) => document.getElementById(id);
const title = byId('{{ form.portal_title.id_for_label }}');
const company = byId('{{ form.company_name.id_for_label }}');

View File

@@ -15,106 +15,75 @@
{% include 'workflows/includes/messages.html' %}
<section class="branding-sections">
<form method="post" action="{% url 'save_portal_company_config' %}" class="stack-form">
{% csrf_token %}
<section class="branding-block">
<div class="branding-block-head">
<h2>{% trans "Firmenprofil" %}</h2>
<p>{% trans "Rechtlicher Name und zentrale Stammdaten der Firma." %}</p>
{% for section in company_config_sections %}
<section class="branding-block company-inline-block" data-company-section="{{ section.key }}">
<div class="branding-block-head company-inline-head">
<div>
<h2>{{ section.title }}</h2>
<p>{{ section.subtitle }}</p>
</div>
<div class="grid">
<div class="field">
<label for="{{ form.legal_company_name.id_for_label }}">{{ form.legal_company_name.label }}</label>
{{ form.legal_company_name }}
</div>
<div class="field">
<label for="{{ form.phone_number.id_for_label }}">{{ form.phone_number.label }}</label>
{{ form.phone_number }}
</div>
<div class="field">
<label for="{{ form.website_url.id_for_label }}">{{ form.website_url.label }}</label>
{{ form.website_url }}
</div>
<div class="field">
<label for="{{ form.country.id_for_label }}">{{ form.country.label }}</label>
{{ form.country }}
</div>
</div>
</section>
<section class="branding-block">
<div class="branding-block-head">
<h2>{% trans "Adresse & Register" %}</h2>
<p>{% trans "Anschrift sowie optionale Register- und Steuerangaben." %}</p>
</div>
<div class="grid">
<div class="field field-full">
<label for="{{ form.street_address.id_for_label }}">{{ form.street_address.label }}</label>
{{ form.street_address }}
</div>
<div class="field">
<label for="{{ form.postal_code.id_for_label }}">{{ form.postal_code.label }}</label>
{{ form.postal_code }}
</div>
<div class="field">
<label for="{{ form.city.id_for_label }}">{{ form.city.label }}</label>
{{ form.city }}
</div>
<div class="field">
<label for="{{ form.registration_number.id_for_label }}">{{ form.registration_number.label }}</label>
{{ form.registration_number }}
</div>
<div class="field">
<label for="{{ form.vat_id.id_for_label }}">{{ form.vat_id.label }}</label>
{{ form.vat_id }}
</div>
</div>
</section>
<section class="branding-block">
<div class="branding-block-head">
<h2>{% trans "Kontaktpunkte" %}</h2>
<p>{% trans "Zentrale Ansprechpartner für HR, IT und Operations." %}</p>
</div>
<div class="grid">
<div class="field">
<label for="{{ form.hr_contact_email.id_for_label }}">{{ form.hr_contact_email.label }}</label>
{{ form.hr_contact_email }}
</div>
<div class="field">
<label for="{{ form.it_contact_email.id_for_label }}">{{ form.it_contact_email.label }}</label>
{{ form.it_contact_email }}
</div>
<div class="field">
<label for="{{ form.operations_contact_email.id_for_label }}">{{ form.operations_contact_email.label }}</label>
{{ form.operations_contact_email }}
</div>
</div>
</section>
<section class="branding-block">
<div class="branding-block-head">
<h2>{% trans "Recht & Öffentlichkeit" %}</h2>
<p>{% trans "Öffentliche Links für Website, Impressum und Datenschutz." %}</p>
</div>
<div class="grid">
<div class="field">
<label for="{{ form.imprint_url.id_for_label }}">{{ form.imprint_url.label }}</label>
{{ form.imprint_url }}
</div>
<div class="field">
<label for="{{ form.privacy_url.id_for_label }}">{{ form.privacy_url.label }}</label>
{{ form.privacy_url }}
</div>
</div>
<div class="hint">{% trans "Diese Links können später im Portal-Footer oder in öffentlichen Seiten verwendet werden." %}</div>
</section>
<div class="toolbar" style="margin-top:1rem;">
<div class="hint">{% trans "Diese Ebene ist bewusst von Branding getrennt: Hier geht es um strukturierte Firmendaten, nicht um visuelle Gestaltung." %}</div>
<button class="btn btn-primary" type="submit">{% trans "Firmenkonfiguration speichern" %}</button>
<button class="btn btn-secondary company-inline-trigger" type="button" data-company-edit-toggle="{{ section.key }}" aria-expanded="{% if editing_company_section == section.key %}true{% else %}false{% endif %}">{% trans "Bearbeiten" %}</button>
</div>
</form>
<div class="company-inline-view{% if editing_company_section == section.key %} is-hidden{% endif %}" data-company-edit-view="{{ section.key }}">
<div class="grid">
{% for row in section.rows %}
<div class="field{% if row.name == 'street_address' %} field-full{% endif %}">
<label>{{ row.label }}</label>
<div class="company-inline-value">{{ row.value|default:"-" }}</div>
</div>
{% endfor %}
</div>
{% if section.hint %}<div class="hint">{{ section.hint }}</div>{% endif %}
</div>
<form method="post" action="{% url 'save_portal_company_config' %}" class="company-inline-form{% if editing_company_section != section.key %} is-hidden{% endif %}" data-company-edit-form="{{ section.key }}">
{% csrf_token %}
<input type="hidden" name="section_key" value="{{ section.key }}" />
<div class="grid">
{% for row in section.rows %}
<div class="field{% if row.name == 'street_address' %} field-full{% endif %}{% if row.bound_field.errors %} has-error{% endif %}">
<label for="{{ row.bound_field.id_for_label }}">{{ row.label }}</label>
{{ row.bound_field }}
{% if row.bound_field.errors %}<div class="company-inline-error">{{ row.bound_field.errors|join:", " }}</div>{% endif %}
</div>
{% endfor %}
</div>
{% if section.hint %}<div class="hint">{{ section.hint }}</div>{% endif %}
<div class="company-inline-actions">
<button class="btn btn-primary" type="submit">{% trans "Speichern" %}</button>
<button class="btn btn-secondary" type="button" data-company-edit-cancel="{{ section.key }}">{% trans "Abbrechen" %}</button>
</div>
</form>
</section>
{% endfor %}
<div class="toolbar" style="margin-top:1rem;">
<div class="hint">{% trans "Diese Ebene ist bewusst von Branding getrennt: Hier geht es um strukturierte Firmendaten, nicht um visuelle Gestaltung." %}</div>
</div>
</section>
{% endblock %}
{% block extra_scripts %}
<script>
(function () {
function setMode(key, editing) {
var view = document.querySelector('[data-company-edit-view="' + key + '"]');
var form = document.querySelector('[data-company-edit-form="' + key + '"]');
var toggle = document.querySelector('[data-company-edit-toggle="' + key + '"]');
if (!view || !form || !toggle) return;
view.classList.toggle('is-hidden', editing);
form.classList.toggle('is-hidden', !editing);
toggle.setAttribute('aria-expanded', editing ? 'true' : 'false');
}
document.querySelectorAll('[data-company-edit-toggle]').forEach(function (button) {
var key = button.getAttribute('data-company-edit-toggle');
button.addEventListener('click', function () { setMode(key, true); });
});
document.querySelectorAll('[data-company-edit-cancel]').forEach(function (button) {
var key = button.getAttribute('data-company-edit-cancel');
button.addEventListener('click', function () { setMode(key, false); });
});
}());
</script>
{% endblock %}

View File

@@ -3,15 +3,15 @@
{% block title %}{{ portal_title }}{% 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/home.css' %}" />
{% endblock %}
{% block shell_body %}
{% include 'workflows/includes/app_header.html' with header_show_lang=1 %}
<div class="hero">
<div class="hero-grid">
<div class="hero-card">

View File

@@ -1,7 +1,9 @@
from django.contrib.auth import get_user_model
from django.test import Client, TestCase
from django.utils import timezone
from workflows.models import UserProfile
from workflows.totp import generate_totp_token
class AccountUISmokeTests(TestCase):
@@ -55,3 +57,59 @@ class AccountUISmokeTests(TestCase):
self.assertEqual(self.user.email, 'updated@example.com')
self.assertEqual(profile.phone_number, '030 123456')
self.assertEqual(profile.job_title, 'IT Manager')
def test_totp_can_be_enabled_from_account(self):
response = self.client.post(
'/account/',
{
'account_form': 'totp_enable',
'current_password': 'secret-12345',
'verification_code': '000000',
},
HTTP_HOST='localhost',
follow=True,
)
self.assertEqual(response.status_code, 200)
self.user.refresh_from_db()
profile = self.user.profile
pending_secret = self.client.session.get('account_totp_pending_secret')
self.assertTrue(pending_secret)
valid_code = generate_totp_token(pending_secret, int(timezone.now().timestamp()))
response = self.client.post(
'/account/',
{
'account_form': 'totp_enable',
'current_password': 'secret-12345',
'verification_code': valid_code,
},
HTTP_HOST='localhost',
follow=True,
)
self.assertEqual(response.status_code, 200)
profile.refresh_from_db()
self.assertTrue(profile.totp_enabled)
self.assertTrue(profile.totp_secret)
def test_login_requires_totp_when_enabled(self):
profile = self.user.profile
profile.totp_secret = 'JBSWY3DPEHPK3PXP'
profile.totp_enabled = True
profile.save(update_fields=['totp_secret', 'totp_enabled', 'updated_at'])
client = Client()
response = client.post(
'/accounts/login/',
{'username': 'profile-user', 'password': 'secret-12345'},
HTTP_HOST='localhost',
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'TOTP-Code')
token = generate_totp_token(profile.totp_secret, int(timezone.now().timestamp()))
response = client.post(
'/accounts/login/',
{'username': 'profile-user', 'password': 'secret-12345', 'otp_code': token},
HTTP_HOST='localhost',
)
self.assertEqual(response.status_code, 302)

49
backend/workflows/totp.py Normal file
View File

@@ -0,0 +1,49 @@
from __future__ import annotations
import base64
import hashlib
import hmac
import secrets
import struct
from urllib.parse import quote
def generate_totp_secret(length: int = 20) -> str:
return base64.b32encode(secrets.token_bytes(length)).decode('ascii').rstrip('=')
def normalize_totp_token(value: str | None) -> str:
return ''.join(ch for ch in (value or '').strip() if ch.isdigit())
def _secret_bytes(secret: str) -> bytes:
padded = secret.strip().replace(' ', '').upper()
padding = '=' * ((8 - len(padded) % 8) % 8)
return base64.b32decode(padded + padding, casefold=True)
def generate_totp_token(secret: str, for_time: int, *, digits: int = 6, period: int = 30) -> str:
counter = int(for_time // period)
key = _secret_bytes(secret)
msg = struct.pack('>Q', counter)
digest = hmac.new(key, msg, hashlib.sha1).digest()
offset = digest[-1] & 0x0F
code_int = struct.unpack('>I', digest[offset:offset + 4])[0] & 0x7FFFFFFF
return str(code_int % (10**digits)).zfill(digits)
def verify_totp_token(secret: str, token: str, *, for_time: int, digits: int = 6, period: int = 30, window: int = 1) -> bool:
normalized = normalize_totp_token(token)
if len(normalized) != digits:
return False
for offset in range(-window, window + 1):
candidate_time = for_time + (offset * period)
if generate_totp_token(secret, candidate_time, digits=digits, period=period) == normalized:
return True
return False
def build_otpauth_uri(secret: str, *, account_name: str, issuer: str) -> str:
label = quote(f'{issuer}:{account_name}')
issuer_q = quote(issuer)
return f'otpauth://totp/{label}?secret={secret}&issuer={issuer_q}&algorithm=SHA1&digits=6&period=30'

View File

@@ -24,7 +24,7 @@ from django.utils.translation import gettext as _, gettext_lazy
from django.utils.translation import get_language, override
from django.urls import reverse
from .app_registry import build_portal_app_sections, get_portal_app_registry_rows
from .app_registry import build_portal_app_sections, get_portal_app_registry_rows, normalize_portal_app_sort_orders
from .backup_ops import (
create_backup_bundle,
delete_backup_bundle,
@@ -33,7 +33,7 @@ from .backup_ops import (
verify_backup_bundle,
)
from .branding import get_branding_email_copy, get_company_email_domain, get_default_notification_templates, get_portal_trial_config, is_trial_expired
from .forms import AccountAvatarForm, AccountDetailsForm, OffboardingRequestForm, OnboardingRequestForm, PortalBrandingForm, PortalCompanyConfigForm, PortalTrialConfigForm, UserManagementCreateForm
from .forms import AccountAvatarForm, AccountDetailsForm, AccountTOTPDisableForm, AccountTOTPEnableForm, OffboardingRequestForm, OnboardingRequestForm, PortalBrandingForm, PortalCompanyConfigForm, PortalTrialConfigForm, UserManagementCreateForm
from .form_builder import (
DEFAULT_FIELD_ORDER,
LOCKED_FIELD_RULES,
@@ -46,6 +46,7 @@ from .models import AdminAuditLog, AsyncTaskLog, EmployeeProfile, FormFieldConfi
from .emailing import send_system_email
from .roles import ROLE_GROUP_NAMES, ROLE_LABELS, ROLE_PLATFORM_OWNER, 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 .totp import build_otpauth_uri, generate_totp_secret
from .tasks import (
_generate_onboarding_intro_pdf,
_generate_onboarding_intro_session_pdf,
@@ -128,10 +129,22 @@ def healthz(request):
@login_required
def account_profile_page(request):
session_secret_key = 'account_totp_pending_secret'
profile, created = UserProfile.objects.get_or_create(user=request.user)
pending_totp_secret = request.session.get(session_secret_key) or ''
if profile.totp_enabled:
pending_totp_secret = ''
request.session.pop(session_secret_key, None)
elif not pending_totp_secret:
pending_totp_secret = generate_totp_secret()
request.session[session_secret_key] = pending_totp_secret
avatar_form = AccountAvatarForm(instance=profile)
details_form = AccountDetailsForm(user=request.user, profile=profile)
totp_enable_form = AccountTOTPEnableForm(user=request.user, secret=pending_totp_secret)
totp_disable_form = AccountTOTPDisableForm(user=request.user, profile=profile)
account_edit_open = False
totp_edit_open = False
if request.method == 'POST':
form_kind = (request.POST.get('account_form') or '').strip()
if form_kind == 'avatar':
@@ -149,6 +162,28 @@ def account_profile_page(request):
messages.success(request, _('Profildaten gespeichert.'))
return redirect('account_profile_page')
messages.error(request, _('Profildaten konnten nicht gespeichert werden.'))
elif form_kind == 'totp_enable':
totp_edit_open = True
totp_enable_form = AccountTOTPEnableForm(request.POST, user=request.user, secret=pending_totp_secret)
if totp_enable_form.is_valid():
profile.enable_totp(pending_totp_secret)
request.session.pop(session_secret_key, None)
messages.success(request, _('TOTP wurde aktiviert.'))
return redirect('account_profile_page')
messages.error(request, _('TOTP konnte nicht aktiviert werden.'))
elif form_kind == 'totp_disable':
totp_edit_open = True
totp_disable_form = AccountTOTPDisableForm(request.POST, user=request.user, profile=profile)
if totp_disable_form.is_valid():
profile.disable_totp()
request.session.pop(session_secret_key, None)
messages.success(request, _('TOTP wurde deaktiviert.'))
return redirect('account_profile_page')
messages.error(request, _('TOTP konnte nicht deaktiviert werden.'))
branding_context = get_branding_email_copy()
totp_account_name = (request.user.email or request.user.username or '').strip()
totp_issuer = (branding_context.get('portal_title') or branding_context.get('company_name') or 'Workdock').strip()
return render(
request,
'workflows/account_profile.html',
@@ -157,8 +192,13 @@ def account_profile_page(request):
'account_user_profile': profile,
'avatar_form': avatar_form,
'details_form': details_form,
'totp_enable_form': totp_enable_form,
'totp_disable_form': totp_disable_form,
'account_edit_open': account_edit_open,
'totp_edit_open': totp_edit_open,
'role_label': get_user_role_label(request.user),
'totp_pending_secret': pending_totp_secret,
'totp_otpauth_uri': '' if profile.totp_enabled else build_otpauth_uri(pending_totp_secret, account_name=totp_account_name, issuer=totp_issuer),
},
)
@@ -426,6 +466,7 @@ def job_monitor_page(request):
@require_POST
def save_portal_app_registry(request):
rows = get_portal_app_registry_rows()
updated_configs = []
for row in rows:
config = row['config']
key = config.key
@@ -448,6 +489,9 @@ def save_portal_app_registry(request):
config.action_label_override = (request.POST.get(f'action_label_override__{key}') or '').strip()
config.action_label_override_en = (request.POST.get(f'action_label_override_en__{key}') or '').strip()
config.save()
updated_configs.append(config)
normalize_portal_app_sort_orders()
_audit(
request,
@@ -607,6 +651,8 @@ def portal_branding_page(request):
{
'form': form,
'branding': branding,
'branding_sections': _build_branding_sections(form, branding),
'editing_branding_section': '',
},
)
@@ -615,7 +661,18 @@ def portal_branding_page(request):
@require_POST
def save_portal_branding(request):
branding, created = PortalBranding.objects.get_or_create(name='Default')
form = PortalBrandingForm(request.POST, request.FILES, instance=branding)
section_key = (request.POST.get('section_key') or '').strip()
data = request.POST.copy()
for field_name in PortalBrandingForm.Meta.fields:
if field_name not in data:
field = PortalBranding._meta.get_field(field_name)
if getattr(field, 'many_to_many', False):
continue
if getattr(field, 'null', False) and getattr(branding, field_name, None) is None:
data[field_name] = ''
else:
data[field_name] = getattr(branding, field_name, '') or ''
form = PortalBrandingForm(data, request.FILES, instance=branding)
if not form.is_valid():
messages.error(request, _('Branding konnte nicht gespeichert werden. Bitte prüfen Sie die Eingaben.'))
return render(
@@ -624,6 +681,8 @@ def save_portal_branding(request):
{
'form': form,
'branding': branding,
'branding_sections': _build_branding_sections(form, branding),
'editing_branding_section': section_key,
},
status=400,
)
@@ -650,10 +709,76 @@ def save_portal_branding(request):
{
'form': PortalBrandingForm(instance=branding),
'branding': branding,
'branding_sections': _build_branding_sections(PortalBrandingForm(instance=branding), branding),
'editing_branding_section': '',
},
)
def _build_branding_sections(form, branding):
sections = [
{
'key': 'identity',
'title': _('Identität'),
'subtitle': _('Titel, Firmenname und zentrale Spracheinstellungen.'),
'fields': ['portal_title', 'company_name', 'company_domain', 'default_language', 'login_subtitle'],
'field_full': {'login_subtitle'},
'hint_map': {
'company_domain': _('Wird für E-Mail-Vorschläge und Domain-bezogene Standardtexte verwendet, z. B. tub.co.'),
},
},
{
'key': 'appearance',
'title': _('Farben & Erscheinungsbild'),
'subtitle': _('Zentrale visuelle Markenwerte und Browser-Icon.'),
'fields': ['primary_color', 'secondary_color', 'logo_image', 'favicon_image'],
'field_full': set(),
'hint_map': {
'logo_image': _('Erlaubte Formate: SVG, PNG, JPG, JPEG, WEBP. Maximal 5 MB.'),
'favicon_image': _('Erlaubte Formate: ICO, PNG, SVG, WEBP. Maximal 2 MB.'),
},
},
{
'key': 'communication',
'title': _('Kommunikation'),
'subtitle': _('Absender, Support und PDF-Branding für ausgehende Kommunikation.'),
'fields': ['support_email', 'sender_display_name', 'pdf_letterhead'],
'field_full': {'pdf_letterhead'},
'hint_map': {
'sender_display_name': _('Wird für ausgehende System-E-Mails als Anzeigename verwendet.'),
'pdf_letterhead': _('Erlaubtes Format: PDF. Maximal 10 MB.'),
},
},
{
'key': 'legal',
'title': _('Footer & Rechtliches'),
'subtitle': _('Gemeinsame Footer-Texte und rechtliche Hinweise für die Shell.'),
'fields': ['footer_text', 'legal_notice', 'footer_text_en', 'legal_notice_en'],
'field_full': {'legal_notice', 'legal_notice_en'},
'hint_map': {},
},
]
for section in sections:
rows = []
for field_name in section['fields']:
field = form[field_name]
value = getattr(branding, field_name, '') or ''
is_file = bool(getattr(field.field.widget, 'input_type', '') == 'file')
rows.append(
{
'name': field_name,
'bound_field': field,
'label': field.label,
'value': value,
'is_file': is_file,
'is_full': field_name in section.get('field_full', set()),
'hint': section.get('hint_map', {}).get(field_name, ''),
}
)
section['rows'] = rows
return sections
@_require_capability('manage_company_config')
def portal_company_config_page(request):
company_config, created = PortalCompanyConfig.objects.get_or_create(name='Default')
@@ -664,6 +789,8 @@ def portal_company_config_page(request):
{
'form': form,
'company_config': company_config,
'company_config_sections': _build_company_config_sections(form, company_config),
'editing_company_section': '',
},
)
@@ -672,7 +799,12 @@ def portal_company_config_page(request):
@require_POST
def save_portal_company_config(request):
company_config, created = PortalCompanyConfig.objects.get_or_create(name='Default')
form = PortalCompanyConfigForm(request.POST, instance=company_config)
section_key = (request.POST.get('section_key') or '').strip()
data = request.POST.copy()
for field_name in PortalCompanyConfigForm.Meta.fields:
if field_name not in data:
data[field_name] = getattr(company_config, field_name, '') or ''
form = PortalCompanyConfigForm(data, instance=company_config)
if not form.is_valid():
messages.error(request, _('Firmenkonfiguration konnte nicht gespeichert werden. Bitte prüfen Sie die Eingaben.'))
return render(
@@ -681,6 +813,8 @@ def save_portal_company_config(request):
{
'form': form,
'company_config': company_config,
'company_config_sections': _build_company_config_sections(form, company_config),
'editing_company_section': section_key,
},
status=400,
)
@@ -708,10 +842,56 @@ def save_portal_company_config(request):
{
'form': PortalCompanyConfigForm(instance=company_config),
'company_config': company_config,
'company_config_sections': _build_company_config_sections(PortalCompanyConfigForm(instance=company_config), company_config),
'editing_company_section': '',
},
)
def _build_company_config_sections(form, company_config):
sections = [
{
'key': 'profile',
'title': _('Firmenprofil'),
'subtitle': _('Rechtlicher Name und zentrale Stammdaten der Firma.'),
'fields': ['legal_company_name', 'phone_number', 'website_url', 'country'],
},
{
'key': 'address',
'title': _('Adresse & Register'),
'subtitle': _('Anschrift sowie optionale Register- und Steuerangaben.'),
'fields': ['street_address', 'postal_code', 'city', 'registration_number', 'vat_id'],
},
{
'key': 'contacts',
'title': _('Kontaktpunkte'),
'subtitle': _('Zentrale Ansprechpartner für HR, IT und Operations.'),
'fields': ['hr_contact_email', 'it_contact_email', 'operations_contact_email'],
},
{
'key': 'public',
'title': _('Recht & Öffentlichkeit'),
'subtitle': _('Öffentliche Links für Website, Impressum und Datenschutz.'),
'fields': ['imprint_url', 'privacy_url'],
'hint': _('Diese Links können später im Portal-Footer oder in öffentlichen Seiten verwendet werden.'),
},
]
for section in sections:
rows = []
for field_name in section['fields']:
field = form[field_name]
rows.append(
{
'name': field_name,
'bound_field': field,
'label': field.label,
'value': getattr(company_config, field_name, '') or '',
}
)
section['rows'] = rows
return sections
@_require_capability('manage_trial_lifecycle')
def portal_trial_config_page(request):
trial_config = get_portal_trial_config()