snapshot: preserve account profile inline editing and avatar flow

This commit is contained in:
Md Bayazid Bostame
2026-03-27 02:06:52 +01:00
parent 8d228723f9
commit 358a71230d
13 changed files with 1462 additions and 398 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,7 @@ from django.utils.translation import get_language, gettext as _, gettext_lazy
from .branding import get_company_email_domain 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, 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
@@ -142,6 +142,85 @@ class AppPasswordChangeForm(PasswordChangeForm):
) )
class AccountAvatarForm(forms.ModelForm):
class Meta:
model = UserProfile
fields = ['avatar_image']
labels = {
'avatar_image': gettext_lazy('Profilbild'),
}
widgets = {
'avatar_image': forms.ClearableFileInput(
attrs={
'accept': '.png,.jpg,.jpeg,.webp,.svg',
'onchange': 'this.form.submit()',
}
),
}
def clean_avatar_image(self):
avatar = self.cleaned_data.get('avatar_image')
if not avatar:
return avatar
if getattr(avatar, 'size', 0) > 5 * 1024 * 1024:
raise forms.ValidationError(_('Das Profilbild darf maximal 5 MB groß sein.'))
return avatar
class AccountDetailsForm(forms.Form):
first_name = forms.CharField(label=gettext_lazy('Vorname'), max_length=150, required=False)
last_name = forms.CharField(label=gettext_lazy('Nachname'), max_length=150, required=False)
email = forms.EmailField(label=gettext_lazy('E-Mail-Adresse'), required=False)
phone_number = forms.CharField(label=gettext_lazy('Telefon'), max_length=80, required=False)
mobile_number = forms.CharField(label=gettext_lazy('Mobil'), max_length=80, required=False)
job_title = forms.CharField(label=gettext_lazy('Position'), max_length=255, required=False)
department = forms.CharField(label=gettext_lazy('Abteilung'), max_length=255, required=False)
location = forms.CharField(label=gettext_lazy('Standort'), max_length=255, required=False)
contact_notes = forms.CharField(
label=gettext_lazy('Hinweise'),
max_length=255,
required=False,
widget=forms.Textarea(attrs={'rows': 3}),
)
def __init__(self, *args, user=None, profile=None, **kwargs):
self.user = user
self.profile = profile
initial = kwargs.setdefault('initial', {})
if user is not None and not args:
initial.setdefault('first_name', user.first_name)
initial.setdefault('last_name', user.last_name)
initial.setdefault('email', user.email)
if profile is not None and not args:
initial.setdefault('phone_number', profile.phone_number)
initial.setdefault('mobile_number', profile.mobile_number)
initial.setdefault('job_title', profile.job_title)
initial.setdefault('department', profile.department)
initial.setdefault('location', profile.location)
initial.setdefault('contact_notes', profile.contact_notes)
super().__init__(*args, **kwargs)
def clean_email(self):
return (self.cleaned_data.get('email') or '').strip().lower()
def save(self):
self.user.first_name = self.cleaned_data.get('first_name', '').strip()
self.user.last_name = self.cleaned_data.get('last_name', '').strip()
self.user.email = self.cleaned_data.get('email', '').strip()
self.user.save(update_fields=['first_name', 'last_name', 'email'])
self.profile.phone_number = self.cleaned_data.get('phone_number', '').strip()
self.profile.mobile_number = self.cleaned_data.get('mobile_number', '').strip()
self.profile.job_title = self.cleaned_data.get('job_title', '').strip()
self.profile.department = self.cleaned_data.get('department', '').strip()
self.profile.location = self.cleaned_data.get('location', '').strip()
self.profile.contact_notes = self.cleaned_data.get('contact_notes', '').strip()
self.profile.save(
update_fields=['phone_number', 'mobile_number', 'job_title', 'department', 'location', 'contact_notes', 'updated_at']
)
return self.user, self.profile
class UserManagementCreateForm(forms.Form): class UserManagementCreateForm(forms.Form):
first_name = forms.CharField(label=_('Vorname'), max_length=150, required=False) first_name = forms.CharField(label=_('Vorname'), max_length=150, required=False)
last_name = forms.CharField(label=_('Nachname'), max_length=150, required=False) last_name = forms.CharField(label=_('Nachname'), max_length=150, required=False)

View File

@@ -0,0 +1,42 @@
from django.conf import settings
from django.core.validators import FileExtensionValidator
from django.db import migrations, models
import django.db.models.deletion
def create_profiles_for_existing_users(apps, schema_editor):
User = apps.get_model(*settings.AUTH_USER_MODEL.split('.'))
UserProfile = apps.get_model('workflows', 'UserProfile')
for user in User.objects.all().iterator():
UserProfile.objects.get_or_create(user=user)
class Migration(migrations.Migration):
dependencies = [
('workflows', '0046_alter_onboardingrequest_phone_number'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='UserProfile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('avatar_image', models.FileField(blank=True, null=True, upload_to='profiles/', validators=[FileExtensionValidator(allowed_extensions=['png', 'jpg', 'jpeg', 'webp', 'svg'])])),
('phone_number', models.CharField(blank=True, default='', max_length=80)),
('mobile_number', models.CharField(blank=True, default='', max_length=80)),
('job_title', models.CharField(blank=True, default='', max_length=255)),
('department', models.CharField(blank=True, default='', max_length=255)),
('location', models.CharField(blank=True, default='', max_length=255)),
('contact_notes', models.CharField(blank=True, default='', max_length=255)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'User Profile',
'verbose_name_plural': 'User Profiles',
},
),
migrations.RunPython(create_profiles_for_existing_users, migrations.RunPython.noop),
]

View File

@@ -25,6 +25,30 @@ class EmployeeProfile(models.Model):
return f"{self.full_name} <{self.work_email}>" return f"{self.full_name} <{self.work_email}>"
class UserProfile(models.Model):
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='profile')
avatar_image = models.FileField(
upload_to='profiles/',
blank=True,
null=True,
validators=[FileExtensionValidator(allowed_extensions=['png', 'jpg', 'jpeg', 'webp', 'svg'])],
)
phone_number = models.CharField(max_length=80, blank=True, default='')
mobile_number = models.CharField(max_length=80, blank=True, default='')
job_title = 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='')
contact_notes = models.CharField(max_length=255, blank=True, default='')
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = 'User Profile'
verbose_name_plural = 'User Profiles'
def __str__(self) -> str:
return getattr(self.user, 'username', '') or str(self.user_id)
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)
portal_title = models.CharField(max_length=255, default='Workdock') portal_title = models.CharField(max_length=255, default='Workdock')

View File

@@ -131,9 +131,16 @@ def user_has_capability(user, capability: str) -> bool:
def template_role_context(user) -> dict[str, object]: def template_role_context(user) -> dict[str, object]:
role_key = get_user_role_key(user) role_key = get_user_role_key(user)
avatar_url = ''
if getattr(user, 'is_authenticated', False):
profile = getattr(user, 'profile', None)
avatar = getattr(profile, 'avatar_image', None)
if avatar:
avatar_url = getattr(avatar, 'url', '') or ''
return { return {
'role_key': role_key, 'role_key': role_key,
'role_label': str(ROLE_LABELS[role_key]), 'role_label': str(ROLE_LABELS[role_key]),
'user_avatar_url': avatar_url,
'can_manage_product_branding': user_has_capability(user, 'manage_product_branding'), 'can_manage_product_branding': user_has_capability(user, 'manage_product_branding'),
'can_manage_company_config': user_has_capability(user, 'manage_company_config'), 'can_manage_company_config': user_has_capability(user, 'manage_company_config'),
'can_manage_trial_lifecycle': user_has_capability(user, 'manage_trial_lifecycle'), 'can_manage_trial_lifecycle': user_has_capability(user, 'manage_trial_lifecycle'),

View File

@@ -1,6 +1,8 @@
from django.db.models.signals import post_migrate from django.conf import settings
from django.db.models.signals import post_migrate, post_save
from django.dispatch import receiver from django.dispatch import receiver
from .models import UserProfile
from .roles import ensure_bootstrap_role_assignments, ensure_role_groups from .roles import ensure_bootstrap_role_assignments, ensure_role_groups
@@ -10,3 +12,9 @@ def workflows_post_migrate(sender, **kwargs):
return return
ensure_role_groups() ensure_role_groups()
ensure_bootstrap_role_assignments() ensure_bootstrap_role_assignments()
@receiver(post_save, sender=settings.AUTH_USER_MODEL)
def ensure_user_profile(sender, instance, created, **kwargs):
if created:
UserProfile.objects.get_or_create(user=instance)

View File

@@ -0,0 +1,486 @@
body {
margin: 0;
min-height: 100vh;
font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
background:
radial-gradient(76% 96% at 8% 10%, rgba(0, 0, 120, 0.12), rgba(0, 0, 120, 0)),
radial-gradient(64% 86% at 92% 88%, rgba(163, 32, 32, 0.1), rgba(163, 32, 32, 0)),
linear-gradient(160deg, #eef3ff 0%, #f8fbff 52%, #edf4ff 100%);
padding: 24px;
}
.shell {
background: rgba(255, 255, 255, 0.78);
backdrop-filter: blur(12px);
border: 1px solid rgba(217, 227, 238, 0.9);
border-radius: 28px;
box-shadow: 0 22px 48px rgba(18, 34, 56, 0.14);
overflow: hidden;
}
.account-shell-body {
padding: 28px;
background:
radial-gradient(90% 120% at 10% 0%, rgba(31, 79, 214, 0.06), rgba(31, 79, 214, 0)),
linear-gradient(180deg, rgba(255,255,255,0.72), rgba(248,251,255,0.48));
}
.account-page {
width: min(1120px, 100%);
margin: 0 auto;
display: grid;
gap: 22px;
}
.account-hero {
display: flex;
justify-content: space-between;
gap: 18px;
align-items: flex-start;
padding: 26px 28px;
border: 1px solid #d9e3f0;
border-radius: 24px;
background:
radial-gradient(circle at top right, rgba(30, 64, 175, 0.1), transparent 24%),
linear-gradient(135deg, rgba(255,255,255,0.96), rgba(244,248,255,0.9));
box-shadow: 0 14px 34px rgba(28, 45, 79, 0.08);
}
.account-kicker {
display: inline-flex;
margin-bottom: 10px;
padding: 6px 10px;
border-radius: 999px;
background: rgba(0, 0, 120, 0.08);
color: #203b74;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.account-hero h1 {
margin: 0 0 8px;
font-size: 32px;
color: #132238;
}
.account-hero p {
margin: 0;
max-width: 620px;
color: #617389;
line-height: 1.55;
}
.account-hero-badges {
display: flex;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.account-chip {
display: inline-flex;
align-items: center;
min-height: 38px;
padding: 0 14px;
border-radius: 999px;
background: #0f2e8a;
color: #fff;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.03em;
}
.account-chip-muted {
background: #edf3ff;
color: #35507e;
border: 1px solid #d5deee;
}
.account-layout {
display: grid;
grid-template-columns: 320px minmax(0, 1fr);
gap: 22px;
align-items: start;
}
.account-profile-card,
.account-panel {
border: 1px solid #d9e3f0;
border-radius: 22px;
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 14px 32px rgba(28, 45, 79, 0.09);
}
.account-profile-card {
padding: 24px;
position: sticky;
top: 24px;
}
.account-avatar-form {
display: grid;
gap: 10px;
}
.account-avatar-wrap {
position: relative;
display: inline-flex;
width: fit-content;
cursor: pointer;
}
.account-avatar {
width: 84px;
height: 84px;
border-radius: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
background: linear-gradient(180deg, #000078, #2943b6);
color: #fff;
font-size: 28px;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.22);
}
.account-avatar-image {
width: 84px;
height: 84px;
object-fit: cover;
display: block;
border-radius: 24px;
border: 1px solid #dce5f2;
box-shadow: 0 12px 24px rgba(18, 34, 56, 0.12);
}
.account-avatar-edit {
position: absolute;
right: -4px;
bottom: -4px;
width: 30px;
height: 30px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
background: #132d8a;
color: #fff;
font-size: 14px;
font-weight: 800;
border: 2px solid #fff;
box-shadow: 0 8px 18px rgba(18, 34, 56, 0.16);
}
.account-avatar-hint {
margin: 0;
color: #617389;
font-size: 12px;
line-height: 1.45;
}
.account-avatar-error {
color: #ab1e1e;
font-size: 12px;
line-height: 1.4;
}
.account-profile-copy {
margin-top: 18px;
}
.account-profile-copy h2 {
margin: 0 0 6px;
font-size: 24px;
color: #132238;
}
.account-profile-copy p {
margin: 0;
color: #617389;
line-height: 1.45;
word-break: break-word;
}
.account-profile-meta {
display: grid;
gap: 12px;
margin-top: 22px;
}
.account-profile-meta div {
padding: 12px 14px;
border-radius: 14px;
background: #f7faff;
border: 1px solid #dce6f2;
}
.account-profile-meta span {
display: block;
margin-bottom: 4px;
color: #6b7a90;
font-size: 12px;
}
.account-profile-meta strong {
color: #132238;
font-size: 14px;
line-height: 1.4;
}
.account-main {
display: grid;
gap: 22px;
}
.account-panel {
padding: 24px;
}
.account-panel-head {
margin-bottom: 18px;
display: flex;
justify-content: space-between;
gap: 14px;
align-items: flex-start;
}
.account-panel-head h2 {
margin: 0 0 6px;
font-size: 20px;
color: #132238;
}
.account-panel-head p {
margin: 0;
color: #617389;
line-height: 1.5;
}
.account-detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.account-detail-wide {
grid-column: 1 / -1;
}
.account-detail {
padding: 14px 16px;
border-radius: 16px;
background: #f9fbff;
border: 1px solid #dbe5f2;
}
.account-detail span {
display: block;
margin-bottom: 6px;
color: #6b7a90;
font-size: 12px;
}
.account-detail strong {
color: #132238;
font-size: 14px;
line-height: 1.45;
word-break: break-word;
}
.account-action-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
margin-bottom: 18px;
}
.account-action-card {
display: grid;
gap: 6px;
padding: 16px 18px;
border-radius: 18px;
border: 1px solid #dbe5f2;
background:
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 {
transform: translateY(-1px);
box-shadow: 0 10px 24px rgba(28, 45, 79, 0.08);
}
.account-action-card strong {
color: #132238;
font-size: 15px;
}
.account-action-card span {
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;
gap: 10px;
}
.account-actions .btn {
width: auto;
}
.account-actions form {
margin: 0;
}
.account-inline-view.is-hidden,
.account-inline-form.is-hidden {
display: none;
}
.account-inline-edit-trigger {
min-width: 112px;
}
.account-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.account-form-field {
display: grid;
gap: 6px;
}
.account-form-field-wide {
grid-column: 1 / -1;
}
.account-form-field label {
color: #132238;
font-size: 13px;
font-weight: 700;
}
.account-form-field input,
.account-form-field textarea {
width: 100%;
box-sizing: border-box;
padding: 10px 12px;
border: 1px solid #cbd5e1;
border-radius: 12px;
min-height: 44px;
font: inherit;
background: #fff;
transition: border-color 180ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 180ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.account-form-field textarea {
min-height: 104px;
resize: vertical;
}
.account-form-field input:focus,
.account-form-field textarea:focus {
outline: none;
border-color: rgba(0, 0, 120, 0.3);
box-shadow: 0 0 0 4px rgba(0, 0, 120, 0.08);
}
.account-form-field.has-error input,
.account-form-field.has-error textarea {
border-color: #e3a3a3;
background: #fffafa;
box-shadow: 0 0 0 4px rgba(185, 28, 28, 0.06);
}
.account-form-error {
color: #ab1e1e;
font-size: 12px;
line-height: 1.4;
}
.account-inline-actions {
display: flex;
gap: 10px;
margin-top: 16px;
}
@media (max-width: 980px) {
.account-layout {
grid-template-columns: 1fr;
}
.account-profile-card {
position: static;
}
}
@media (max-width: 760px) {
body {
padding: 14px;
}
.account-shell-body {
padding: 16px;
}
.account-hero,
.account-panel,
.account-profile-card {
padding: 18px;
border-radius: 18px;
}
.account-hero {
flex-direction: column;
}
.account-hero h1 {
font-size: 26px;
}
.account-hero-badges {
justify-content: flex-start;
}
.account-detail-grid,
.account-action-grid,
.account-form-grid {
grid-template-columns: 1fr;
}
.account-actions {
flex-direction: column;
}
.account-inline-actions {
flex-direction: column;
}
.account-actions .btn {
width: 100%;
}
.account-panel-head {
flex-direction: column;
}
}

View File

@@ -190,7 +190,7 @@
align-items: center; align-items: center;
gap: 10px; gap: 10px;
min-height: 44px; min-height: 44px;
padding: 6px 10px 6px 6px; padding: 6px 10px 6px 8px;
border: 1px solid var(--app-line); border: 1px solid var(--app-line);
border-radius: 999px; border-radius: 999px;
background: rgba(248, 251, 255, 0.92); background: rgba(248, 251, 255, 0.92);
@@ -217,18 +217,27 @@
} }
.app-user-avatar { .app-user-avatar {
width: 32px; width: 28px;
height: 32px; height: 28px;
border-radius: 50%; border-radius: 50%;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: linear-gradient(180deg, var(--app-brand-blue), #1d3ca8); background: linear-gradient(180deg, var(--app-brand-blue), #1d3ca8);
color: #fff; color: #fff;
font-size: 11px; font-size: 10px;
font-weight: 800; font-weight: 800;
letter-spacing: 0.04em; letter-spacing: 0.04em;
text-transform: uppercase; text-transform: uppercase;
overflow: hidden;
flex: 0 0 28px;
}
.app-user-avatar-image {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
} }
.app-user-copy { .app-user-copy {

View File

@@ -8,37 +8,183 @@
{% endblock %} {% endblock %}
{% block extra_css %} {% block extra_css %}
<link rel="stylesheet" href="{% static 'workflows/css/login.css' %}" /> <link rel="stylesheet" href="{% static 'workflows/css/account.css' %}" />
{% endblock %} {% endblock %}
{% block shell_body %} {% block shell_body %}
<section class="login-shell-body"> <section class="account-shell-body">
<div class="login-card account-card"> <div class="account-page">
<section class="account-hero">
<div class="account-hero-copy">
<span class="account-kicker">{% trans "Konto" %}</span>
<h1>{% trans "Profil" %}</h1> <h1>{% trans "Profil" %}</h1>
<p>{% trans "Ihre aktuelle Workdock-Kontoübersicht und direkte Kontoaktionen." %}</p> <p>{% trans "Ihre aktuelle Workdock-Kontoübersicht und wichtige Sicherheitsaktionen." %}</p>
</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>
<div class="account-grid"> <div class="account-layout">
<div class="account-row"> <aside class="account-profile-card">
<span>{% trans "Name" %}</span> <form class="account-avatar-form" method="post" enctype="multipart/form-data">
<strong>{{ account_user.get_full_name|default:account_user.username }}</strong> {% csrf_token %}
<input type="hidden" name="account_form" value="avatar" />
<label class="account-avatar-wrap" for="{{ avatar_form.avatar_image.id_for_label }}">
{% if account_user_profile.avatar_image %}
<img class="account-avatar-image" src="{{ account_user_profile.avatar_image.url }}" alt="{% trans 'Profilbild' %}" />
{% else %}
<div class="account-avatar" aria-hidden="true">
{% if account_user.first_name or account_user.last_name %}
{{ account_user.first_name|slice:":1" }}{{ account_user.last_name|slice:":1" }}
{% else %}
{{ account_user.username|slice:":2" }}
{% endif %}
</div> </div>
<div class="account-row"> {% endif %}
<span>{% trans "Benutzername" %}</span> <span class="account-avatar-edit" aria-hidden="true"></span>
<strong>{{ account_user.username }}</strong> </label>
<div class="account-avatar-input" hidden>
{{ avatar_form.avatar_image }}
</div> </div>
<div class="account-row"> {% if avatar_form.avatar_image.errors %}
<span>{% trans "E-Mail" %}</span> <div class="account-avatar-error">{{ avatar_form.avatar_image.errors|join:", " }}</div>
<strong>{{ account_user.email|default:"-" }}</strong> {% endif %}
<p class="account-avatar-hint">{% trans "Klicken Sie auf das Bild, um ein neues Profilbild auszuwählen." %}</p>
</form>
<div class="account-profile-copy">
<h2>{{ account_user.get_full_name|default:account_user.username }}</h2>
<p>{{ account_user.email|default:account_user.username }}</p>
</div> </div>
<div class="account-row"> <div class="account-profile-meta">
<div>
<span>{% trans "Rolle" %}</span> <span>{% trans "Rolle" %}</span>
<strong>{{ role_label }}</strong> <strong>{{ role_label }}</strong>
</div> </div>
<div class="account-row"> <div>
<span>{% trans "Benutzername" %}</span>
<strong>{{ account_user.username }}</strong>
</div>
<div>
<span>{% trans "Position" %}</span>
<strong>{{ account_user_profile.job_title|default:"-" }}</strong>
</div>
<div>
<span>{% trans "Abteilung" %}</span>
<strong>{{ account_user_profile.department|default:"-" }}</strong>
</div>
<div>
<span>{% trans "Letzte Anmeldung" %}</span> <span>{% trans "Letzte Anmeldung" %}</span>
<strong>{% if account_user.last_login %}{{ account_user.last_login|date:"d.m.Y H:i" }}{% else %}-{% endif %}</strong> <strong>{% if account_user.last_login %}{{ account_user.last_login|date:"d.m.Y H:i" }}{% else %}-{% endif %}</strong>
</div> </div>
</div> </div>
</aside>
<div class="account-main">
<section class="account-panel">
<div class="account-panel-head">
<div>
<h2>{% trans "Kontodaten" %}</h2>
<p>{% trans "Die wichtigsten Stammdaten Ihres aktuellen Kontos." %}</p>
</div>
<button
class="btn btn-secondary account-inline-edit-trigger"
type="button"
data-account-edit-toggle="details"
aria-expanded="{% if account_edit_open %}true{% else %}false{% endif %}"
>
{% trans "Bearbeiten" %}
</button>
</div>
<div class="account-inline-view{% if account_edit_open %} is-hidden{% endif %}" data-account-edit-view="details">
<div class="account-detail-grid">
<div class="account-detail">
<span>{% trans "Name" %}</span>
<strong>{{ account_user.get_full_name|default:"-" }}</strong>
</div>
<div class="account-detail">
<span>{% trans "E-Mail" %}</span>
<strong>{{ account_user.email|default:"-" }}</strong>
</div>
<div class="account-detail">
<span>{% trans "Vorname" %}</span>
<strong>{{ account_user.first_name|default:"-" }}</strong>
</div>
<div class="account-detail">
<span>{% trans "Nachname" %}</span>
<strong>{{ account_user.last_name|default:"-" }}</strong>
</div>
<div class="account-detail">
<span>{% trans "Telefon" %}</span>
<strong>{{ account_user_profile.phone_number|default:"-" }}</strong>
</div>
<div class="account-detail">
<span>{% trans "Mobil" %}</span>
<strong>{{ account_user_profile.mobile_number|default:"-" }}</strong>
</div>
<div class="account-detail">
<span>{% trans "Position" %}</span>
<strong>{{ account_user_profile.job_title|default:"-" }}</strong>
</div>
<div class="account-detail">
<span>{% trans "Abteilung" %}</span>
<strong>{{ account_user_profile.department|default:"-" }}</strong>
</div>
<div class="account-detail">
<span>{% trans "Standort" %}</span>
<strong>{{ account_user_profile.location|default:"-" }}</strong>
</div>
<div class="account-detail account-detail-wide">
<span>{% trans "Hinweise" %}</span>
<strong>{{ account_user_profile.contact_notes|default:"-" }}</strong>
</div>
</div>
</div>
<form
class="account-inline-form{% if not account_edit_open %} is-hidden{% endif %}"
method="post"
data-account-edit-form="details"
>
{% csrf_token %}
<input type="hidden" name="account_form" value="details" />
<div class="account-form-grid">
{% for field in details_form %}
<div class="account-form-field{% if field.name == 'contact_notes' %} account-form-field-wide{% endif %}{% 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 "Speichern" %}</button>
<button class="btn btn-secondary" type="button" data-account-edit-cancel="details">{% trans "Abbrechen" %}</button>
</div>
</form>
</section>
<section class="account-panel">
<div class="account-panel-head">
<h2>{% trans "Sicherheit & Aktionen" %}</h2>
<p>{% trans "Direkte Aktionen für Ihr Workdock-Konto." %}</p>
</div>
<div class="account-action-grid">
<a class="account-action-card" href="{% url 'password_change' %}">
<strong>{% trans "Passwort ändern" %}</strong>
<span>{% trans "Aktualisieren Sie Ihr Passwort direkt im Konto." %}</span>
</a>
<div class="account-action-card account-action-card-muted">
<strong>{% trans "Sitzung" %}</strong>
<span>{% trans "Sie können sich jederzeit sicher vom aktuellen Gerät abmelden." %}</span>
</div>
</div>
<div class="account-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>
@@ -47,6 +193,35 @@
<button class="btn btn-secondary" type="submit">{% trans "Abmelden" %}</button> <button class="btn btn-secondary" type="submit">{% trans "Abmelden" %}</button>
</form> </form>
</div> </div>
</section>
</div>
</div>
</div> </div>
</section> </section>
{% endblock %} {% endblock %}
{% block extra_scripts %}
<script>
(function () {
var toggle = document.querySelector('[data-account-edit-toggle="details"]');
var cancel = document.querySelector('[data-account-edit-cancel="details"]');
var view = document.querySelector('[data-account-edit-view="details"]');
var form = document.querySelector('[data-account-edit-form="details"]');
if (!toggle || !cancel || !view || !form) return;
function setMode(editing) {
view.classList.toggle('is-hidden', editing);
form.classList.toggle('is-hidden', !editing);
toggle.setAttribute('aria-expanded', editing ? 'true' : 'false');
}
toggle.addEventListener('click', function () {
setMode(true);
});
cancel.addEventListener('click', function () {
setMode(false);
});
}());
</script>
{% endblock %}

View File

@@ -23,11 +23,15 @@
<details class="app-user-menu"> <details class="app-user-menu">
<summary class="app-user-trigger"> <summary class="app-user-trigger">
<span class="app-user-avatar" aria-hidden="true"> <span class="app-user-avatar" aria-hidden="true">
{% if user_avatar_url %}
<img class="app-user-avatar-image" src="{{ user_avatar_url }}" alt="{% trans 'Profilbild' %}" />
{% else %}
{% if request.user.first_name or request.user.last_name %} {% if request.user.first_name or request.user.last_name %}
{{ request.user.first_name|slice:":1" }}{{ request.user.last_name|slice:":1" }} {{ request.user.first_name|slice:":1" }}{{ request.user.last_name|slice:":1" }}
{% else %} {% else %}
{{ request.user.username|slice:":2" }} {{ request.user.username|slice:":2" }}
{% endif %} {% endif %}
{% endif %}
</span> </span>
<span class="app-user-copy"> <span class="app-user-copy">
<strong>{{ request.user.get_full_name|default:request.user.username }}</strong> <strong>{{ request.user.get_full_name|default:request.user.username }}</strong>

View File

@@ -1,6 +1,8 @@
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 workflows.models import UserProfile
class AccountUISmokeTests(TestCase): class AccountUISmokeTests(TestCase):
def setUp(self): def setUp(self):
@@ -24,3 +26,32 @@ class AccountUISmokeTests(TestCase):
response = self.client.get('/accounts/password_change/', HTTP_HOST='localhost') response = self.client.get('/accounts/password_change/', HTTP_HOST='localhost')
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Aktuelles Passwort') self.assertContains(response, 'Aktuelles Passwort')
def test_user_profile_is_created_automatically(self):
self.assertTrue(UserProfile.objects.filter(user=self.user).exists())
def test_account_profile_details_can_be_updated(self):
response = self.client.post(
'/account/',
{
'account_form': 'details',
'first_name': 'Updated',
'last_name': 'User',
'email': 'updated@example.com',
'phone_number': '030 123456',
'mobile_number': '0176 123456',
'job_title': 'IT Manager',
'department': 'IT',
'location': 'Berlin',
'contact_notes': 'Available in the mornings',
},
HTTP_HOST='localhost',
follow=True,
)
self.assertEqual(response.status_code, 200)
self.user.refresh_from_db()
profile = self.user.profile
self.assertEqual(self.user.first_name, 'Updated')
self.assertEqual(self.user.email, 'updated@example.com')
self.assertEqual(profile.phone_number, '030 123456')
self.assertEqual(profile.job_title, 'IT Manager')

View File

@@ -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 OffboardingRequestForm, OnboardingRequestForm, PortalBrandingForm, PortalCompanyConfigForm, PortalTrialConfigForm, UserManagementCreateForm from .forms import AccountAvatarForm, AccountDetailsForm, 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,
@@ -42,7 +42,7 @@ from .form_builder import (
ONBOARDING_PAGE_ORDER, ONBOARDING_PAGE_ORDER,
ensure_form_field_configs, ensure_form_field_configs,
) )
from .models import AdminAuditLog, AsyncTaskLog, EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, PortalAppConfig, PortalBranding, PortalCompanyConfig, PortalTrialConfig, ScheduledWelcomeEmail, SystemEmailConfig, WorkflowConfig from .models import AdminAuditLog, AsyncTaskLog, EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, PortalAppConfig, PortalBranding, PortalCompanyConfig, PortalTrialConfig, ScheduledWelcomeEmail, SystemEmailConfig, UserProfile, WorkflowConfig
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
@@ -128,11 +128,36 @@ def healthz(request):
@login_required @login_required
def account_profile_page(request): def account_profile_page(request):
profile, created = UserProfile.objects.get_or_create(user=request.user)
avatar_form = AccountAvatarForm(instance=profile)
details_form = AccountDetailsForm(user=request.user, profile=profile)
account_edit_open = False
if request.method == 'POST':
form_kind = (request.POST.get('account_form') or '').strip()
if form_kind == 'avatar':
avatar_form = AccountAvatarForm(request.POST, request.FILES, instance=profile)
if avatar_form.is_valid():
avatar_form.save()
messages.success(request, _('Profilbild gespeichert.'))
return redirect('account_profile_page')
messages.error(request, _('Profilbild konnte nicht gespeichert werden.'))
elif form_kind == 'details':
account_edit_open = True
details_form = AccountDetailsForm(request.POST, user=request.user, profile=profile)
if details_form.is_valid():
details_form.save()
messages.success(request, _('Profildaten gespeichert.'))
return redirect('account_profile_page')
messages.error(request, _('Profildaten konnten nicht gespeichert werden.'))
return render( return render(
request, request,
'workflows/account_profile.html', 'workflows/account_profile.html',
{ {
'account_user': request.user, 'account_user': request.user,
'account_user_profile': profile,
'avatar_form': avatar_form,
'details_form': details_form,
'account_edit_open': account_edit_open,
'role_label': get_user_role_label(request.user), 'role_label': get_user_role_label(request.user),
}, },
) )