snapshot: preserve account profile inline editing and avatar flow
This commit is contained in:
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ from django.utils.translation import get_language, gettext as _, gettext_lazy
|
||||
|
||||
from .branding import get_company_email_domain
|
||||
from .form_builder import apply_form_field_config
|
||||
from .models import EmployeeProfile, FormOption, OffboardingRequest, OnboardingRequest, PortalBranding, PortalCompanyConfig, PortalTrialConfig, 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
|
||||
|
||||
|
||||
@@ -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):
|
||||
first_name = forms.CharField(label=_('Vorname'), max_length=150, required=False)
|
||||
last_name = forms.CharField(label=_('Nachname'), max_length=150, required=False)
|
||||
|
||||
42
backend/workflows/migrations/0048_userprofile.py
Normal file
42
backend/workflows/migrations/0048_userprofile.py
Normal 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),
|
||||
]
|
||||
@@ -25,6 +25,30 @@ class EmployeeProfile(models.Model):
|
||||
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):
|
||||
name = models.CharField(max_length=80, default='Default', unique=True)
|
||||
portal_title = models.CharField(max_length=255, default='Workdock')
|
||||
|
||||
@@ -131,9 +131,16 @@ def user_has_capability(user, capability: str) -> bool:
|
||||
|
||||
def template_role_context(user) -> dict[str, object]:
|
||||
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 {
|
||||
'role_key': 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_company_config': user_has_capability(user, 'manage_company_config'),
|
||||
'can_manage_trial_lifecycle': user_has_capability(user, 'manage_trial_lifecycle'),
|
||||
|
||||
@@ -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 .models import UserProfile
|
||||
from .roles import ensure_bootstrap_role_assignments, ensure_role_groups
|
||||
|
||||
|
||||
@@ -10,3 +12,9 @@ def workflows_post_migrate(sender, **kwargs):
|
||||
return
|
||||
ensure_role_groups()
|
||||
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)
|
||||
|
||||
486
backend/workflows/static/workflows/css/account.css
Normal file
486
backend/workflows/static/workflows/css/account.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -190,7 +190,7 @@
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 44px;
|
||||
padding: 6px 10px 6px 6px;
|
||||
padding: 6px 10px 6px 8px;
|
||||
border: 1px solid var(--app-line);
|
||||
border-radius: 999px;
|
||||
background: rgba(248, 251, 255, 0.92);
|
||||
@@ -217,18 +217,27 @@
|
||||
}
|
||||
|
||||
.app-user-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(180deg, var(--app-brand-blue), #1d3ca8);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
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 {
|
||||
|
||||
@@ -8,45 +8,220 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="{% static 'workflows/css/login.css' %}" />
|
||||
<link rel="stylesheet" href="{% static 'workflows/css/account.css' %}" />
|
||||
{% endblock %}
|
||||
|
||||
{% block shell_body %}
|
||||
<section class="login-shell-body">
|
||||
<div class="login-card account-card">
|
||||
<h1>{% trans "Profil" %}</h1>
|
||||
<p>{% trans "Ihre aktuelle Workdock-Kontoübersicht und direkte Kontoaktionen." %}</p>
|
||||
<section class="account-shell-body">
|
||||
<div class="account-page">
|
||||
<section class="account-hero">
|
||||
<div class="account-hero-copy">
|
||||
<span class="account-kicker">{% trans "Konto" %}</span>
|
||||
<h1>{% trans "Profil" %}</h1>
|
||||
<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-row">
|
||||
<span>{% trans "Name" %}</span>
|
||||
<strong>{{ account_user.get_full_name|default:account_user.username }}</strong>
|
||||
</div>
|
||||
<div class="account-row">
|
||||
<span>{% trans "Benutzername" %}</span>
|
||||
<strong>{{ account_user.username }}</strong>
|
||||
</div>
|
||||
<div class="account-row">
|
||||
<span>{% trans "E-Mail" %}</span>
|
||||
<strong>{{ account_user.email|default:"-" }}</strong>
|
||||
</div>
|
||||
<div class="account-row">
|
||||
<span>{% trans "Rolle" %}</span>
|
||||
<strong>{{ role_label }}</strong>
|
||||
</div>
|
||||
<div class="account-row">
|
||||
<span>{% trans "Letzte Anmeldung" %}</span>
|
||||
<strong>{% if account_user.last_login %}{{ account_user.last_login|date:"d.m.Y H:i" }}{% else %}-{% endif %}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="account-layout">
|
||||
<aside class="account-profile-card">
|
||||
<form class="account-avatar-form" method="post" enctype="multipart/form-data">
|
||||
{% 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>
|
||||
{% endif %}
|
||||
<span class="account-avatar-edit" aria-hidden="true">✎</span>
|
||||
</label>
|
||||
<div class="account-avatar-input" hidden>
|
||||
{{ avatar_form.avatar_image }}
|
||||
</div>
|
||||
{% if avatar_form.avatar_image.errors %}
|
||||
<div class="account-avatar-error">{{ avatar_form.avatar_image.errors|join:", " }}</div>
|
||||
{% 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 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>
|
||||
<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>
|
||||
<strong>{% if account_user.last_login %}{{ account_user.last_login|date:"d.m.Y H:i" }}{% else %}-{% endif %}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="account-actions">
|
||||
<a class="btn btn-primary" href="{% url 'password_change' %}">{% trans "Passwort ändern" %}</a>
|
||||
<form method="post" action="{% url 'logout' %}">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-secondary" type="submit">{% trans "Abmelden" %}</button>
|
||||
</form>
|
||||
<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">
|
||||
<a class="btn btn-primary" href="{% url 'password_change' %}">{% trans "Passwort ändern" %}</a>
|
||||
<form method="post" action="{% url 'logout' %}">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-secondary" type="submit">{% trans "Abmelden" %}</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% 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 %}
|
||||
|
||||
@@ -23,10 +23,14 @@
|
||||
<details class="app-user-menu">
|
||||
<summary class="app-user-trigger">
|
||||
<span class="app-user-avatar" aria-hidden="true">
|
||||
{% if request.user.first_name or request.user.last_name %}
|
||||
{{ request.user.first_name|slice:":1" }}{{ request.user.last_name|slice:":1" }}
|
||||
{% if user_avatar_url %}
|
||||
<img class="app-user-avatar-image" src="{{ user_avatar_url }}" alt="{% trans 'Profilbild' %}" />
|
||||
{% else %}
|
||||
{{ request.user.username|slice:":2" }}
|
||||
{% if request.user.first_name or request.user.last_name %}
|
||||
{{ request.user.first_name|slice:":1" }}{{ request.user.last_name|slice:":1" }}
|
||||
{% else %}
|
||||
{{ request.user.username|slice:":2" }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="app-user-copy">
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import Client, TestCase
|
||||
|
||||
from workflows.models import UserProfile
|
||||
|
||||
|
||||
class AccountUISmokeTests(TestCase):
|
||||
def setUp(self):
|
||||
@@ -24,3 +26,32 @@ class AccountUISmokeTests(TestCase):
|
||||
response = self.client.get('/accounts/password_change/', HTTP_HOST='localhost')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
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')
|
||||
|
||||
@@ -33,7 +33,7 @@ from .backup_ops import (
|
||||
verify_backup_bundle,
|
||||
)
|
||||
from .branding import get_branding_email_copy, get_company_email_domain, get_default_notification_templates, get_portal_trial_config, is_trial_expired
|
||||
from .forms import OffboardingRequestForm, OnboardingRequestForm, PortalBrandingForm, PortalCompanyConfigForm, PortalTrialConfigForm, UserManagementCreateForm
|
||||
from .forms import AccountAvatarForm, AccountDetailsForm, OffboardingRequestForm, OnboardingRequestForm, PortalBrandingForm, PortalCompanyConfigForm, PortalTrialConfigForm, UserManagementCreateForm
|
||||
from .form_builder import (
|
||||
DEFAULT_FIELD_ORDER,
|
||||
LOCKED_FIELD_RULES,
|
||||
@@ -42,7 +42,7 @@ from .form_builder import (
|
||||
ONBOARDING_PAGE_ORDER,
|
||||
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 .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
|
||||
@@ -128,11 +128,36 @@ def healthz(request):
|
||||
|
||||
@login_required
|
||||
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(
|
||||
request,
|
||||
'workflows/account_profile.html',
|
||||
{
|
||||
'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),
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user