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. 3. Start as single-tenant configurable, not full multi-tenant.
4. Make branding and document identity admin-managed, not code-managed. 4. Make branding and document identity admin-managed, not code-managed.
5. Add new business apps only after the core platform layer is standardized. 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 ## Product Layers
@@ -213,3 +214,35 @@ This is the first productization slice because it gives:
- keep migrations backward-compatible - keep migrations backward-compatible
- update both wiki and developer handbook for every architecture change - update both wiki and developer handbook for every architecture change
- snapshot at the end of each major phase - 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), '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]]: 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 datetime import timedelta
from django.contrib.auth import get_user_model, password_validation from django.contrib.auth import get_user_model, password_validation
from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm, PasswordResetForm, SetPasswordForm from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm, PasswordResetForm, SetPasswordForm
from django.core.exceptions import ValidationError
from django.utils import timezone from django.utils import timezone
from django.utils.translation import get_language, gettext as _, gettext_lazy 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 .form_builder import apply_form_field_config
from .models import EmployeeProfile, FormOption, OffboardingRequest, OnboardingRequest, PortalBranding, PortalCompanyConfig, PortalTrialConfig, UserProfile, WorkflowConfig 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 .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')] YES_NO_CHOICES = [('', '--'), ('ja', 'Ja'), ('nein', 'Nein')]
@@ -103,6 +105,38 @@ SOFTWARE_EXTRA_CHOICES = [('Adobe Acrobat Pro (Abonnement: Zusätzliche Kosten)'
class AppAuthenticationForm(AuthenticationForm): class AppAuthenticationForm(AuthenticationForm):
username = forms.CharField(label=gettext_lazy('Benutzername')) username = forms.CharField(label=gettext_lazy('Benutzername'))
password = forms.CharField(label=gettext_lazy('Passwort'), strip=False, widget=forms.PasswordInput(attrs={'autocomplete': 'current-password'})) 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): class AppPasswordResetForm(PasswordResetForm):
@@ -221,6 +255,71 @@ class AccountDetailsForm(forms.Form):
return self.user, self.profile 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): class UserManagementCreateForm(forms.Form):
first_name = forms.CharField(label=_('Vorname'), max_length=150, required=False) first_name = forms.CharField(label=_('Vorname'), max_length=150, required=False)
last_name = forms.CharField(label=_('Nachname'), 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.core.validators import FileExtensionValidator
from django.db import models from django.db import models
from django.utils.translation import get_language from django.utils.translation import get_language
from django.utils import timezone
from django.utils.translation import gettext_lazy as _ 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='') department = models.CharField(max_length=255, blank=True, default='')
location = 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='') 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) updated_at = models.DateTimeField(auto_now=True)
class Meta: class Meta:
@@ -48,6 +52,18 @@ class UserProfile(models.Model):
def __str__(self) -> str: def __str__(self) -> str:
return getattr(self.user, 'username', '') or str(self.user_id) 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): class PortalBranding(models.Model):
name = models.CharField(max_length=80, default='Default', unique=True) name = models.CharField(max_length=80, default='Default', unique=True)

View File

@@ -298,6 +298,34 @@ body {
margin-bottom: 18px; 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 { .account-action-card {
display: grid; display: grid;
gap: 6px; gap: 6px;

View File

@@ -12,6 +12,19 @@ h1 { margin: 12px 0 6px; color: #000078; }
.branding-block-head { margin-bottom: 12px; } .branding-block-head { margin-bottom: 12px; }
.branding-block-head h2 { margin: 0; color: #17345e; font-size: 18px; } .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-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-pairs { align-items: start; }
.lang-block { border: 1px solid #d9e4f1; border-radius: 14px; background: rgba(255,255,255,0.82); padding: 12px; } .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; } .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 { 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: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-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[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-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 { 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-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-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::-webkit-details-marker { display: none; }
.app-registry-summary::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-main { min-width: 0; }
.app-registry-summary-meta { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; align-items: center; } .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; } .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; } .actions { white-space: nowrap; }
@media (max-width: 760px) { @media (max-width: 760px) {
.grid { grid-template-columns: 1fr; } .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-summary-grid { grid-template-columns: 1fr 1fr; }
.trial-expired-shell { padding: 20px 16px 28px; } .trial-expired-shell { padding: 20px 16px 28px; }
.trial-expired-card { padding: 18px; } .trial-expired-card { padding: 18px; }
@@ -200,3 +219,9 @@ th { background: #f6f9ff; color: #334155; }
.app-registry-copy-panel { grid-column: auto; } .app-registry-copy-panel { grid-column: auto; }
.app-registry-savebar { align-items: stretch; flex-direction: column; } .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> <span>{% trans "Aktualisieren Sie Ihr Passwort direkt im Konto." %}</span>
</a> </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"> <div class="account-action-card account-action-card-muted">
<strong>{% trans "Sitzung" %}</strong> <strong>{% trans "Sitzung" %}</strong>
<span>{% trans "Sie können sich jederzeit sicher vom aktuellen Gerät abmelden." %}</span> <span>{% trans "Sie können sich jederzeit sicher vom aktuellen Gerät abmelden." %}</span>
</div> </div>
</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"> <div class="account-actions">
<a class="btn btn-primary" href="{% url 'password_change' %}">{% trans "Passwort ändern" %}</a> <a class="btn btn-primary" href="{% url 'password_change' %}">{% trans "Passwort ändern" %}</a>
<form method="post" action="{% url 'logout' %}"> <form method="post" action="{% url 'logout' %}">

View File

@@ -1,6 +1,7 @@
{% extends 'workflows/base_shell.html' %} {% extends 'workflows/base_shell.html' %}
{% load static i18n %} {% load static i18n %}
{% trans "Ungespeicherte Änderungen" as dirty_state_label %} {% trans "Ungespeicherte Änderungen" as dirty_state_label %}
{% trans "Sortierung" as sort_label %}
{% block title %}{% trans "App Registry" %}{% endblock %} {% block title %}{% trans "App Registry" %}{% endblock %}
@@ -40,150 +41,184 @@
<label for="app-registry-section">{% trans "Bereich" %}</label> <label for="app-registry-section">{% trans "Bereich" %}</label>
<select id="app-registry-section"> <select id="app-registry-section">
<option value="all">{% trans "Alle" %}</option> <option value="all">{% trans "Alle" %}</option>
<option value="apps">{% trans "Apps" %}</option> <option value="app">{% trans "Apps" %}</option>
<option value="platform_apps">{% trans "Platform Apps" %}</option> <option value="platform">{% trans "Platform Apps" %}</option>
<option value="admin_apps">{% trans "Admin Apps" %}</option> <option value="admin">{% trans "Admin Apps" %}</option>
</select> </select>
</div> </div>
</section> </section>
<div class="app-registry-cards"> <div class="hint" id="app-registry-reorder-hint">{% trans "Für eine verlässliche Reihenfolge bitte ohne aktive Filter umsortieren." %}</div>
{% for row in rows %} <div class="app-registry-groups">
<details class="app-registry-card{% if not row.config.is_enabled %} is-disabled{% endif %}" data-app-card {% for section_key, section_label in section_choices %}
data-key="{{ row.config.key|lower }}" <section class="app-registry-group" data-app-group="{{ section_key }}">
data-title="{{ row.definition.title|lower }}" <div class="app-registry-group-head">
data-enabled="{% if row.config.is_enabled %}1{% else %}0{% endif %}" <div>
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 %}" <h2>{{ section_label }}</h2>
data-section="{{ row.config.section }}"> <p class="mini">
<summary class="app-registry-summary"> {% if section_key == 'platform' %}
<div class="app-registry-summary-main"> {% trans "Produktweite Steuerung und nur für die Platform sichtbare Oberflächen." %}
<div class="app-registry-card-title-row"> {% elif section_key == 'admin' %}
<h2>{{ row.definition.title }}</h2> {% trans "Administrative Apps für Kundenrollen mit erhöhter Verantwortung." %}
{% if row.config.is_enabled %}
<span class="badge sent">{% trans "Aktiv" %}</span>
{% else %} {% else %}
<span class="badge cancelled">{% trans "Deaktiviert" %}</span> {% trans "Operative Apps, die im täglichen Einsatz auf der Landing Page erscheinen." %}
{% endif %} {% endif %}
</div> </p>
<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>
<div class="app-registry-summary-meta"> <span class="badge scheduled" data-app-group-count>{{ section_label }}</span>
<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>
</div> </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 %} {% endfor %}
</div> </div>
<div class="app-registry-savebar"> <div class="app-registry-savebar">
@@ -204,13 +239,46 @@
const stateSelect = document.getElementById('app-registry-state'); const stateSelect = document.getElementById('app-registry-state');
const sectionSelect = document.getElementById('app-registry-section'); const sectionSelect = document.getElementById('app-registry-section');
const cards = Array.from(document.querySelectorAll('[data-app-card]')); 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 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 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() { function applyFilters() {
const query = (searchInput?.value || '').trim().toLowerCase(); const query = (searchInput?.value || '').trim().toLowerCase();
const state = stateSelect?.value || 'all'; const state = stateSelect?.value || 'all';
const section = sectionSelect?.value || 'all'; const section = sectionSelect?.value || 'all';
const filterActive = Boolean(query) || state !== 'all' || section !== 'all';
cards.forEach((card) => { cards.forEach((card) => {
const matchesQuery = !query || card.dataset.key.includes(query) || card.dataset.title.includes(query); const matchesQuery = !query || card.dataset.key.includes(query) || card.dataset.title.includes(query);
@@ -221,20 +289,116 @@
(state === 'platform_only' && card.dataset.platformOnly === '1'); (state === 'platform_only' && card.dataset.platformOnly === '1');
const matchesSection = section === 'all' || card.dataset.section === section; const matchesSection = section === 'all' || card.dataset.section === section;
card.hidden = !(matchesQuery && matchesState && matchesSection); 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() { function closestCardAfterPointer(container, clientY) {
if (!dirtyState) return; const siblingCards = Array.from(container.querySelectorAll('[data-app-card]:not(.is-dragging)'));
dirtyState.textContent = "{{ dirty_state_label|escapejs }}"; return siblingCards.find((card) => {
const rect = card.getBoundingClientRect();
return clientY < rect.top + rect.height / 2;
}) || null;
} }
searchInput?.addEventListener('input', applyFilters); function markDirty() {
stateSelect?.addEventListener('change', applyFilters); if (dirtyState) {
sectionSelect?.addEventListener('change', applyFilters); dirtyState.textContent = '{{ dirty_state_label|escapejs }}';
form?.addEventListener('input', markDirty); }
form?.addEventListener('change', markDirty); }
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(); applyFilters();
})(); })();
</script> </script>

View File

@@ -26,12 +26,16 @@
<div class="app-alert app-alert-error" role="alert" aria-live="assertive"> <div class="app-alert app-alert-error" role="alert" aria-live="assertive">
<div class="app-alert-body"> <div class="app-alert-body">
<strong>{% trans "Anmeldung fehlgeschlagen" %}</strong><br /> <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>
</div> </div>
{% endif %} {% endif %}
<div class="field{% if form.errors %} has-error{% endif %}">{{ form.username.label_tag }}{{ form.username }}</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.errors %} has-error{% endif %}">{{ form.password.label_tag }}{{ form.password }}</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> <button class="btn btn-primary" type="submit">{% trans "Anmelden" %}</button>
</form> </form>
</div> </div>

View File

@@ -15,71 +15,66 @@
{% include 'workflows/includes/messages.html' %} {% include 'workflows/includes/messages.html' %}
<section class="card"> <section class="card">
<form method="post" action="{% url 'save_portal_branding' %}" enctype="multipart/form-data" class="stack-form"> <div class="branding-sections">
{% csrf_token %} {% for section in branding_sections %}
<div class="branding-sections"> <section class="branding-block branding-inline-block" data-branding-section="{{ section.key }}">
<section class="branding-block"> <div class="branding-block-head branding-inline-head">
<div class="branding-block-head"> <div>
<h2>{% trans "Identität" %}</h2> <h2>{{ section.title }}</h2>
<p>{% trans "Titel, Firmenname und zentrale Spracheinstellungen." %}</p> <p>{{ section.subtitle }}</p>
</div> </div>
<div class="grid two"> <button
<div class="field"> class="btn btn-secondary branding-inline-trigger"
<label for="{{ form.portal_title.id_for_label }}">{{ form.portal_title.label }}</label> type="button"
{{ form.portal_title }} data-branding-edit-toggle="{{ section.key }}"
</div> aria-expanded="{% if editing_branding_section == section.key %}true{% else %}false{% endif %}"
<div class="field"> >
<label for="{{ form.company_name.id_for_label }}">{{ form.company_name.label }}</label> {% trans "Bearbeiten" %}
{{ form.company_name }} </button>
</div> </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>
<section class="branding-block"> <div class="branding-inline-view{% if editing_branding_section == section.key %} is-hidden{% endif %}" data-branding-edit-view="{{ section.key }}">
<div class="branding-block-head"> {% if section.key == 'legal' %}
<h2>{% trans "Farben & Erscheinungsbild" %}</h2> <div class="grid two lang-pairs">
<p>{% trans "Zentrale visuelle Markenwerte und Browser-Icon." %}</p> <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> </div>
{% else %}
<div class="grid two"> <div class="grid two">
<div class="field"> {% for row in section.rows %}
<label for="{{ form.primary_color.id_for_label }}">{{ form.primary_color.label }}</label> <div class="field{% if row.is_full %} field-full{% endif %}">
{{ form.primary_color }} <label>{{ row.label }}</label>
</div> <div class="branding-inline-value">
<div class="field"> {% if row.is_file %}
<label for="{{ form.secondary_color.id_for_label }}">{{ form.secondary_color.label }}</label> {% if row.value %}
{{ form.secondary_color }} <a href="{{ row.value.url }}" target="_blank" rel="noopener">{% trans "öffnen" %}</a>
</div> {% else %}
<div class="field"> -
<label for="{{ form.logo_image.id_for_label }}">{{ form.logo_image.label }}</label> {% endif %}
{{ form.logo_image }} {% else %}
<div class="hint">{% trans "Erlaubte Formate: SVG, PNG, JPG, JPEG, WEBP. Maximal 5 MB." %}</div> {{ row.value|default:"-" }}
{% for error in form.logo_image.errors %}<div class="hint">{{ error }}</div>{% endfor %} {% endif %}
{% if branding.logo_image %} </div>
<div class="hint">{% trans "Aktuelles Logo:" %} <a href="{{ branding.logo_image.url }}" target="_blank" rel="noopener">{% trans "öffnen" %}</a></div> {% if row.hint %}<div class="hint">{{ row.hint }}</div>{% endif %}
{% 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 %}
</div> </div>
{% endfor %}
{% if section.key == 'appearance' %}
<div class="field field-full"> <div class="field field-full">
<div class="branding-preview" id="branding-preview" data-default-logo="{{ portal_logo_url }}"> <div class="branding-preview" id="branding-preview" data-default-logo="{{ portal_logo_url }}">
<div class="branding-preview-shell"> <div class="branding-preview-shell">
@@ -101,78 +96,121 @@
</div> </div>
</div> </div>
</div> </div>
{% endif %}
</div> </div>
</section> {% endif %}
</div>
<section class="branding-block"> <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 }}">
<div class="branding-block-head"> {% csrf_token %}
<h2>{% trans "Kommunikation" %}</h2> <input type="hidden" name="section_key" value="{{ section.key }}" />
<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>
<section class="branding-block"> {% if section.key == 'legal' %}
<div class="branding-block-head">
<h2>{% trans "Footer & Rechtliches" %}</h2>
<p>{% trans "Gemeinsame Footer-Texte und rechtliche Hinweise für die Shell." %}</p>
</div>
<div class="grid two lang-pairs"> <div class="grid two lang-pairs">
<div class="lang-block"> <div class="lang-block">
<h3>{% trans "Deutsch" %}</h3> <h3>{% trans "Deutsch" %}</h3>
<div class="field"> {% for row in section.rows|slice:":2" %}
<label for="{{ form.footer_text.id_for_label }}">{{ form.footer_text.label }}</label> <div class="field{% if row.is_full %} field-full{% endif %}{% if row.bound_field.errors %} has-error{% endif %}">
{{ form.footer_text }} <label for="{{ row.bound_field.id_for_label }}">{{ row.label }}</label>
</div> {{ row.bound_field }}
<div class="field"> {% if row.bound_field.errors %}<div class="branding-inline-error">{{ row.bound_field.errors|join:", " }}</div>{% endif %}
<label for="{{ form.legal_notice.id_for_label }}">{{ form.legal_notice.label }}</label>
{{ form.legal_notice }}
</div> </div>
{% endfor %}
</div> </div>
<div class="lang-block"> <div class="lang-block">
<h3>{% trans "English" %}</h3> <h3>{% trans "English" %}</h3>
<div class="field"> {% for row in section.rows|slice:"2:" %}
<label for="{{ form.footer_text_en.id_for_label }}">{{ form.footer_text_en.label }}</label> <div class="field{% if row.is_full %} field-full{% endif %}{% if row.bound_field.errors %} has-error{% endif %}">
{{ form.footer_text_en }} <label for="{{ row.bound_field.id_for_label }}">{{ row.label }}</label>
</div> {{ row.bound_field }}
<div class="field"> {% if row.bound_field.errors %}<div class="branding-inline-error">{{ row.bound_field.errors|join:", " }}</div>{% endif %}
<label for="{{ form.legal_notice_en.id_for_label }}">{{ form.legal_notice_en.label }}</label>
{{ form.legal_notice_en }}
</div> </div>
{% endfor %}
</div> </div>
</div> </div>
</section> {% else %}
</div> <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="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> <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> </div>
</form> </div>
</section> </section>
{% endblock %} {% endblock %}
{% block extra_scripts %} {% block extra_scripts %}
<script> <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 byId = (id) => document.getElementById(id);
const title = byId('{{ form.portal_title.id_for_label }}'); const title = byId('{{ form.portal_title.id_for_label }}');
const company = byId('{{ form.company_name.id_for_label }}'); const company = byId('{{ form.company_name.id_for_label }}');

View File

@@ -15,106 +15,75 @@
{% include 'workflows/includes/messages.html' %} {% include 'workflows/includes/messages.html' %}
<section class="branding-sections"> <section class="branding-sections">
<form method="post" action="{% url 'save_portal_company_config' %}" class="stack-form"> {% for section in company_config_sections %}
{% csrf_token %} <section class="branding-block company-inline-block" data-company-section="{{ section.key }}">
<div class="branding-block-head company-inline-head">
<section class="branding-block"> <div>
<div class="branding-block-head"> <h2>{{ section.title }}</h2>
<h2>{% trans "Firmenprofil" %}</h2> <p>{{ section.subtitle }}</p>
<p>{% trans "Rechtlicher Name und zentrale Stammdaten der Firma." %}</p>
</div> </div>
<div class="grid"> <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 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>
</div> </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> </section>
{% endblock %} {% 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 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 %} {% block extra_css %}
<link rel="stylesheet" href="{% static 'workflows/css/home.css' %}" /> <link rel="stylesheet" href="{% static 'workflows/css/home.css' %}" />
{% endblock %} {% endblock %}
{% block shell_body %} {% block shell_body %}
{% include 'workflows/includes/app_header.html' with header_show_lang=1 %}
<div class="hero"> <div class="hero">
<div class="hero-grid"> <div class="hero-grid">
<div class="hero-card"> <div class="hero-card">

View File

@@ -1,7 +1,9 @@
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.test import Client, TestCase from django.test import Client, TestCase
from django.utils import timezone
from workflows.models import UserProfile from workflows.models import UserProfile
from workflows.totp import generate_totp_token
class AccountUISmokeTests(TestCase): class AccountUISmokeTests(TestCase):
@@ -55,3 +57,59 @@ class AccountUISmokeTests(TestCase):
self.assertEqual(self.user.email, 'updated@example.com') self.assertEqual(self.user.email, 'updated@example.com')
self.assertEqual(profile.phone_number, '030 123456') self.assertEqual(profile.phone_number, '030 123456')
self.assertEqual(profile.job_title, 'IT Manager') 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.utils.translation import get_language, override
from django.urls import reverse 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 ( from .backup_ops import (
create_backup_bundle, create_backup_bundle,
delete_backup_bundle, delete_backup_bundle,
@@ -33,7 +33,7 @@ from .backup_ops import (
verify_backup_bundle, 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 .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 ( from .form_builder import (
DEFAULT_FIELD_ORDER, DEFAULT_FIELD_ORDER,
LOCKED_FIELD_RULES, LOCKED_FIELD_RULES,
@@ -46,6 +46,7 @@ from .models import AdminAuditLog, AsyncTaskLog, EmployeeProfile, FormFieldConfi
from .emailing import send_system_email 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 .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 .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 ( from .tasks import (
_generate_onboarding_intro_pdf, _generate_onboarding_intro_pdf,
_generate_onboarding_intro_session_pdf, _generate_onboarding_intro_session_pdf,
@@ -128,10 +129,22 @@ def healthz(request):
@login_required @login_required
def account_profile_page(request): def account_profile_page(request):
session_secret_key = 'account_totp_pending_secret'
profile, created = UserProfile.objects.get_or_create(user=request.user) 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) avatar_form = AccountAvatarForm(instance=profile)
details_form = AccountDetailsForm(user=request.user, profile=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 account_edit_open = False
totp_edit_open = False
if request.method == 'POST': if request.method == 'POST':
form_kind = (request.POST.get('account_form') or '').strip() form_kind = (request.POST.get('account_form') or '').strip()
if form_kind == 'avatar': if form_kind == 'avatar':
@@ -149,6 +162,28 @@ def account_profile_page(request):
messages.success(request, _('Profildaten gespeichert.')) messages.success(request, _('Profildaten gespeichert.'))
return redirect('account_profile_page') return redirect('account_profile_page')
messages.error(request, _('Profildaten konnten nicht gespeichert werden.')) 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( return render(
request, request,
'workflows/account_profile.html', 'workflows/account_profile.html',
@@ -157,8 +192,13 @@ def account_profile_page(request):
'account_user_profile': profile, 'account_user_profile': profile,
'avatar_form': avatar_form, 'avatar_form': avatar_form,
'details_form': details_form, 'details_form': details_form,
'totp_enable_form': totp_enable_form,
'totp_disable_form': totp_disable_form,
'account_edit_open': account_edit_open, 'account_edit_open': account_edit_open,
'totp_edit_open': totp_edit_open,
'role_label': get_user_role_label(request.user), '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 @require_POST
def save_portal_app_registry(request): def save_portal_app_registry(request):
rows = get_portal_app_registry_rows() rows = get_portal_app_registry_rows()
updated_configs = []
for row in rows: for row in rows:
config = row['config'] config = row['config']
key = config.key 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 = (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.action_label_override_en = (request.POST.get(f'action_label_override_en__{key}') or '').strip()
config.save() config.save()
updated_configs.append(config)
normalize_portal_app_sort_orders()
_audit( _audit(
request, request,
@@ -607,6 +651,8 @@ def portal_branding_page(request):
{ {
'form': form, 'form': form,
'branding': branding, 'branding': branding,
'branding_sections': _build_branding_sections(form, branding),
'editing_branding_section': '',
}, },
) )
@@ -615,7 +661,18 @@ def portal_branding_page(request):
@require_POST @require_POST
def save_portal_branding(request): def save_portal_branding(request):
branding, created = PortalBranding.objects.get_or_create(name='Default') 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(): if not form.is_valid():
messages.error(request, _('Branding konnte nicht gespeichert werden. Bitte prüfen Sie die Eingaben.')) messages.error(request, _('Branding konnte nicht gespeichert werden. Bitte prüfen Sie die Eingaben.'))
return render( return render(
@@ -624,6 +681,8 @@ def save_portal_branding(request):
{ {
'form': form, 'form': form,
'branding': branding, 'branding': branding,
'branding_sections': _build_branding_sections(form, branding),
'editing_branding_section': section_key,
}, },
status=400, status=400,
) )
@@ -650,10 +709,76 @@ def save_portal_branding(request):
{ {
'form': PortalBrandingForm(instance=branding), 'form': PortalBrandingForm(instance=branding),
'branding': 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') @_require_capability('manage_company_config')
def portal_company_config_page(request): def portal_company_config_page(request):
company_config, created = PortalCompanyConfig.objects.get_or_create(name='Default') company_config, created = PortalCompanyConfig.objects.get_or_create(name='Default')
@@ -664,6 +789,8 @@ def portal_company_config_page(request):
{ {
'form': form, 'form': form,
'company_config': company_config, '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 @require_POST
def save_portal_company_config(request): def save_portal_company_config(request):
company_config, created = PortalCompanyConfig.objects.get_or_create(name='Default') 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(): if not form.is_valid():
messages.error(request, _('Firmenkonfiguration konnte nicht gespeichert werden. Bitte prüfen Sie die Eingaben.')) messages.error(request, _('Firmenkonfiguration konnte nicht gespeichert werden. Bitte prüfen Sie die Eingaben.'))
return render( return render(
@@ -681,6 +813,8 @@ def save_portal_company_config(request):
{ {
'form': form, 'form': form,
'company_config': company_config, 'company_config': company_config,
'company_config_sections': _build_company_config_sections(form, company_config),
'editing_company_section': section_key,
}, },
status=400, status=400,
) )
@@ -708,10 +842,56 @@ def save_portal_company_config(request):
{ {
'form': PortalCompanyConfigForm(instance=company_config), 'form': PortalCompanyConfigForm(instance=company_config),
'company_config': 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') @_require_capability('manage_trial_lifecycle')
def portal_trial_config_page(request): def portal_trial_config_page(request):
trial_config = get_portal_trial_config() trial_config = get_portal_trial_config()