diff --git a/.env.example b/.env.example
index 5566960..1e76286 100644
--- a/.env.example
+++ b/.env.example
@@ -1,15 +1,26 @@
DJANGO_SECRET_KEY=change-me
DJANGO_DEBUG=1
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
+DJANGO_SECURE_COOKIES=0
+DJANGO_SECURE_SSL_REDIRECT=0
+SESSION_IDLE_TIMEOUT_SECONDS=1800
+SENSITIVE_ACTION_REAUTH_SECONDS=1200
-POSTGRES_DB=onoff
-POSTGRES_USER=onoff
-POSTGRES_PASSWORD=onoff
+POSTGRES_DB=workdock
+POSTGRES_USER=workdock
+POSTGRES_PASSWORD=workdock
POSTGRES_HOST=db
POSTGRES_PORT=5432
REDIS_URL=redis://redis:6379/0
CELERY_TASK_ALWAYS_EAGER=0
+RATE_LIMIT_ENABLED=1
+RATE_LIMIT_LOGIN_LIMIT=8
+RATE_LIMIT_LOGIN_WINDOW=300
+RATE_LIMIT_PASSWORD_RESET_LIMIT=5
+RATE_LIMIT_PASSWORD_RESET_WINDOW=600
+RATE_LIMIT_ADMIN_ACTION_LIMIT=20
+RATE_LIMIT_ADMIN_ACTION_WINDOW=300
EMAIL_HOST=mailhog
EMAIL_PORT=1025
diff --git a/PRODUCTIZATION_ROADMAP.md b/PRODUCTIZATION_ROADMAP.md
index 978c559..c05a29e 100644
--- a/PRODUCTIZATION_ROADMAP.md
+++ b/PRODUCTIZATION_ROADMAP.md
@@ -183,7 +183,7 @@ Examples already identified:
- former TUBCO-specific portal title kept only as historical baseline context
- logo asset references
- invitation email wording mentioning TUBCO
-- welcome-email defaults mentioning TUB/CO
+- historical product text references that still describe the original TUBCO baseline
- fixed letterhead file assumptions
These should move into configuration progressively, not all at once in one risky rewrite.
diff --git a/backend/config/settings.py b/backend/config/settings.py
index cfc5d11..045551c 100644
--- a/backend/config/settings.py
+++ b/backend/config/settings.py
@@ -1,4 +1,5 @@
import os
+import sys
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
@@ -25,6 +26,28 @@ CSRF_COOKIE_SECURE = _secure_cookies
DATA_UPLOAD_MAX_MEMORY_SIZE = int(os.getenv('DJANGO_DATA_UPLOAD_MAX_MEMORY_SIZE', str(10 * 1024 * 1024)))
FILE_UPLOAD_MAX_MEMORY_SIZE = int(os.getenv('DJANGO_FILE_UPLOAD_MAX_MEMORY_SIZE', str(5 * 1024 * 1024)))
+CACHES = {
+ 'default': {
+ 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
+ 'LOCATION': 'workdock-default-cache',
+ }
+}
+
+SESSION_COOKIE_AGE = int(os.getenv('DJANGO_SESSION_COOKIE_AGE', str(60 * 60 * 8)))
+SESSION_SAVE_EVERY_REQUEST = os.getenv('DJANGO_SESSION_SAVE_EVERY_REQUEST', '1') == '1'
+SESSION_EXPIRE_AT_BROWSER_CLOSE = os.getenv('DJANGO_SESSION_EXPIRE_AT_BROWSER_CLOSE', '1') == '1'
+SESSION_IDLE_TIMEOUT_SECONDS = int(os.getenv('SESSION_IDLE_TIMEOUT_SECONDS', str(60 * 30)))
+SENSITIVE_ACTION_REAUTH_SECONDS = int(os.getenv('SENSITIVE_ACTION_REAUTH_SECONDS', str(60 * 20)))
+
+RATE_LIMIT_LOGIN_LIMIT = int(os.getenv('RATE_LIMIT_LOGIN_LIMIT', '8'))
+RATE_LIMIT_LOGIN_WINDOW = int(os.getenv('RATE_LIMIT_LOGIN_WINDOW', '300'))
+RATE_LIMIT_PASSWORD_RESET_LIMIT = int(os.getenv('RATE_LIMIT_PASSWORD_RESET_LIMIT', '5'))
+RATE_LIMIT_PASSWORD_RESET_WINDOW = int(os.getenv('RATE_LIMIT_PASSWORD_RESET_WINDOW', '600'))
+RATE_LIMIT_ADMIN_ACTION_LIMIT = int(os.getenv('RATE_LIMIT_ADMIN_ACTION_LIMIT', '20'))
+RATE_LIMIT_ADMIN_ACTION_WINDOW = int(os.getenv('RATE_LIMIT_ADMIN_ACTION_WINDOW', '300'))
+RATE_LIMIT_ENABLED = os.getenv('RATE_LIMIT_ENABLED', '1') == '1'
+RUN_SECURITY_CHECKS_DURING_TESTS = os.getenv('RUN_SECURITY_CHECKS_DURING_TESTS', '0') == '1'
+
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
@@ -41,8 +64,10 @@ MIDDLEWARE = [
'django.middleware.locale.LocaleMiddleware',
'django.middleware.common.CommonMiddleware',
'workflows.middleware.RequestIDMiddleware',
+ 'workflows.middleware.RateLimitMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
+ 'workflows.middleware.AuthSessionHardeningMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'workflows.middleware.TrialModeMiddleware',
@@ -72,9 +97,9 @@ ASGI_APPLICATION = 'config.asgi.application'
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
- 'NAME': os.getenv('POSTGRES_DB', 'onoff'),
- 'USER': os.getenv('POSTGRES_USER', 'onoff'),
- 'PASSWORD': os.getenv('POSTGRES_PASSWORD', 'onoff'),
+ 'NAME': os.getenv('POSTGRES_DB', 'workdock'),
+ 'USER': os.getenv('POSTGRES_USER', 'workdock'),
+ 'PASSWORD': os.getenv('POSTGRES_PASSWORD', 'workdock'),
'HOST': os.getenv('POSTGRES_HOST', 'db'),
'PORT': int(os.getenv('POSTGRES_PORT', '5432')),
}
diff --git a/backend/config/urls.py b/backend/config/urls.py
index 12c21f2..81ef653 100644
--- a/backend/config/urls.py
+++ b/backend/config/urls.py
@@ -4,7 +4,7 @@ from django.contrib import admin
from django.contrib.auth import views as auth_views
from django.urls import include, path
-from workflows.forms import AppAuthenticationForm, AppPasswordResetForm, AppSetPasswordForm
+from workflows.forms import AppAuthenticationForm, AppPasswordChangeForm, AppPasswordResetForm, AppSetPasswordForm
urlpatterns = [
path('admin/', admin.site.urls),
@@ -24,6 +24,19 @@ urlpatterns = [
auth_views.PasswordResetView.as_view(template_name='workflows/auth/password_reset_form.html', form_class=AppPasswordResetForm),
name='password_reset',
),
+ path(
+ 'accounts/password_change/',
+ auth_views.PasswordChangeView.as_view(
+ template_name='workflows/auth/password_change_form.html',
+ form_class=AppPasswordChangeForm,
+ ),
+ name='password_change',
+ ),
+ path(
+ 'accounts/password_change/done/',
+ auth_views.PasswordChangeDoneView.as_view(template_name='workflows/auth/password_change_done.html'),
+ name='password_change_done',
+ ),
path(
'accounts/password_reset/done/',
auth_views.PasswordResetDoneView.as_view(template_name='workflows/auth/password_reset_done.html'),
diff --git a/backend/workflows/app_registry.py b/backend/workflows/app_registry.py
index 9e01c2b..bf46d01 100644
--- a/backend/workflows/app_registry.py
+++ b/backend/workflows/app_registry.py
@@ -8,6 +8,9 @@ from django.utils.translation import gettext_lazy as _
from .models import PortalAppConfig
from .roles import ROLE_ADMIN, ROLE_IT_STAFF, ROLE_LABELS, ROLE_PLATFORM_OWNER, ROLE_STAFF, ROLE_SUPER_ADMIN, get_user_role_key, user_has_capability
+# The registry controls discoverability and packaging posture for apps.
+# Actual authorization still comes from role capabilities in roles.py.
+
@dataclass(frozen=True)
class AppDefinition:
@@ -188,6 +191,8 @@ APP_DEFINITIONS: tuple[AppDefinition, ...] = (
DEFAULT_ROLE_VISIBILITY = {
+ # These defaults are product recommendations for fresh deployments.
+ # Saved PortalAppConfig rows can override them per customer installation.
'onboarding': {
ROLE_SUPER_ADMIN: True,
ROLE_ADMIN: True,
diff --git a/backend/workflows/apps.py b/backend/workflows/apps.py
index bf4f6e7..1706b63 100644
--- a/backend/workflows/apps.py
+++ b/backend/workflows/apps.py
@@ -7,3 +7,4 @@ class WorkflowsConfig(AppConfig):
def ready(self):
from . import signals # noqa: F401
+ from . import checks # noqa: F401
diff --git a/backend/workflows/backup_ops.py b/backend/workflows/backup_ops.py
index 6cf5ce3..d6516cf 100644
--- a/backend/workflows/backup_ops.py
+++ b/backend/workflows/backup_ops.py
@@ -18,6 +18,9 @@ from django.utils.translation import gettext as _
from .models import WorkflowConfig
from .services import delete_from_nextcloud, upload_to_nextcloud
+# Backup bundles are local-first. Remote copy is a secondary delivery path and
+# must never replace the ability to verify/restore the local bundle directly.
+
def _backup_root() -> Path:
root = Path(settings.BACKUP_OUTPUT_DIR)
@@ -115,6 +118,8 @@ def list_backup_bundles() -> list[dict]:
def latest_backup_health_snapshot(stale_after_hours: int = 48) -> dict:
+ # A single snapshot keeps the UI, scheduled command, and future monitoring
+ # on the same health contract.
rows = list_backup_bundles()
if not rows:
return {
@@ -329,7 +334,7 @@ def verify_backup_bundle(backup_name: str) -> dict:
env=env,
text=True,
).strip()
- with tempfile.TemporaryDirectory(prefix='tubco_backup_verify_media_') as tmpdir:
+ with tempfile.TemporaryDirectory(prefix='workdock_backup_verify_media_') as tmpdir:
with tarfile.open(media_archive_path, 'r:gz') as archive:
archive.extractall(tmpdir, filter='data')
media_dir = Path(tmpdir) / 'media'
diff --git a/backend/workflows/branding.py b/backend/workflows/branding.py
index 2220c3b..1679a0a 100644
--- a/backend/workflows/branding.py
+++ b/backend/workflows/branding.py
@@ -10,6 +10,10 @@ from django.utils.translation import get_language
from .models import PortalBranding, PortalCompanyConfig, PortalTrialConfig
+# Branding is the product/deployment boundary.
+# Workdock is the generic default, while stored DB values preserve the current
+# customer deployment identity such as TUBCO.
+
def get_portal_branding() -> PortalBranding:
branding, _ = PortalBranding.objects.get_or_create(
@@ -118,6 +122,8 @@ def get_portal_logo_url() -> str:
return branding.logo_image.url
except ValueError:
pass
+ # The fallback asset file is still the historical TUBCO wordmark. A later
+ # asset refresh can replace the file without changing the branding contract.
return static('workflows/img/tubco-logo.svg')
@@ -128,6 +134,7 @@ def get_portal_favicon_url() -> str:
return branding.favicon_image.url
except ValueError:
pass
+ # Same fallback rule as the logo: keep runtime stable now, replace asset later.
return static('workflows/img/tubco-logo.svg')
diff --git a/backend/workflows/checks.py b/backend/workflows/checks.py
new file mode 100644
index 0000000..39b4bfa
--- /dev/null
+++ b/backend/workflows/checks.py
@@ -0,0 +1,57 @@
+import sys
+
+from django.conf import settings
+from django.core.checks import Error, Warning, register
+
+
+@register()
+def security_settings_check(app_configs, **kwargs):
+ # Keep production checks strict in normal runtime, but avoid blocking the
+ # entire Django test runner before per-test overrides can take effect.
+ if 'test' in sys.argv and not settings.RUN_SECURITY_CHECKS_DURING_TESTS:
+ return []
+
+ issues = []
+
+ if not settings.DEBUG and settings.SECRET_KEY == 'unsafe-dev-key':
+ issues.append(
+ Error(
+ 'DJANGO_SECRET_KEY is using the development fallback while DEBUG is disabled.',
+ id='workdock.E001',
+ )
+ )
+
+ if not settings.DEBUG and not settings.ALLOWED_HOSTS:
+ issues.append(
+ Error(
+ 'ALLOWED_HOSTS must be configured when DEBUG is disabled.',
+ id='workdock.E002',
+ )
+ )
+
+ if not settings.DEBUG and not settings.SESSION_COOKIE_SECURE:
+ issues.append(
+ Error(
+ 'Secure session cookies must be enabled when DEBUG is disabled.',
+ id='workdock.E003',
+ )
+ )
+
+ if not settings.DEBUG and not settings.CSRF_COOKIE_SECURE:
+ issues.append(
+ Error(
+ 'Secure CSRF cookies must be enabled when DEBUG is disabled.',
+ id='workdock.E004',
+ )
+ )
+
+ if not settings.DEBUG and not settings.SECURE_SSL_REDIRECT:
+ issues.append(
+ Warning(
+ 'SECURE_SSL_REDIRECT is disabled while DEBUG is off.',
+ hint='Enable DJANGO_SECURE_SSL_REDIRECT=1 behind HTTPS-aware proxying.',
+ id='workdock.W001',
+ )
+ )
+
+ return issues
diff --git a/backend/workflows/forms.py b/backend/workflows/forms.py
index 8ddc4d3..fa15b03 100644
--- a/backend/workflows/forms.py
+++ b/backend/workflows/forms.py
@@ -2,7 +2,7 @@ from django import forms
from pathlib import Path
from datetime import timedelta
from django.contrib.auth import get_user_model, password_validation
-from django.contrib.auth.forms import AuthenticationForm, PasswordResetForm, SetPasswordForm
+from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm, PasswordResetForm, SetPasswordForm
from django.utils import timezone
from django.utils.translation import get_language, gettext as _, gettext_lazy
@@ -123,6 +123,25 @@ class AppSetPasswordForm(SetPasswordForm):
)
+class AppPasswordChangeForm(PasswordChangeForm):
+ old_password = forms.CharField(
+ label=gettext_lazy('Aktuelles Passwort'),
+ strip=False,
+ widget=forms.PasswordInput(attrs={'autocomplete': 'current-password'}),
+ )
+ new_password1 = forms.CharField(
+ label=gettext_lazy('Neues Passwort'),
+ strip=False,
+ widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}),
+ help_text=password_validation.password_validators_help_text_html(),
+ )
+ new_password2 = forms.CharField(
+ label=gettext_lazy('Neues Passwort bestätigen'),
+ strip=False,
+ widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}),
+ )
+
+
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)
@@ -385,7 +404,7 @@ class OnboardingRequestForm(forms.ModelForm):
widget=forms.CheckboxSelectMultiple,
)
phone_number_choice = forms.CharField(
- label='TUB/CO-Telefon-Direktwahl-Nr. 030 447202 (10-89)',
+ label='Telefon-Direktwahl',
required=False,
widget=forms.TextInput(attrs={'placeholder': 'z. B. 030 44720212'}),
)
diff --git a/backend/workflows/middleware.py b/backend/workflows/middleware.py
index 904ade5..e19f025 100644
--- a/backend/workflows/middleware.py
+++ b/backend/workflows/middleware.py
@@ -1,6 +1,15 @@
import uuid
-from django.shortcuts import render
+from django.conf import settings
+from django.contrib import messages
+from django.contrib.auth import logout
+from django.contrib.messages.api import MessageFailure
+from django.core.cache import cache
+from django.http import HttpResponse
+from django.shortcuts import redirect, render
+from django.urls import reverse
+from django.utils import timezone
+from django.utils.translation import gettext as _
from .branding import is_trial_expired, is_trial_mode_enabled
from .logging_utils import clear_request_id, set_request_id
@@ -29,6 +38,139 @@ class RequestIDMiddleware:
return response
+class RateLimitMiddleware:
+ LOGIN_PATHS = ('/accounts/login/',)
+ PASSWORD_RESET_PATHS = ('/accounts/password_reset/',)
+ # Keep this list path-prefix based so new platform actions get protected
+ # without having to wire every single view into a second permission layer.
+ ADMIN_SENSITIVE_PREFIXES = (
+ '/admin-tools/nextcloud/toggle/',
+ '/admin-tools/email-mode/toggle/',
+ '/admin-tools/integrations/save',
+ '/admin-tools/welcome-emails/',
+ '/admin-tools/branding/save/',
+ '/admin-tools/company/save/',
+ '/admin-tools/trial/save/',
+ '/admin-tools/apps/save/',
+ '/admin-tools/users/',
+ '/admin-tools/backups/',
+ '/requests/delete/',
+ '/requests/retry/',
+ )
+
+ def __init__(self, get_response):
+ self.get_response = get_response
+
+ def _client_identifier(self, request) -> str:
+ user = getattr(request, 'user', None)
+ if getattr(user, 'is_authenticated', False):
+ return f'user:{user.pk}'
+ forwarded = (request.META.get('HTTP_X_FORWARDED_FOR') or '').split(',')[0].strip()
+ remote = forwarded or request.META.get('REMOTE_ADDR') or 'unknown'
+ return f'ip:{remote}'
+
+ def _match_rule(self, path: str):
+ if any(path.startswith(prefix) for prefix in self.LOGIN_PATHS):
+ return ('login', settings.RATE_LIMIT_LOGIN_LIMIT, settings.RATE_LIMIT_LOGIN_WINDOW)
+ if any(path.startswith(prefix) for prefix in self.PASSWORD_RESET_PATHS):
+ return ('password_reset', settings.RATE_LIMIT_PASSWORD_RESET_LIMIT, settings.RATE_LIMIT_PASSWORD_RESET_WINDOW)
+ if any(path.startswith(prefix) for prefix in self.ADMIN_SENSITIVE_PREFIXES):
+ return ('admin_post', settings.RATE_LIMIT_ADMIN_ACTION_LIMIT, settings.RATE_LIMIT_ADMIN_ACTION_WINDOW)
+ return None
+
+ def __call__(self, request):
+ if not settings.RATE_LIMIT_ENABLED or request.method != 'POST':
+ return self.get_response(request)
+
+ rule = self._match_rule(request.path or '/')
+ if not rule:
+ return self.get_response(request)
+
+ scope, limit, window = rule
+ identifier = self._client_identifier(request)
+ cache_key = f'ratelimit:{scope}:{identifier}'
+ added = cache.add(cache_key, 1, timeout=window)
+ current = 1 if added else cache.incr(cache_key)
+ if current > limit:
+ response = HttpResponse(
+ _('Zu viele Anfragen. Bitte versuchen Sie es in wenigen Minuten erneut.'),
+ status=429,
+ content_type='text/plain; charset=utf-8',
+ )
+ retry_after = cache.ttl(cache_key) if hasattr(cache, 'ttl') else window
+ response['Retry-After'] = str(max(1, retry_after))
+ return response
+
+ return self.get_response(request)
+
+
+class AuthSessionHardeningMiddleware:
+ EXEMPT_PREFIXES = (
+ '/healthz/',
+ '/i18n/',
+ '/accounts/login/',
+ '/accounts/logout/',
+ '/accounts/password_reset/',
+ '/accounts/reset/',
+ '/static/',
+ '/media/',
+ )
+ SENSITIVE_POST_PREFIXES = (
+ '/admin-tools/users/',
+ '/admin-tools/backups/',
+ '/admin-tools/trial/save/',
+ '/admin-tools/apps/save/',
+ '/admin-tools/branding/save/',
+ '/admin-tools/company/save/',
+ '/admin-tools/integrations/save',
+ '/requests/delete/',
+ )
+
+ def __init__(self, get_response):
+ self.get_response = get_response
+
+ def _is_exempt(self, path: str) -> bool:
+ return any(path.startswith(prefix) for prefix in self.EXEMPT_PREFIXES)
+
+ def _touch_session(self, request, now_ts: int) -> None:
+ request.session['last_activity_ts'] = now_ts
+ request.session.setdefault('auth_fresh_ts', now_ts)
+
+ def _warn(self, request, message: str) -> None:
+ try:
+ messages.warning(request, message)
+ except MessageFailure:
+ return
+
+ def __call__(self, request):
+ path = request.path or '/'
+ user = getattr(request, 'user', None)
+ if not getattr(user, 'is_authenticated', False) or self._is_exempt(path):
+ return self.get_response(request)
+
+ now_ts = int(timezone.now().timestamp())
+ idle_timeout = max(60, settings.SESSION_IDLE_TIMEOUT_SECONDS)
+ last_activity_ts = int(request.session.get('last_activity_ts') or now_ts)
+ if now_ts - last_activity_ts > idle_timeout:
+ logout(request)
+ self._warn(request, _('Ihre Sitzung ist wegen Inaktivität abgelaufen. Bitte melden Sie sich erneut an.'))
+ login_url = reverse('login')
+ return redirect(f'{login_url}?next={request.get_full_path()}')
+
+ if request.method == 'POST' and any(path.startswith(prefix) for prefix in self.SENSITIVE_POST_PREFIXES):
+ fresh_window = max(60, settings.SENSITIVE_ACTION_REAUTH_SECONDS)
+ auth_fresh_ts = int(request.session.get('auth_fresh_ts') or last_activity_ts)
+ if now_ts - auth_fresh_ts > fresh_window:
+ logout(request)
+ self._warn(request, _('Bitte bestätigen Sie Ihre Identität erneut, bevor Sie diese sensible Aktion ausführen.'))
+ login_url = reverse('login')
+ return redirect(f'{login_url}?next={request.get_full_path()}')
+
+ response = self.get_response(request)
+ self._touch_session(request, now_ts)
+ return response
+
+
class TrialModeMiddleware:
EXEMPT_PREFIXES = (
'/healthz/',
diff --git a/backend/workflows/migrations/0046_alter_onboardingrequest_phone_number.py b/backend/workflows/migrations/0046_alter_onboardingrequest_phone_number.py
new file mode 100644
index 0000000..d00ea57
--- /dev/null
+++ b/backend/workflows/migrations/0046_alter_onboardingrequest_phone_number.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.1.5 on 2026-03-26 23:49
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('workflows', '0045_alter_portalbranding_company_domain_and_more'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='onboardingrequest',
+ name='phone_number',
+ field=models.CharField(blank=True, max_length=100, verbose_name='Telefon-Direktwahl'),
+ ),
+ ]
diff --git a/backend/workflows/models.py b/backend/workflows/models.py
index 0133958..92a8a4d 100644
--- a/backend/workflows/models.py
+++ b/backend/workflows/models.py
@@ -272,7 +272,7 @@ class OnboardingRequest(models.Model):
additional_access_needed = models.BooleanField(default=False, verbose_name='Werden weitere Zugänge benötigt?')
additional_access_text = models.TextField(blank=True, verbose_name='Weitere Zugänge (Freitext)')
needed_resources = models.TextField(blank=True, verbose_name='Benötigte Ressourcen')
- phone_number = models.CharField(max_length=100, blank=True, verbose_name='TUB/CO-Telefon-Direktwahl-Nr. 030 447202 (10-89)')
+ phone_number = models.CharField(max_length=100, blank=True, verbose_name='Telefon-Direktwahl')
successor_required = models.BooleanField(default=False, verbose_name='Neue Mitarbeitende ist Nachfolge von?')
successor_name = models.CharField(max_length=255, blank=True, verbose_name='Name der Vorgängerperson')
inherit_phone_number = models.BooleanField(default=False, verbose_name='Telefonnummer von Vorgängerperson übernehmen')
diff --git a/backend/workflows/roles.py b/backend/workflows/roles.py
index 579d4cb..1c4d684 100644
--- a/backend/workflows/roles.py
+++ b/backend/workflows/roles.py
@@ -4,6 +4,10 @@ from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from django.utils.translation import gettext_lazy as _
+# Product-level and company-level roles intentionally coexist here.
+# Workdock uses capability checks as the long-term contract so app-registry
+# visibility can stay a presentation concern instead of an authorization layer.
+
ROLE_PLATFORM_OWNER = 'platform_owner'
ROLE_SUPER_ADMIN = 'super_admin'
ROLE_ADMIN = 'admin'
@@ -27,6 +31,7 @@ ROLE_LABELS = {
}
CAPABILITIES = {
+ # Platform-only capabilities stay above any customer-company admin role.
'manage_users': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN},
'manage_product_branding': {ROLE_PLATFORM_OWNER},
'manage_company_config': {ROLE_PLATFORM_OWNER},
@@ -94,6 +99,8 @@ def ensure_bootstrap_role_assignments() -> None:
def get_user_role_key(user) -> str:
+ # Keep a conservative fallback for legacy staff users until a later
+ # dedicated cleanup phase removes the remaining historical assumptions.
if not getattr(user, 'is_authenticated', False):
return ROLE_STAFF
if getattr(user, 'is_superuser', False):
diff --git a/backend/workflows/static/workflows/css/app_chrome.css b/backend/workflows/static/workflows/css/app_chrome.css
index 954f249..aba6be6 100644
--- a/backend/workflows/static/workflows/css/app_chrome.css
+++ b/backend/workflows/static/workflows/css/app_chrome.css
@@ -180,6 +180,143 @@
align-items: center;
}
+.app-user-menu {
+ position: relative;
+}
+
+.app-user-trigger {
+ list-style: none;
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ min-height: 44px;
+ padding: 6px 10px 6px 6px;
+ border: 1px solid var(--app-line);
+ border-radius: 999px;
+ background: rgba(248, 251, 255, 0.92);
+ color: #1f3a5f;
+ cursor: pointer;
+ transition:
+ border-color var(--motion-fast) var(--motion-ease),
+ background-color var(--motion-fast) var(--motion-ease),
+ transform var(--motion-fast) var(--motion-ease),
+ box-shadow var(--motion-fast) var(--motion-ease);
+}
+
+.app-user-trigger::-webkit-details-marker {
+ display: none;
+}
+
+.app-user-trigger:hover {
+ transform: translateY(-1px);
+}
+
+.app-user-menu[open] .app-user-trigger {
+ border-color: rgba(0, 0, 120, 0.22);
+ box-shadow: 0 0 0 4px rgba(0, 0, 120, 0.08);
+}
+
+.app-user-avatar {
+ width: 32px;
+ height: 32px;
+ 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-weight: 800;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
+.app-user-copy {
+ display: grid;
+ gap: 1px;
+ text-align: left;
+}
+
+.app-user-copy strong {
+ font-size: 13px;
+ line-height: 1.2;
+}
+
+.app-user-copy span {
+ color: #64748b;
+ font-size: 11px;
+ line-height: 1.2;
+}
+
+.app-user-caret {
+ color: #64748b;
+ font-size: 12px;
+}
+
+.app-user-panel {
+ position: absolute;
+ top: calc(100% + 10px);
+ right: 0;
+ min-width: 220px;
+ padding: 10px;
+ border: 1px solid rgba(217, 227, 238, 0.96);
+ border-radius: 18px;
+ background: rgba(255, 255, 255, 0.98);
+ box-shadow: 0 24px 44px rgba(18, 34, 56, 0.16);
+ display: grid;
+ gap: 4px;
+ z-index: 40;
+}
+
+.app-user-panel-head {
+ display: grid;
+ gap: 2px;
+ padding: 4px 6px 8px;
+ margin-bottom: 4px;
+ border-bottom: 1px solid rgba(217, 227, 238, 0.85);
+}
+
+.app-user-panel-head strong {
+ color: #132238;
+ font-size: 13px;
+ line-height: 1.2;
+}
+
+.app-user-panel-head span {
+ color: #64748b;
+ font-size: 12px;
+ line-height: 1.4;
+}
+
+.app-user-panel a,
+.app-user-panel button {
+ width: 100%;
+ border: 0;
+ border-radius: 12px;
+ background: transparent;
+ color: #1f3a5f;
+ font: inherit;
+ font-size: 13px;
+ font-weight: 700;
+ text-align: left;
+ text-decoration: none;
+ padding: 10px 12px;
+ cursor: pointer;
+ transition:
+ background-color var(--motion-fast) var(--motion-ease),
+ color var(--motion-fast) var(--motion-ease);
+}
+
+.app-user-panel a:hover,
+.app-user-panel button:hover {
+ background: #f4f8ff;
+ color: var(--app-brand-blue);
+}
+
+.app-user-panel form {
+ margin: 0;
+}
+
.app-lang-switch {
display: flex;
gap: 6px;
diff --git a/backend/workflows/static/workflows/css/login.css b/backend/workflows/static/workflows/css/login.css
index 5efb371..2b3664a 100644
--- a/backend/workflows/static/workflows/css/login.css
+++ b/backend/workflows/static/workflows/css/login.css
@@ -47,6 +47,58 @@ body {
line-height: 1.45;
}
+.account-card {
+ width: min(560px, 100%);
+}
+
+.account-grid {
+ display: grid;
+ gap: 10px;
+ margin: 0 0 16px;
+}
+
+.account-row {
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+ align-items: baseline;
+ padding: 10px 12px;
+ border: 1px solid #d9e3f0;
+ border-radius: 12px;
+ background: #f9fbff;
+}
+
+.account-row span {
+ color: #607086;
+ font-size: 13px;
+}
+
+.account-row strong {
+ color: #132238;
+ font-size: 14px;
+ text-align: right;
+}
+
+.account-actions {
+ display: flex;
+ gap: 10px;
+}
+
+.account-actions .btn {
+ width: auto;
+}
+
+.account-actions form {
+ margin: 0;
+}
+
+.hint {
+ margin-top: 6px;
+ color: #8a5a00;
+ font-size: 12px;
+ line-height: 1.4;
+}
+
.field {
margin-bottom: 12px;
}
@@ -114,4 +166,21 @@ body {
padding: 18px;
border-radius: 16px;
}
+
+ .account-row {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .account-row strong {
+ text-align: left;
+ }
+
+ .account-actions {
+ flex-direction: column;
+ }
+
+ .account-actions .btn {
+ width: 100%;
+ }
}
diff --git a/backend/workflows/tasks.py b/backend/workflows/tasks.py
index e45e594..950e4eb 100644
--- a/backend/workflows/tasks.py
+++ b/backend/workflows/tasks.py
@@ -29,6 +29,9 @@ from .forms import (
WORKSPACE_GROUP_CHOICES,
)
+# These templates are the product-level defaults for fresh deployments.
+# Runtime branding and company config can override the company-facing identity
+# without changing the workflow/task logic itself.
DEFAULT_NOTIFICATION_TEMPLATES = {
'onboarding_it': {
@@ -158,27 +161,27 @@ DEFAULT_NOTIFICATION_TEMPLATES = {
),
},
'onboarding_welcome': {
- 'subject': 'Willkommen bei TUB/CO, {{ VORNAME }}',
- 'subject_en': 'Welcome to TUB/CO, {{ VORNAME }}',
+ 'subject': 'Willkommen bei {{ COMPANY_NAME }}, {{ VORNAME }}',
+ 'subject_en': 'Welcome to {{ COMPANY_NAME }}, {{ VORNAME }}',
'body': (
'Hallo {{ FULL_NAME }},\n\n'
- 'herzlich willkommen bei TUB/CO.\n'
+ 'herzlich willkommen bei {{ COMPANY_NAME }}.\n'
'Wir freuen uns sehr, dass du ab dem {{ CONTRACT_START }} unser Team in der Abteilung {{ DEPARTMENT }} verstärkst.\n\n'
'Deine dienstliche E-Mail-Adresse lautet: {{ EMAIL }}.\n'
'Im Anhang findest du deine Onboarding-Unterlagen als PDF.\n\n'
'Wenn du Fragen hast, melde dich gerne jederzeit.\n\n'
'Viele Grüße\n'
- 'TUB/CO IT'
+ '{{ COMPANY_NAME }} IT'
),
'body_en': (
'Hello {{ FULL_NAME }},\n\n'
- 'welcome to TUB/CO.\n'
+ 'welcome to {{ COMPANY_NAME }}.\n'
'We are very happy that you will join our {{ DEPARTMENT }} team starting on {{ CONTRACT_START }}.\n\n'
'Your work email address is: {{ EMAIL }}.\n'
'You will find your onboarding documents attached as a PDF.\n\n'
'If you have any questions, feel free to contact us anytime.\n\n'
'Best regards,\n'
- 'TUB/CO IT'
+ '{{ COMPANY_NAME }} IT'
),
},
'offboarding_it': {
diff --git a/backend/workflows/templates/workflows/account_profile.html b/backend/workflows/templates/workflows/account_profile.html
new file mode 100644
index 0000000..f5dacb1
--- /dev/null
+++ b/backend/workflows/templates/workflows/account_profile.html
@@ -0,0 +1,52 @@
+{% extends 'workflows/base_shell.html' %}
+{% load static i18n %}
+
+{% block title %}{% trans "Profil" %}{% endblock %}
+
+{% block shell_header %}
+{% include 'workflows/includes/app_header.html' with header_show_home=1 header_show_lang=1 header_inside_shell=1 %}
+{% endblock %}
+
+{% block extra_css %}
+
+{% endblock %}
+
+{% block shell_body %}
+ {% trans "Ihre aktuelle Workdock-Kontoübersicht und direkte Kontoaktionen." %} {% trans "Ihr Passwort wurde erfolgreich aktualisiert." %} {% trans "Vergeben Sie ein neues Passwort für Ihr Konto." %}{% trans "Profil" %}
+ {% trans "Passwort geändert" %}
+ {% trans "Passwort ändern" %}
+
PortalBranding.Branding.@tub.co code changes.