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.
|
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
|
||||||
|
|||||||
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),
|
'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]]:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
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.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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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' %}">
|
||||||
|
|||||||
@@ -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,21 +41,42 @@
|
|||||||
<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>
|
||||||
|
<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 %}
|
||||||
|
{% trans "Operative Apps, die im täglichen Einsatz auf der Landing Page erscheinen." %}
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge scheduled" data-app-group-count>{{ section_label }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="app-registry-group-body" data-app-group-body="{{ section_key }}">
|
||||||
{% for row in rows %}
|
{% for row in rows %}
|
||||||
<details class="app-registry-card{% if not row.config.is_enabled %} is-disabled{% endif %}" data-app-card
|
{% 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-key="{{ row.config.key|lower }}"
|
||||||
data-title="{{ row.definition.title|lower }}"
|
data-title="{{ row.definition.title|lower }}"
|
||||||
data-enabled="{% if row.config.is_enabled %}1{% else %}0{% endif %}"
|
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-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 }}">
|
data-section="{{ row.config.section }}">
|
||||||
<summary class="app-registry-summary">
|
<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-summary-main">
|
||||||
<div class="app-registry-card-title-row">
|
<div class="app-registry-card-title-row">
|
||||||
<h2>{{ row.definition.title }}</h2>
|
<h2>{{ row.definition.title }}</h2>
|
||||||
@@ -70,15 +92,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="app-registry-summary-meta">
|
<div class="app-registry-summary-meta">
|
||||||
<span class="badge scheduled">
|
<span class="badge scheduled">
|
||||||
{% if row.config.section == 'platform_apps' %}
|
{% if row.config.section == 'platform' %}
|
||||||
{% trans "Platform Apps" %}
|
{% trans "Platform Apps" %}
|
||||||
{% elif row.config.section == 'admin_apps' %}
|
{% elif row.config.section == 'admin' %}
|
||||||
{% trans "Admin Apps" %}
|
{% trans "Admin Apps" %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% trans "Apps" %}
|
{% trans "Apps" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
<span class="badge">{% trans "Sortierung" %}: {{ row.config.sort_order }}</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 %}
|
{% 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>
|
<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 %}
|
{% 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 %}
|
||||||
@@ -142,7 +164,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="sort_order__{{ row.config.key }}">{% trans "Reihenfolge" %}</label>
|
<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" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -184,6 +215,10 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
|
||||||
{% csrf_token %}
|
|
||||||
<div class="branding-sections">
|
<div class="branding-sections">
|
||||||
<section class="branding-block">
|
{% for section in branding_sections %}
|
||||||
<div class="branding-block-head">
|
<section class="branding-block branding-inline-block" data-branding-section="{{ section.key }}">
|
||||||
<h2>{% trans "Identität" %}</h2>
|
<div class="branding-block-head branding-inline-head">
|
||||||
<p>{% trans "Titel, Firmenname und zentrale Spracheinstellungen." %}</p>
|
<div>
|
||||||
|
<h2>{{ section.title }}</h2>
|
||||||
|
<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 }}"
|
||||||
|
aria-expanded="{% if editing_branding_section == section.key %}true{% else %}false{% endif %}"
|
||||||
|
>
|
||||||
|
{% trans "Bearbeiten" %}
|
||||||
|
</button>
|
||||||
</div>
|
</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>
|
|
||||||
|
|
||||||
<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>
|
</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="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 %}
|
|
||||||
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
{% if row.hint %}<div class="hint">{{ row.hint }}</div>{% endif %}
|
||||||
<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>
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
<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 "Footer & Rechtliches" %}</h2>
|
<input type="hidden" name="section_key" value="{{ section.key }}" />
|
||||||
<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="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>
|
||||||
|
{{ row.bound_field }}
|
||||||
|
{% if row.bound_field.errors %}<div class="branding-inline-error">{{ row.bound_field.errors|join:", " }}</div>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
{% endfor %}
|
||||||
<label for="{{ form.legal_notice_en.id_for_label }}">{{ form.legal_notice_en.label }}</label>
|
</div>
|
||||||
{{ form.legal_notice_en }}
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar" style="margin-top:1.25rem;">
|
{% endif %}
|
||||||
<div class="hint">{% trans "Die aktuell gesetzte Deployment-Branding bleibt erhalten, bis hier Werte geändert oder Dateien hochgeladen werden." %}</div>
|
</div>
|
||||||
<button class="btn btn-primary" type="submit">{% trans "Branding speichern" %}</button>
|
{% 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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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 }}');
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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 %}
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="section_key" value="{{ section.key }}" />
|
||||||
<section class="branding-block">
|
|
||||||
<div class="branding-block-head">
|
|
||||||
<h2>{% trans "Firmenprofil" %}</h2>
|
|
||||||
<p>{% trans "Rechtlicher Name und zentrale Stammdaten der Firma." %}</p>
|
|
||||||
</div>
|
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<div class="field">
|
{% for row in section.rows %}
|
||||||
<label for="{{ form.legal_company_name.id_for_label }}">{{ form.legal_company_name.label }}</label>
|
<div class="field{% if row.name == 'street_address' %} field-full{% endif %}{% if row.bound_field.errors %} has-error{% endif %}">
|
||||||
{{ form.legal_company_name }}
|
<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>
|
</div>
|
||||||
<div class="field">
|
{% endfor %}
|
||||||
<label for="{{ form.phone_number.id_for_label }}">{{ form.phone_number.label }}</label>
|
|
||||||
{{ form.phone_number }}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
{% if section.hint %}<div class="hint">{{ section.hint }}</div>{% endif %}
|
||||||
<label for="{{ form.website_url.id_for_label }}">{{ form.website_url.label }}</label>
|
<div class="company-inline-actions">
|
||||||
{{ form.website_url }}
|
<button class="btn btn-primary" type="submit">{% trans "Speichern" %}</button>
|
||||||
</div>
|
<button class="btn btn-secondary" type="button" data-company-edit-cancel="{{ section.key }}">{% trans "Abbrechen" %}</button>
|
||||||
<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>
|
</form>
|
||||||
</section>
|
</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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
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.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()
|
||||||
|
|||||||
Reference in New Issue
Block a user