snapshot: preserve totp account security baseline
This commit is contained in:
@@ -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
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -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]]:
|
||||
|
||||
@@ -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)
|
||||
|
||||
26
backend/workflows/migrations/0049_userprofile_totp_fields.py
Normal file
26
backend/workflows/migrations/0049_userprofile_totp_fields.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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' %}">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}');
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
49
backend/workflows/totp.py
Normal 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'
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user