snapshot: preserve account security and profile UI cleanup
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -10,3 +10,4 @@ pypdf==5.1.0
|
|||||||
jinja2==3.1.4
|
jinja2==3.1.4
|
||||||
xhtml2pdf==0.2.16
|
xhtml2pdf==0.2.16
|
||||||
gunicorn==23.0.0
|
gunicorn==23.0.0
|
||||||
|
qrcode==8.2
|
||||||
|
|||||||
@@ -11,7 +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
|
from .totp import normalize_recovery_code, normalize_totp_token, verify_totp_token
|
||||||
|
|
||||||
|
|
||||||
YES_NO_CHOICES = [('', '--'), ('ja', 'Ja'), ('nein', 'Nein')]
|
YES_NO_CHOICES = [('', '--'), ('ja', 'Ja'), ('nein', 'Nein')]
|
||||||
@@ -111,6 +111,12 @@ class AppAuthenticationForm(AuthenticationForm):
|
|||||||
max_length=12,
|
max_length=12,
|
||||||
widget=forms.TextInput(attrs={'autocomplete': 'one-time-code', 'inputmode': 'numeric'}),
|
widget=forms.TextInput(attrs={'autocomplete': 'one-time-code', 'inputmode': 'numeric'}),
|
||||||
)
|
)
|
||||||
|
recovery_code = forms.CharField(
|
||||||
|
label=gettext_lazy('Recovery-Code'),
|
||||||
|
required=False,
|
||||||
|
max_length=32,
|
||||||
|
widget=forms.TextInput(attrs={'autocomplete': 'one-time-code'}),
|
||||||
|
)
|
||||||
|
|
||||||
error_messages = {
|
error_messages = {
|
||||||
**AuthenticationForm.error_messages,
|
**AuthenticationForm.error_messages,
|
||||||
@@ -126,6 +132,14 @@ class AppAuthenticationForm(AuthenticationForm):
|
|||||||
profile, _ = UserProfile.objects.get_or_create(user=user)
|
profile, _ = UserProfile.objects.get_or_create(user=user)
|
||||||
if profile.totp_enabled:
|
if profile.totp_enabled:
|
||||||
otp_code = normalize_totp_token(cleaned_data.get('otp_code'))
|
otp_code = normalize_totp_token(cleaned_data.get('otp_code'))
|
||||||
|
recovery_code = normalize_recovery_code(cleaned_data.get('recovery_code'))
|
||||||
|
if recovery_code:
|
||||||
|
if not profile.consume_recovery_code(recovery_code):
|
||||||
|
raise ValidationError(
|
||||||
|
self.error_messages['invalid_otp'],
|
||||||
|
code='invalid_otp',
|
||||||
|
)
|
||||||
|
return cleaned_data
|
||||||
if not otp_code:
|
if not otp_code:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
self.error_messages['missing_otp'],
|
self.error_messages['missing_otp'],
|
||||||
@@ -296,8 +310,15 @@ class AccountTOTPDisableForm(forms.Form):
|
|||||||
verification_code = forms.CharField(
|
verification_code = forms.CharField(
|
||||||
label=gettext_lazy('TOTP-Code'),
|
label=gettext_lazy('TOTP-Code'),
|
||||||
max_length=12,
|
max_length=12,
|
||||||
|
required=False,
|
||||||
widget=forms.TextInput(attrs={'autocomplete': 'one-time-code', 'inputmode': 'numeric'}),
|
widget=forms.TextInput(attrs={'autocomplete': 'one-time-code', 'inputmode': 'numeric'}),
|
||||||
)
|
)
|
||||||
|
recovery_code = forms.CharField(
|
||||||
|
label=gettext_lazy('Recovery-Code'),
|
||||||
|
max_length=32,
|
||||||
|
required=False,
|
||||||
|
widget=forms.TextInput(attrs={'autocomplete': 'one-time-code'}),
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(self, *args, user=None, profile=None, **kwargs):
|
def __init__(self, *args, user=None, profile=None, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
@@ -310,14 +331,66 @@ class AccountTOTPDisableForm(forms.Form):
|
|||||||
raise ValidationError(_('Das aktuelle Passwort ist nicht korrekt.'))
|
raise ValidationError(_('Das aktuelle Passwort ist nicht korrekt.'))
|
||||||
return password
|
return password
|
||||||
|
|
||||||
def clean_verification_code(self):
|
def clean(self):
|
||||||
code = normalize_totp_token(self.cleaned_data.get('verification_code'))
|
cleaned_data = super().clean()
|
||||||
if not code:
|
code = normalize_totp_token(cleaned_data.get('verification_code'))
|
||||||
raise ValidationError(_('Bitte geben Sie einen gültigen TOTP-Code ein.'))
|
recovery_code = normalize_recovery_code(cleaned_data.get('recovery_code'))
|
||||||
|
if not code and not recovery_code:
|
||||||
|
raise ValidationError(_('Bitte geben Sie einen gültigen TOTP-Code oder Recovery-Code ein.'))
|
||||||
secret = getattr(self.profile, 'totp_secret', '') or ''
|
secret = getattr(self.profile, 'totp_secret', '') or ''
|
||||||
if not secret or not verify_totp_token(secret, code, for_time=int(timezone.now().timestamp())):
|
if code:
|
||||||
raise ValidationError(_('Der TOTP-Code ist ungültig.'))
|
if not secret or not verify_totp_token(secret, code, for_time=int(timezone.now().timestamp())):
|
||||||
return code
|
raise ValidationError(_('Der TOTP-Code ist ungültig.'))
|
||||||
|
return cleaned_data
|
||||||
|
if not self.profile.consume_recovery_code(recovery_code):
|
||||||
|
raise ValidationError(_('Der Recovery-Code ist ungültig.'))
|
||||||
|
return cleaned_data
|
||||||
|
|
||||||
|
|
||||||
|
class AccountTOTPRegenerateRecoveryCodesForm(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,
|
||||||
|
required=False,
|
||||||
|
widget=forms.TextInput(attrs={'autocomplete': 'one-time-code', 'inputmode': 'numeric'}),
|
||||||
|
)
|
||||||
|
recovery_code = forms.CharField(
|
||||||
|
label=gettext_lazy('Recovery-Code'),
|
||||||
|
max_length=32,
|
||||||
|
required=False,
|
||||||
|
widget=forms.TextInput(attrs={'autocomplete': 'one-time-code'}),
|
||||||
|
)
|
||||||
|
|
||||||
|
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(self):
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
code = normalize_totp_token(cleaned_data.get('verification_code'))
|
||||||
|
recovery_code = normalize_recovery_code(cleaned_data.get('recovery_code'))
|
||||||
|
if not code and not recovery_code:
|
||||||
|
raise ValidationError(_('Bitte geben Sie einen gültigen TOTP-Code oder Recovery-Code ein.'))
|
||||||
|
secret = getattr(self.profile, 'totp_secret', '') or ''
|
||||||
|
if code:
|
||||||
|
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 cleaned_data
|
||||||
|
if not self.profile.consume_recovery_code(recovery_code):
|
||||||
|
raise ValidationError(_('Der Recovery-Code ist ungültig.'))
|
||||||
|
return cleaned_data
|
||||||
|
|
||||||
|
|
||||||
class UserManagementCreateForm(forms.Form):
|
class UserManagementCreateForm(forms.Form):
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('workflows', '0049_userprofile_totp_fields'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='userprofile',
|
||||||
|
name='totp_recovery_codes',
|
||||||
|
field=models.JSONField(blank=True, default=list),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib.auth.hashers import check_password, make_password
|
||||||
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
|
||||||
@@ -43,6 +44,7 @@ class UserProfile(models.Model):
|
|||||||
totp_secret = models.CharField(max_length=64, blank=True, default='')
|
totp_secret = models.CharField(max_length=64, blank=True, default='')
|
||||||
totp_enabled = models.BooleanField(default=False)
|
totp_enabled = models.BooleanField(default=False)
|
||||||
totp_confirmed_at = models.DateTimeField(null=True, blank=True)
|
totp_confirmed_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
totp_recovery_codes = models.JSONField(default=list, blank=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -56,13 +58,31 @@ class UserProfile(models.Model):
|
|||||||
self.totp_secret = ''
|
self.totp_secret = ''
|
||||||
self.totp_enabled = False
|
self.totp_enabled = False
|
||||||
self.totp_confirmed_at = None
|
self.totp_confirmed_at = None
|
||||||
self.save(update_fields=['totp_secret', 'totp_enabled', 'totp_confirmed_at', 'updated_at'])
|
self.totp_recovery_codes = []
|
||||||
|
self.save(update_fields=['totp_secret', 'totp_enabled', 'totp_confirmed_at', 'totp_recovery_codes', 'updated_at'])
|
||||||
|
|
||||||
def enable_totp(self, secret: str) -> None:
|
def enable_totp(self, secret: str, recovery_codes: list[str]) -> None:
|
||||||
self.totp_secret = secret
|
self.totp_secret = secret
|
||||||
self.totp_enabled = True
|
self.totp_enabled = True
|
||||||
self.totp_confirmed_at = timezone.now()
|
self.totp_confirmed_at = timezone.now()
|
||||||
self.save(update_fields=['totp_secret', 'totp_enabled', 'totp_confirmed_at', 'updated_at'])
|
self.set_recovery_codes(recovery_codes)
|
||||||
|
self.save(update_fields=['totp_secret', 'totp_enabled', 'totp_confirmed_at', 'totp_recovery_codes', 'updated_at'])
|
||||||
|
|
||||||
|
def set_recovery_codes(self, recovery_codes: list[str]) -> None:
|
||||||
|
self.totp_recovery_codes = [make_password(code) for code in recovery_codes]
|
||||||
|
|
||||||
|
def consume_recovery_code(self, raw_code: str) -> bool:
|
||||||
|
remaining_hashes = []
|
||||||
|
matched = False
|
||||||
|
for hashed_code in self.totp_recovery_codes or []:
|
||||||
|
if not matched and check_password(raw_code, hashed_code):
|
||||||
|
matched = True
|
||||||
|
continue
|
||||||
|
remaining_hashes.append(hashed_code)
|
||||||
|
if matched:
|
||||||
|
self.totp_recovery_codes = remaining_hashes
|
||||||
|
self.save(update_fields=['totp_recovery_codes', 'updated_at'])
|
||||||
|
return matched
|
||||||
|
|
||||||
|
|
||||||
class PortalBranding(models.Model):
|
class PortalBranding(models.Model):
|
||||||
|
|||||||
@@ -291,13 +291,46 @@ body {
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-action-grid {
|
.account-security-overview {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
margin-bottom: 18px;
|
margin-bottom: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.account-security-item {
|
||||||
|
padding: 16px 18px;
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid #dbe5f2;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top right, rgba(30, 64, 175, 0.06), transparent 26%),
|
||||||
|
linear-gradient(180deg, rgba(255,255,255,0.96), rgba(246,250,255,0.9));
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-security-item span {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: #6b7a90;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-security-item strong {
|
||||||
|
display: block;
|
||||||
|
color: #132238;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-security-item p {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: #617389;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
.account-totp-card {
|
.account-totp-card {
|
||||||
margin-bottom: 18px;
|
margin-bottom: 18px;
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
@@ -326,56 +359,77 @@ body {
|
|||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-action-card {
|
.account-qr-card,
|
||||||
display: grid;
|
.account-recovery-card {
|
||||||
gap: 6px;
|
margin-top: 14px;
|
||||||
padding: 16px 18px;
|
padding: 16px;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
border: 1px solid #dbe5f2;
|
border: 1px solid #dbe5f2;
|
||||||
background:
|
background: rgba(255, 255, 255, 0.82);
|
||||||
radial-gradient(circle at top right, rgba(30, 64, 175, 0.08), transparent 28%),
|
|
||||||
#f9fbff;
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: transform 160ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 160ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-action-card:hover {
|
.account-qr-card svg {
|
||||||
transform: translateY(-1px);
|
display: block;
|
||||||
box-shadow: 0 10px 24px rgba(28, 45, 79, 0.08);
|
width: min(220px, 100%);
|
||||||
|
height: auto;
|
||||||
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-action-card strong {
|
.account-secret-panel {
|
||||||
color: #132238;
|
margin-top: 14px;
|
||||||
font-size: 15px;
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid #dbe5f2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-action-card span {
|
.account-secret-head {
|
||||||
color: #617389;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-action-card-muted {
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-action-card-muted:hover {
|
|
||||||
transform: none;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-actions {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-secret-head span {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: #6b7a90;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-secret-head strong {
|
||||||
|
color: #132238;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-secret-toggle {
|
||||||
|
min-width: 48px;
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-secret-body {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-secret-body.is-hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-recovery-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-actions .btn {
|
.account-recovery-code {
|
||||||
width: auto;
|
padding: 12px 14px;
|
||||||
}
|
border-radius: 14px;
|
||||||
|
border: 1px dashed #c8d7ea;
|
||||||
.account-actions form {
|
background: #f7faff;
|
||||||
margin: 0;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: #17345e;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-inline-view.is-hidden,
|
.account-inline-view.is-hidden,
|
||||||
@@ -491,23 +545,16 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.account-detail-grid,
|
.account-detail-grid,
|
||||||
.account-action-grid,
|
.account-security-overview,
|
||||||
.account-form-grid {
|
.account-form-grid,
|
||||||
|
.account-recovery-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-actions {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-inline-actions {
|
.account-inline-actions {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-actions .btn {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-panel-head {
|
.account-panel-head {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -275,6 +275,7 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
z-index: 40;
|
z-index: 40;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-user-panel-head {
|
.app-user-panel-head {
|
||||||
@@ -299,7 +300,10 @@
|
|||||||
|
|
||||||
.app-user-panel a,
|
.app-user-panel a,
|
||||||
.app-user-panel button {
|
.app-user-panel button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-height: 42px;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -322,6 +326,13 @@
|
|||||||
color: var(--app-brand-blue);
|
color: var(--app-brand-blue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-user-panel a:focus-visible,
|
||||||
|
.app-user-panel button:focus-visible,
|
||||||
|
.app-user-trigger:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px rgba(0, 0, 120, 0.10);
|
||||||
|
}
|
||||||
|
|
||||||
.app-user-panel form {
|
.app-user-panel form {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,10 +20,6 @@
|
|||||||
<h1>{% trans "Profil" %}</h1>
|
<h1>{% trans "Profil" %}</h1>
|
||||||
<p>{% trans "Ihre aktuelle Workdock-Kontoübersicht und wichtige Sicherheitsaktionen." %}</p>
|
<p>{% trans "Ihre aktuelle Workdock-Kontoübersicht und wichtige Sicherheitsaktionen." %}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="account-hero-badges">
|
|
||||||
<span class="account-chip">{{ role_label }}</span>
|
|
||||||
<span class="account-chip account-chip-muted">{{ account_user.username }}</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="account-layout">
|
<div class="account-layout">
|
||||||
@@ -58,14 +54,6 @@
|
|||||||
<p>{{ account_user.email|default:account_user.username }}</p>
|
<p>{{ account_user.email|default:account_user.username }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="account-profile-meta">
|
<div class="account-profile-meta">
|
||||||
<div>
|
|
||||||
<span>{% trans "Rolle" %}</span>
|
|
||||||
<strong>{{ role_label }}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>{% trans "Benutzername" %}</span>
|
|
||||||
<strong>{{ account_user.username }}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<span>{% trans "Position" %}</span>
|
<span>{% trans "Position" %}</span>
|
||||||
<strong>{{ account_user_profile.job_title|default:"-" }}</strong>
|
<strong>{{ account_user_profile.job_title|default:"-" }}</strong>
|
||||||
@@ -171,29 +159,36 @@
|
|||||||
<section class="account-panel">
|
<section class="account-panel">
|
||||||
<div class="account-panel-head">
|
<div class="account-panel-head">
|
||||||
<h2>{% trans "Sicherheit & Aktionen" %}</h2>
|
<h2>{% trans "Sicherheit & Aktionen" %}</h2>
|
||||||
<p>{% trans "Direkte Aktionen für Ihr Workdock-Konto." %}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="account-action-grid">
|
<div class="account-security-overview">
|
||||||
<a class="account-action-card" href="{% url 'password_change' %}">
|
<div class="account-security-item">
|
||||||
<strong>{% trans "Passwort ändern" %}</strong>
|
<span>{% trans "TOTP" %}</span>
|
||||||
<span>{% trans "Aktualisieren Sie Ihr Passwort direkt im Konto." %}</span>
|
<strong>{% if account_user_profile.totp_enabled %}{% trans "Aktiv" %}{% else %}{% trans "Aus" %}{% endif %}</strong>
|
||||||
</a>
|
<p>
|
||||||
|
|
||||||
<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 %}
|
{% if account_user_profile.totp_enabled %}
|
||||||
{% trans "Zweiter Faktor ist aktiv und wird bei der Anmeldung geprüft." %}
|
{% trans "Anmeldung wird zusätzlich mit einem zweiten Faktor geschützt." %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% trans "Standardmäßig deaktiviert. Kann hier jederzeit aktiviert werden." %}
|
{% trans "Optional. Kann bei Bedarf direkt unten aktiviert werden." %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</span>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="account-security-item">
|
||||||
<div class="account-action-card account-action-card-muted">
|
<span>{% trans "Recovery-Codes" %}</span>
|
||||||
<strong>{% trans "Sitzung" %}</strong>
|
<strong>
|
||||||
<span>{% trans "Sie können sich jederzeit sicher vom aktuellen Gerät abmelden." %}</span>
|
{% if account_user_profile.totp_enabled %}
|
||||||
|
{{ account_user_profile.totp_recovery_codes|length }}
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</strong>
|
||||||
|
<p>
|
||||||
|
{% if account_user_profile.totp_enabled %}
|
||||||
|
{% trans "Einmal-Codes für Notfälle oder verlorene Authenticator-Geräte." %}
|
||||||
|
{% else %}
|
||||||
|
{% trans "Werden automatisch erzeugt, sobald TOTP aktiviert wird." %}
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -241,18 +236,52 @@
|
|||||||
<button class="btn btn-secondary" type="submit">{% trans "TOTP deaktivieren" %}</button>
|
<button class="btn btn-secondary" type="submit">{% trans "TOTP deaktivieren" %}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% else %}
|
<form class="account-totp-form" method="post">
|
||||||
<div class="account-detail-grid">
|
{% csrf_token %}
|
||||||
<div class="account-detail">
|
<input type="hidden" name="account_form" value="totp_regenerate_codes" />
|
||||||
<span>{% trans "Manueller Schlüssel" %}</span>
|
<div class="account-form-grid">
|
||||||
<strong class="account-secret">{{ totp_pending_secret }}</strong>
|
{% for field in totp_regenerate_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>
|
||||||
<div class="account-detail">
|
<div class="account-inline-actions">
|
||||||
<span>{% trans "Setup-Link" %}</span>
|
<button class="btn btn-primary" type="submit">{% trans "Recovery-Codes neu erzeugen" %}</button>
|
||||||
<strong class="account-secret">{{ totp_otpauth_uri }}</strong>
|
</div>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<div class="account-qr-card">
|
||||||
|
{% if totp_qr_svg %}
|
||||||
|
{{ totp_qr_svg|safe }}
|
||||||
|
{% endif %}
|
||||||
|
<div class="account-secret-panel">
|
||||||
|
<div class="account-secret-head">
|
||||||
|
<div>
|
||||||
|
<span>{% trans "Manueller Schlüssel" %}</span>
|
||||||
|
<strong>{% trans "Nur bei Bedarf anzeigen" %}</strong>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary account-secret-toggle"
|
||||||
|
type="button"
|
||||||
|
data-secret-toggle
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-controls="totp-manual-secret"
|
||||||
|
title="{% trans 'Manuellen Schlüssel anzeigen oder ausblenden' %}"
|
||||||
|
>
|
||||||
|
<span data-secret-toggle-icon>◐</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="account-secret-body is-hidden" id="totp-manual-secret" data-secret-body>
|
||||||
|
<strong class="account-secret">{{ totp_pending_secret }}</strong>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<p class="mini">{% trans "Scannen Sie den QR-Code mit Ihrer Authenticator-App. Den manuellen Schlüssel können Sie bei Bedarf einblenden." %}</p>
|
||||||
<form class="account-totp-form" method="post">
|
<form class="account-totp-form" method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="account_form" value="totp_enable" />
|
<input type="hidden" name="account_form" value="totp_enable" />
|
||||||
@@ -272,14 +301,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="account-actions">
|
{% if totp_recovery_codes %}
|
||||||
<a class="btn btn-primary" href="{% url 'password_change' %}">{% trans "Passwort ändern" %}</a>
|
<div class="account-recovery-card">
|
||||||
<form method="post" action="{% url 'logout' %}">
|
<div class="account-panel-head">
|
||||||
{% csrf_token %}
|
<div>
|
||||||
<button class="btn btn-secondary" type="submit">{% trans "Abmelden" %}</button>
|
<h3>{% trans "Recovery-Codes" %}</h3>
|
||||||
</form>
|
<p>{% trans "Diese Codes werden nur jetzt im Klartext angezeigt. Jeden Code können Sie genau einmal verwenden." %}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="account-recovery-grid">
|
||||||
|
{% for code in totp_recovery_codes %}
|
||||||
|
<div class="account-recovery-code">{{ code }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -295,6 +332,9 @@
|
|||||||
var cancel = document.querySelector('[data-account-edit-cancel="details"]');
|
var cancel = document.querySelector('[data-account-edit-cancel="details"]');
|
||||||
var view = document.querySelector('[data-account-edit-view="details"]');
|
var view = document.querySelector('[data-account-edit-view="details"]');
|
||||||
var form = document.querySelector('[data-account-edit-form="details"]');
|
var form = document.querySelector('[data-account-edit-form="details"]');
|
||||||
|
var secretToggle = document.querySelector('[data-secret-toggle]');
|
||||||
|
var secretBody = document.querySelector('[data-secret-body]');
|
||||||
|
var secretIcon = document.querySelector('[data-secret-toggle-icon]');
|
||||||
if (!toggle || !cancel || !view || !form) return;
|
if (!toggle || !cancel || !view || !form) return;
|
||||||
|
|
||||||
function setMode(editing) {
|
function setMode(editing) {
|
||||||
@@ -310,6 +350,17 @@
|
|||||||
cancel.addEventListener('click', function () {
|
cancel.addEventListener('click', function () {
|
||||||
setMode(false);
|
setMode(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (secretToggle && secretBody) {
|
||||||
|
secretToggle.addEventListener('click', function () {
|
||||||
|
var isOpen = secretToggle.getAttribute('aria-expanded') === 'true';
|
||||||
|
secretToggle.setAttribute('aria-expanded', isOpen ? 'false' : 'true');
|
||||||
|
secretBody.classList.toggle('is-hidden', isOpen);
|
||||||
|
if (secretIcon) {
|
||||||
|
secretIcon.textContent = isOpen ? '◐' : '◑';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}());
|
}());
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -36,6 +36,10 @@
|
|||||||
{{ form.otp_code.label_tag }}{{ form.otp_code }}
|
{{ form.otp_code.label_tag }}{{ form.otp_code }}
|
||||||
<div class="mini">{% trans "Nur erforderlich, wenn TOTP für Ihr Konto aktiviert ist." %}</div>
|
<div class="mini">{% trans "Nur erforderlich, wenn TOTP für Ihr Konto aktiviert ist." %}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field{% if form.recovery_code.errors %} has-error{% endif %}">
|
||||||
|
{{ form.recovery_code.label_tag }}{{ form.recovery_code }}
|
||||||
|
<div class="mini">{% trans "Alternativ können Sie einen einmaligen Recovery-Code verwenden." %}</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>
|
||||||
|
|||||||
@@ -90,12 +90,15 @@ class AccountUISmokeTests(TestCase):
|
|||||||
profile.refresh_from_db()
|
profile.refresh_from_db()
|
||||||
self.assertTrue(profile.totp_enabled)
|
self.assertTrue(profile.totp_enabled)
|
||||||
self.assertTrue(profile.totp_secret)
|
self.assertTrue(profile.totp_secret)
|
||||||
|
self.assertEqual(len(profile.totp_recovery_codes), 8)
|
||||||
|
self.assertContains(response, 'Recovery-Codes')
|
||||||
|
|
||||||
def test_login_requires_totp_when_enabled(self):
|
def test_login_requires_totp_when_enabled(self):
|
||||||
profile = self.user.profile
|
profile = self.user.profile
|
||||||
profile.totp_secret = 'JBSWY3DPEHPK3PXP'
|
profile.totp_secret = 'JBSWY3DPEHPK3PXP'
|
||||||
profile.totp_enabled = True
|
profile.totp_enabled = True
|
||||||
profile.save(update_fields=['totp_secret', 'totp_enabled', 'updated_at'])
|
profile.set_recovery_codes(['ABCDE-12345'])
|
||||||
|
profile.save(update_fields=['totp_secret', 'totp_enabled', 'totp_recovery_codes', 'updated_at'])
|
||||||
|
|
||||||
client = Client()
|
client = Client()
|
||||||
response = client.post(
|
response = client.post(
|
||||||
@@ -113,3 +116,13 @@ class AccountUISmokeTests(TestCase):
|
|||||||
HTTP_HOST='localhost',
|
HTTP_HOST='localhost',
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
client = Client()
|
||||||
|
response = client.post(
|
||||||
|
'/accounts/login/',
|
||||||
|
{'username': 'profile-user', 'password': 'secret-12345', 'recovery_code': 'ABCDE-12345'},
|
||||||
|
HTTP_HOST='localhost',
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
profile.refresh_from_db()
|
||||||
|
self.assertEqual(profile.totp_recovery_codes, [])
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import hashlib
|
|||||||
import hmac
|
import hmac
|
||||||
import secrets
|
import secrets
|
||||||
import struct
|
import struct
|
||||||
|
import string
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
|
||||||
@@ -47,3 +48,19 @@ def build_otpauth_uri(secret: str, *, account_name: str, issuer: str) -> str:
|
|||||||
label = quote(f'{issuer}:{account_name}')
|
label = quote(f'{issuer}:{account_name}')
|
||||||
issuer_q = quote(issuer)
|
issuer_q = quote(issuer)
|
||||||
return f'otpauth://totp/{label}?secret={secret}&issuer={issuer_q}&algorithm=SHA1&digits=6&period=30'
|
return f'otpauth://totp/{label}?secret={secret}&issuer={issuer_q}&algorithm=SHA1&digits=6&period=30'
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_recovery_code(value: str | None) -> str:
|
||||||
|
raw = (value or '').strip().upper().replace(' ', '')
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def generate_recovery_codes(count: int = 8) -> list[str]:
|
||||||
|
alphabet = string.ascii_uppercase + string.digits
|
||||||
|
codes = []
|
||||||
|
for _ in range(count):
|
||||||
|
parts = []
|
||||||
|
for _part in range(2):
|
||||||
|
parts.append(''.join(secrets.choice(alphabet) for _ in range(5)))
|
||||||
|
codes.append('-'.join(parts))
|
||||||
|
return codes
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from pathlib import Path
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from tempfile import NamedTemporaryFile
|
from tempfile import NamedTemporaryFile
|
||||||
import json
|
import json
|
||||||
|
from io import BytesIO
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
from celery import current_app
|
from celery import current_app
|
||||||
@@ -33,7 +34,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, AccountTOTPDisableForm, AccountTOTPEnableForm, OffboardingRequestForm, OnboardingRequestForm, PortalBrandingForm, PortalCompanyConfigForm, PortalTrialConfigForm, UserManagementCreateForm
|
from .forms import AccountAvatarForm, AccountDetailsForm, AccountTOTPDisableForm, AccountTOTPEnableForm, AccountTOTPRegenerateRecoveryCodesForm, 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,7 +47,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 .totp import build_otpauth_uri, generate_recovery_codes, 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,
|
||||||
@@ -130,7 +131,9 @@ def healthz(request):
|
|||||||
@login_required
|
@login_required
|
||||||
def account_profile_page(request):
|
def account_profile_page(request):
|
||||||
session_secret_key = 'account_totp_pending_secret'
|
session_secret_key = 'account_totp_pending_secret'
|
||||||
|
session_codes_key = 'account_totp_recovery_codes'
|
||||||
profile, created = UserProfile.objects.get_or_create(user=request.user)
|
profile, created = UserProfile.objects.get_or_create(user=request.user)
|
||||||
|
recovery_codes = request.session.pop(session_codes_key, [])
|
||||||
pending_totp_secret = request.session.get(session_secret_key) or ''
|
pending_totp_secret = request.session.get(session_secret_key) or ''
|
||||||
if profile.totp_enabled:
|
if profile.totp_enabled:
|
||||||
pending_totp_secret = ''
|
pending_totp_secret = ''
|
||||||
@@ -143,6 +146,7 @@ def account_profile_page(request):
|
|||||||
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_enable_form = AccountTOTPEnableForm(user=request.user, secret=pending_totp_secret)
|
||||||
totp_disable_form = AccountTOTPDisableForm(user=request.user, profile=profile)
|
totp_disable_form = AccountTOTPDisableForm(user=request.user, profile=profile)
|
||||||
|
totp_regenerate_form = AccountTOTPRegenerateRecoveryCodesForm(user=request.user, profile=profile)
|
||||||
account_edit_open = False
|
account_edit_open = False
|
||||||
totp_edit_open = False
|
totp_edit_open = False
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
@@ -166,7 +170,9 @@ def account_profile_page(request):
|
|||||||
totp_edit_open = True
|
totp_edit_open = True
|
||||||
totp_enable_form = AccountTOTPEnableForm(request.POST, user=request.user, secret=pending_totp_secret)
|
totp_enable_form = AccountTOTPEnableForm(request.POST, user=request.user, secret=pending_totp_secret)
|
||||||
if totp_enable_form.is_valid():
|
if totp_enable_form.is_valid():
|
||||||
profile.enable_totp(pending_totp_secret)
|
recovery_codes = generate_recovery_codes()
|
||||||
|
profile.enable_totp(pending_totp_secret, recovery_codes)
|
||||||
|
request.session[session_codes_key] = recovery_codes
|
||||||
request.session.pop(session_secret_key, None)
|
request.session.pop(session_secret_key, None)
|
||||||
messages.success(request, _('TOTP wurde aktiviert.'))
|
messages.success(request, _('TOTP wurde aktiviert.'))
|
||||||
return redirect('account_profile_page')
|
return redirect('account_profile_page')
|
||||||
@@ -180,10 +186,34 @@ def account_profile_page(request):
|
|||||||
messages.success(request, _('TOTP wurde deaktiviert.'))
|
messages.success(request, _('TOTP wurde deaktiviert.'))
|
||||||
return redirect('account_profile_page')
|
return redirect('account_profile_page')
|
||||||
messages.error(request, _('TOTP konnte nicht deaktiviert werden.'))
|
messages.error(request, _('TOTP konnte nicht deaktiviert werden.'))
|
||||||
|
elif form_kind == 'totp_regenerate_codes':
|
||||||
|
totp_edit_open = True
|
||||||
|
totp_regenerate_form = AccountTOTPRegenerateRecoveryCodesForm(request.POST, user=request.user, profile=profile)
|
||||||
|
if totp_regenerate_form.is_valid():
|
||||||
|
recovery_codes = generate_recovery_codes()
|
||||||
|
profile.set_recovery_codes(recovery_codes)
|
||||||
|
profile.save(update_fields=['totp_recovery_codes', 'updated_at'])
|
||||||
|
request.session[session_codes_key] = recovery_codes
|
||||||
|
messages.success(request, _('Recovery-Codes wurden neu erzeugt.'))
|
||||||
|
return redirect('account_profile_page')
|
||||||
|
messages.error(request, _('Recovery-Codes konnten nicht neu erzeugt werden.'))
|
||||||
|
|
||||||
branding_context = get_branding_email_copy()
|
branding_context = get_branding_email_copy()
|
||||||
totp_account_name = (request.user.email or request.user.username or '').strip()
|
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()
|
totp_issuer = (branding_context.get('portal_title') or branding_context.get('company_name') or 'Workdock').strip()
|
||||||
|
totp_otpauth_uri = '' if profile.totp_enabled else build_otpauth_uri(pending_totp_secret, account_name=totp_account_name, issuer=totp_issuer)
|
||||||
|
totp_qr_svg = ''
|
||||||
|
if totp_otpauth_uri:
|
||||||
|
try:
|
||||||
|
import qrcode
|
||||||
|
import qrcode.image.svg
|
||||||
|
|
||||||
|
qr_image = qrcode.make(totp_otpauth_uri, image_factory=qrcode.image.svg.SvgPathImage)
|
||||||
|
stream = BytesIO()
|
||||||
|
qr_image.save(stream)
|
||||||
|
totp_qr_svg = stream.getvalue().decode('utf-8')
|
||||||
|
except Exception:
|
||||||
|
totp_qr_svg = ''
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
'workflows/account_profile.html',
|
'workflows/account_profile.html',
|
||||||
@@ -194,11 +224,14 @@ def account_profile_page(request):
|
|||||||
'details_form': details_form,
|
'details_form': details_form,
|
||||||
'totp_enable_form': totp_enable_form,
|
'totp_enable_form': totp_enable_form,
|
||||||
'totp_disable_form': totp_disable_form,
|
'totp_disable_form': totp_disable_form,
|
||||||
|
'totp_regenerate_form': totp_regenerate_form,
|
||||||
'account_edit_open': account_edit_open,
|
'account_edit_open': account_edit_open,
|
||||||
'totp_edit_open': totp_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_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),
|
'totp_otpauth_uri': totp_otpauth_uri,
|
||||||
|
'totp_qr_svg': totp_qr_svg,
|
||||||
|
'totp_recovery_codes': recovery_codes,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user