snapshot: preserve session hardening and account surface
This commit is contained in:
17
.env.example
17
.env.example
@@ -1,15 +1,26 @@
|
|||||||
DJANGO_SECRET_KEY=change-me
|
DJANGO_SECRET_KEY=change-me
|
||||||
DJANGO_DEBUG=1
|
DJANGO_DEBUG=1
|
||||||
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.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_DB=workdock
|
||||||
POSTGRES_USER=onoff
|
POSTGRES_USER=workdock
|
||||||
POSTGRES_PASSWORD=onoff
|
POSTGRES_PASSWORD=workdock
|
||||||
POSTGRES_HOST=db
|
POSTGRES_HOST=db
|
||||||
POSTGRES_PORT=5432
|
POSTGRES_PORT=5432
|
||||||
|
|
||||||
REDIS_URL=redis://redis:6379/0
|
REDIS_URL=redis://redis:6379/0
|
||||||
CELERY_TASK_ALWAYS_EAGER=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_HOST=mailhog
|
||||||
EMAIL_PORT=1025
|
EMAIL_PORT=1025
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ Examples already identified:
|
|||||||
- former TUBCO-specific portal title kept only as historical baseline context
|
- former TUBCO-specific portal title kept only as historical baseline context
|
||||||
- logo asset references
|
- logo asset references
|
||||||
- invitation email wording mentioning TUBCO
|
- 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
|
- fixed letterhead file assumptions
|
||||||
|
|
||||||
These should move into configuration progressively, not all at once in one risky rewrite.
|
These should move into configuration progressively, not all at once in one risky rewrite.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
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)))
|
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)))
|
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 = [
|
INSTALLED_APPS = [
|
||||||
'django.contrib.admin',
|
'django.contrib.admin',
|
||||||
'django.contrib.auth',
|
'django.contrib.auth',
|
||||||
@@ -41,8 +64,10 @@ MIDDLEWARE = [
|
|||||||
'django.middleware.locale.LocaleMiddleware',
|
'django.middleware.locale.LocaleMiddleware',
|
||||||
'django.middleware.common.CommonMiddleware',
|
'django.middleware.common.CommonMiddleware',
|
||||||
'workflows.middleware.RequestIDMiddleware',
|
'workflows.middleware.RequestIDMiddleware',
|
||||||
|
'workflows.middleware.RateLimitMiddleware',
|
||||||
'django.middleware.csrf.CsrfViewMiddleware',
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
|
'workflows.middleware.AuthSessionHardeningMiddleware',
|
||||||
'django.contrib.messages.middleware.MessageMiddleware',
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
'workflows.middleware.TrialModeMiddleware',
|
'workflows.middleware.TrialModeMiddleware',
|
||||||
@@ -72,9 +97,9 @@ ASGI_APPLICATION = 'config.asgi.application'
|
|||||||
DATABASES = {
|
DATABASES = {
|
||||||
'default': {
|
'default': {
|
||||||
'ENGINE': 'django.db.backends.postgresql',
|
'ENGINE': 'django.db.backends.postgresql',
|
||||||
'NAME': os.getenv('POSTGRES_DB', 'onoff'),
|
'NAME': os.getenv('POSTGRES_DB', 'workdock'),
|
||||||
'USER': os.getenv('POSTGRES_USER', 'onoff'),
|
'USER': os.getenv('POSTGRES_USER', 'workdock'),
|
||||||
'PASSWORD': os.getenv('POSTGRES_PASSWORD', 'onoff'),
|
'PASSWORD': os.getenv('POSTGRES_PASSWORD', 'workdock'),
|
||||||
'HOST': os.getenv('POSTGRES_HOST', 'db'),
|
'HOST': os.getenv('POSTGRES_HOST', 'db'),
|
||||||
'PORT': int(os.getenv('POSTGRES_PORT', '5432')),
|
'PORT': int(os.getenv('POSTGRES_PORT', '5432')),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from django.contrib import admin
|
|||||||
from django.contrib.auth import views as auth_views
|
from django.contrib.auth import views as auth_views
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
|
|
||||||
from workflows.forms import AppAuthenticationForm, AppPasswordResetForm, AppSetPasswordForm
|
from workflows.forms import AppAuthenticationForm, AppPasswordChangeForm, AppPasswordResetForm, AppSetPasswordForm
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('admin/', admin.site.urls),
|
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),
|
auth_views.PasswordResetView.as_view(template_name='workflows/auth/password_reset_form.html', form_class=AppPasswordResetForm),
|
||||||
name='password_reset',
|
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(
|
path(
|
||||||
'accounts/password_reset/done/',
|
'accounts/password_reset/done/',
|
||||||
auth_views.PasswordResetDoneView.as_view(template_name='workflows/auth/password_reset_done.html'),
|
auth_views.PasswordResetDoneView.as_view(template_name='workflows/auth/password_reset_done.html'),
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
from .models import PortalAppConfig
|
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
|
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)
|
@dataclass(frozen=True)
|
||||||
class AppDefinition:
|
class AppDefinition:
|
||||||
@@ -188,6 +191,8 @@ APP_DEFINITIONS: tuple[AppDefinition, ...] = (
|
|||||||
|
|
||||||
|
|
||||||
DEFAULT_ROLE_VISIBILITY = {
|
DEFAULT_ROLE_VISIBILITY = {
|
||||||
|
# These defaults are product recommendations for fresh deployments.
|
||||||
|
# Saved PortalAppConfig rows can override them per customer installation.
|
||||||
'onboarding': {
|
'onboarding': {
|
||||||
ROLE_SUPER_ADMIN: True,
|
ROLE_SUPER_ADMIN: True,
|
||||||
ROLE_ADMIN: True,
|
ROLE_ADMIN: True,
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ class WorkflowsConfig(AppConfig):
|
|||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
from . import signals # noqa: F401
|
from . import signals # noqa: F401
|
||||||
|
from . import checks # noqa: F401
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ from django.utils.translation import gettext as _
|
|||||||
from .models import WorkflowConfig
|
from .models import WorkflowConfig
|
||||||
from .services import delete_from_nextcloud, upload_to_nextcloud
|
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:
|
def _backup_root() -> Path:
|
||||||
root = Path(settings.BACKUP_OUTPUT_DIR)
|
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:
|
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()
|
rows = list_backup_bundles()
|
||||||
if not rows:
|
if not rows:
|
||||||
return {
|
return {
|
||||||
@@ -329,7 +334,7 @@ def verify_backup_bundle(backup_name: str) -> dict:
|
|||||||
env=env,
|
env=env,
|
||||||
text=True,
|
text=True,
|
||||||
).strip()
|
).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:
|
with tarfile.open(media_archive_path, 'r:gz') as archive:
|
||||||
archive.extractall(tmpdir, filter='data')
|
archive.extractall(tmpdir, filter='data')
|
||||||
media_dir = Path(tmpdir) / 'media'
|
media_dir = Path(tmpdir) / 'media'
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ from django.utils.translation import get_language
|
|||||||
|
|
||||||
from .models import PortalBranding, PortalCompanyConfig, PortalTrialConfig
|
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:
|
def get_portal_branding() -> PortalBranding:
|
||||||
branding, _ = PortalBranding.objects.get_or_create(
|
branding, _ = PortalBranding.objects.get_or_create(
|
||||||
@@ -118,6 +122,8 @@ def get_portal_logo_url() -> str:
|
|||||||
return branding.logo_image.url
|
return branding.logo_image.url
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
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')
|
return static('workflows/img/tubco-logo.svg')
|
||||||
|
|
||||||
|
|
||||||
@@ -128,6 +134,7 @@ def get_portal_favicon_url() -> str:
|
|||||||
return branding.favicon_image.url
|
return branding.favicon_image.url
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
# Same fallback rule as the logo: keep runtime stable now, replace asset later.
|
||||||
return static('workflows/img/tubco-logo.svg')
|
return static('workflows/img/tubco-logo.svg')
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
57
backend/workflows/checks.py
Normal file
57
backend/workflows/checks.py
Normal file
@@ -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
|
||||||
@@ -2,7 +2,7 @@ from django import forms
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from django.contrib.auth import get_user_model, password_validation
|
from django.contrib.auth import get_user_model, password_validation
|
||||||
from django.contrib.auth.forms import AuthenticationForm, PasswordResetForm, SetPasswordForm
|
from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm, PasswordResetForm, SetPasswordForm
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import get_language, gettext as _, gettext_lazy
|
from django.utils.translation import get_language, gettext as _, gettext_lazy
|
||||||
|
|
||||||
@@ -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):
|
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)
|
||||||
@@ -385,7 +404,7 @@ class OnboardingRequestForm(forms.ModelForm):
|
|||||||
widget=forms.CheckboxSelectMultiple,
|
widget=forms.CheckboxSelectMultiple,
|
||||||
)
|
)
|
||||||
phone_number_choice = forms.CharField(
|
phone_number_choice = forms.CharField(
|
||||||
label='TUB/CO-Telefon-Direktwahl-Nr. 030 447202 (10-89)',
|
label='Telefon-Direktwahl',
|
||||||
required=False,
|
required=False,
|
||||||
widget=forms.TextInput(attrs={'placeholder': 'z. B. 030 44720212'}),
|
widget=forms.TextInput(attrs={'placeholder': 'z. B. 030 44720212'}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import uuid
|
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 .branding import is_trial_expired, is_trial_mode_enabled
|
||||||
from .logging_utils import clear_request_id, set_request_id
|
from .logging_utils import clear_request_id, set_request_id
|
||||||
@@ -29,6 +38,139 @@ class RequestIDMiddleware:
|
|||||||
return response
|
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:
|
class TrialModeMiddleware:
|
||||||
EXEMPT_PREFIXES = (
|
EXEMPT_PREFIXES = (
|
||||||
'/healthz/',
|
'/healthz/',
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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_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)')
|
additional_access_text = models.TextField(blank=True, verbose_name='Weitere Zugänge (Freitext)')
|
||||||
needed_resources = models.TextField(blank=True, verbose_name='Benötigte Ressourcen')
|
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_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')
|
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')
|
inherit_phone_number = models.BooleanField(default=False, verbose_name='Telefonnummer von Vorgängerperson übernehmen')
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ from django.contrib.auth import get_user_model
|
|||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
from django.utils.translation import gettext_lazy as _
|
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_PLATFORM_OWNER = 'platform_owner'
|
||||||
ROLE_SUPER_ADMIN = 'super_admin'
|
ROLE_SUPER_ADMIN = 'super_admin'
|
||||||
ROLE_ADMIN = 'admin'
|
ROLE_ADMIN = 'admin'
|
||||||
@@ -27,6 +31,7 @@ ROLE_LABELS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
CAPABILITIES = {
|
CAPABILITIES = {
|
||||||
|
# Platform-only capabilities stay above any customer-company admin role.
|
||||||
'manage_users': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN},
|
'manage_users': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN},
|
||||||
'manage_product_branding': {ROLE_PLATFORM_OWNER},
|
'manage_product_branding': {ROLE_PLATFORM_OWNER},
|
||||||
'manage_company_config': {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:
|
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):
|
if not getattr(user, 'is_authenticated', False):
|
||||||
return ROLE_STAFF
|
return ROLE_STAFF
|
||||||
if getattr(user, 'is_superuser', False):
|
if getattr(user, 'is_superuser', False):
|
||||||
|
|||||||
@@ -180,6 +180,143 @@
|
|||||||
align-items: center;
|
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 {
|
.app-lang-switch {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
|||||||
@@ -47,6 +47,58 @@ body {
|
|||||||
line-height: 1.45;
|
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 {
|
.field {
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
@@ -114,4 +166,21 @@ body {
|
|||||||
padding: 18px;
|
padding: 18px;
|
||||||
border-radius: 16px;
|
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%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ from .forms import (
|
|||||||
WORKSPACE_GROUP_CHOICES,
|
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 = {
|
DEFAULT_NOTIFICATION_TEMPLATES = {
|
||||||
'onboarding_it': {
|
'onboarding_it': {
|
||||||
@@ -158,27 +161,27 @@ DEFAULT_NOTIFICATION_TEMPLATES = {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
'onboarding_welcome': {
|
'onboarding_welcome': {
|
||||||
'subject': 'Willkommen bei TUB/CO, {{ VORNAME }}',
|
'subject': 'Willkommen bei {{ COMPANY_NAME }}, {{ VORNAME }}',
|
||||||
'subject_en': 'Welcome to TUB/CO, {{ VORNAME }}',
|
'subject_en': 'Welcome to {{ COMPANY_NAME }}, {{ VORNAME }}',
|
||||||
'body': (
|
'body': (
|
||||||
'Hallo {{ FULL_NAME }},\n\n'
|
'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'
|
'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'
|
'Deine dienstliche E-Mail-Adresse lautet: {{ EMAIL }}.\n'
|
||||||
'Im Anhang findest du deine Onboarding-Unterlagen als PDF.\n\n'
|
'Im Anhang findest du deine Onboarding-Unterlagen als PDF.\n\n'
|
||||||
'Wenn du Fragen hast, melde dich gerne jederzeit.\n\n'
|
'Wenn du Fragen hast, melde dich gerne jederzeit.\n\n'
|
||||||
'Viele Grüße\n'
|
'Viele Grüße\n'
|
||||||
'TUB/CO IT'
|
'{{ COMPANY_NAME }} IT'
|
||||||
),
|
),
|
||||||
'body_en': (
|
'body_en': (
|
||||||
'Hello {{ FULL_NAME }},\n\n'
|
'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'
|
'We are very happy that you will join our {{ DEPARTMENT }} team starting on {{ CONTRACT_START }}.\n\n'
|
||||||
'Your work email address is: {{ EMAIL }}.\n'
|
'Your work email address is: {{ EMAIL }}.\n'
|
||||||
'You will find your onboarding documents attached as a PDF.\n\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'
|
'If you have any questions, feel free to contact us anytime.\n\n'
|
||||||
'Best regards,\n'
|
'Best regards,\n'
|
||||||
'TUB/CO IT'
|
'{{ COMPANY_NAME }} IT'
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
'offboarding_it': {
|
'offboarding_it': {
|
||||||
|
|||||||
52
backend/workflows/templates/workflows/account_profile.html
Normal file
52
backend/workflows/templates/workflows/account_profile.html
Normal file
@@ -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 %}
|
||||||
|
<link rel="stylesheet" href="{% static 'workflows/css/login.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>
|
||||||
|
|
||||||
|
<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-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>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{% extends 'workflows/base_shell.html' %}
|
||||||
|
{% load static i18n %}
|
||||||
|
|
||||||
|
{% block title %}{% trans "Passwort geändert" %}{% endblock %}
|
||||||
|
|
||||||
|
{% block shell_header %}
|
||||||
|
{% include 'workflows/includes/app_header.html' with header_show_lang=1 header_inside_shell=1 %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<link rel="stylesheet" href="{% static 'workflows/css/login.css' %}" />
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block shell_body %}
|
||||||
|
<section class="login-shell-body">
|
||||||
|
<div class="login-card">
|
||||||
|
<h1>{% trans "Passwort geändert" %}</h1>
|
||||||
|
<p>{% trans "Ihr Passwort wurde erfolgreich aktualisiert." %}</p>
|
||||||
|
<a class="btn btn-primary" href="{% url 'account_profile_page' %}">{% trans "Zum Profil" %}</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{% extends 'workflows/base_shell.html' %}
|
||||||
|
{% load static i18n %}
|
||||||
|
|
||||||
|
{% block title %}{% trans "Passwort ändern" %}{% endblock %}
|
||||||
|
|
||||||
|
{% block shell_header %}
|
||||||
|
{% include 'workflows/includes/app_header.html' with header_show_lang=1 header_inside_shell=1 %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<link rel="stylesheet" href="{% static 'workflows/css/login.css' %}" />
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block shell_body %}
|
||||||
|
<section class="login-shell-body">
|
||||||
|
<div class="login-card">
|
||||||
|
<h1>{% trans "Passwort ändern" %}</h1>
|
||||||
|
<p>{% trans "Vergeben Sie ein neues Passwort für Ihr Konto." %}</p>
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="field{% if form.old_password.errors %} has-error{% endif %}">
|
||||||
|
{{ form.old_password.label_tag }}{{ form.old_password }}
|
||||||
|
{% for error in form.old_password.errors %}<div class="hint">{{ error }}</div>{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="field{% if form.new_password1.errors %} has-error{% endif %}">
|
||||||
|
{{ form.new_password1.label_tag }}{{ form.new_password1 }}
|
||||||
|
{% for error in form.new_password1.errors %}<div class="hint">{{ error }}</div>{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="field{% if form.new_password2.errors %} has-error{% endif %}">
|
||||||
|
{{ form.new_password2.label_tag }}{{ form.new_password2 }}
|
||||||
|
{% for error in form.new_password2.errors %}<div class="hint">{{ error }}</div>{% endfor %}
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" type="submit">{% trans "Passwort speichern" %}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -177,7 +177,7 @@ docker compose exec -T web django-admin compilemessages</code></pre>
|
|||||||
<li>Portal-level branding is stored in the singleton model <code>PortalBranding</code>.</li>
|
<li>Portal-level branding is stored in the singleton model <code>PortalBranding</code>.</li>
|
||||||
<li>Configured from Admin Apps → <code>Branding</code>.</li>
|
<li>Configured from Admin Apps → <code>Branding</code>.</li>
|
||||||
<li>Current scope: portal title, company name, company domain, support email, sender display name, login subtitle, footer/legal text, logo, favicon, PDF letterhead, and primary/secondary colors.</li>
|
<li>Current scope: portal title, company name, company domain, support email, sender display name, login subtitle, footer/legal text, logo, favicon, PDF letterhead, and primary/secondary colors.</li>
|
||||||
<li>Shared header/logo rendering now uses the branding context processor instead of hardcoded TUBCO asset references.</li>
|
<li>Shared header/logo rendering now uses the branding context processor instead of hardcoded customer-specific asset references.</li>
|
||||||
<li>The company domain now drives onboarding/offboarding email autofill and domain validation, so new customer deployments no longer require <code>@tub.co</code> code changes.</li>
|
<li>The company domain now drives onboarding/offboarding email autofill and domain validation, so new customer deployments no longer require <code>@tub.co</code> code changes.</li>
|
||||||
<li>Outgoing system mail sender names are now branded through the same layer.</li>
|
<li>Outgoing system mail sender names are now branded through the same layer.</li>
|
||||||
<li>User invitation emails and welcome-template fallbacks also use the configured branding defaults.</li>
|
<li>User invitation emails and welcome-template fallbacks also use the configured branding defaults.</li>
|
||||||
|
|||||||
@@ -10,23 +10,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block shell_body %}
|
{% block shell_body %}
|
||||||
<div class="topbar">
|
{% include 'workflows/includes/app_header.html' with header_show_lang=1 %}
|
||||||
<div class="brand-wrap">
|
|
||||||
<a class="app-brand" href="/"><img class="brand-logo" src="{{ portal_logo_url }}" alt="{{ portal_company_name }} Logo" /></a>
|
|
||||||
</div>
|
|
||||||
<div class="quick-actions">
|
|
||||||
<form method="post" action="{% url 'set_language' %}" class="lang-switch">
|
|
||||||
{% csrf_token %}
|
|
||||||
<input type="hidden" name="next" value="{{ request.get_full_path }}" />
|
|
||||||
<button class="lang-btn {% if CURRENT_LANGUAGE == 'de' %}active{% endif %}" type="submit" name="language" value="de">DE</button>
|
|
||||||
<button class="lang-btn {% if CURRENT_LANGUAGE == 'en' %}active{% endif %}" type="submit" name="language" value="en">EN</button>
|
|
||||||
</form>
|
|
||||||
<form method="post" action="/accounts/logout/" style="display:inline;">
|
|
||||||
{% csrf_token %}
|
|
||||||
<button class="btn btn-secondary" type="submit">{% trans "Abmelden" %}</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="hero">
|
<div class="hero">
|
||||||
<div class="hero-grid">
|
<div class="hero-grid">
|
||||||
|
|||||||
@@ -19,5 +19,35 @@
|
|||||||
{% if header_show_home %}
|
{% if header_show_home %}
|
||||||
<a class="btn btn-secondary" href="/">{% trans "Zur Startseite" %}</a>
|
<a class="btn btn-secondary" href="/">{% trans "Zur Startseite" %}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if request.user.is_authenticated %}
|
||||||
|
<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" }}
|
||||||
|
{% else %}
|
||||||
|
{{ request.user.username|slice:":2" }}
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
<span class="app-user-copy">
|
||||||
|
<strong>{{ request.user.get_full_name|default:request.user.username }}</strong>
|
||||||
|
<span>{{ role_label }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="app-user-caret" aria-hidden="true">▾</span>
|
||||||
|
</summary>
|
||||||
|
<div class="app-user-panel">
|
||||||
|
<div class="app-user-panel-head">
|
||||||
|
<strong>{{ request.user.get_full_name|default:request.user.username }}</strong>
|
||||||
|
<span>{{ request.user.email|default:request.user.username }}</span>
|
||||||
|
</div>
|
||||||
|
<a href="{% url 'account_profile_page' %}">{% trans "Profil" %}</a>
|
||||||
|
<a href="{% url 'password_change' %}">{% trans "Passwort ändern" %}</a>
|
||||||
|
<form method="post" action="{% url 'logout' %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit">{% trans "Abmelden" %}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
26
backend/workflows/tests/test_account_ui.py
Normal file
26
backend/workflows/tests/test_account_ui.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import Client, TestCase
|
||||||
|
|
||||||
|
|
||||||
|
class AccountUISmokeTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = get_user_model().objects.create_user(
|
||||||
|
username='profile-user',
|
||||||
|
email='profile@example.com',
|
||||||
|
password='secret-12345',
|
||||||
|
first_name='Profile',
|
||||||
|
last_name='User',
|
||||||
|
)
|
||||||
|
self.client = Client()
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
def test_account_profile_page_renders(self):
|
||||||
|
response = self.client.get('/account/', HTTP_HOST='localhost')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, 'profile@example.com')
|
||||||
|
self.assertContains(response, 'Passwort ändern')
|
||||||
|
|
||||||
|
def test_password_change_page_renders(self):
|
||||||
|
response = self.client.get('/accounts/password_change/', HTTP_HOST='localhost')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, 'Aktuelles Passwort')
|
||||||
@@ -12,7 +12,7 @@ class OnboardingFlowTests(TestCase):
|
|||||||
self.user = user_model.objects.create_user(
|
self.user = user_model.objects.create_user(
|
||||||
username='onboard_user',
|
username='onboard_user',
|
||||||
password='secret123',
|
password='secret123',
|
||||||
email='requester@tub.co',
|
email='requester@workdock.de',
|
||||||
first_name='Mia',
|
first_name='Mia',
|
||||||
last_name='Beispiel',
|
last_name='Beispiel',
|
||||||
)
|
)
|
||||||
@@ -26,7 +26,7 @@ class OnboardingFlowTests(TestCase):
|
|||||||
'gender': 'herr',
|
'gender': 'herr',
|
||||||
'job_title': 'Consultant',
|
'job_title': 'Consultant',
|
||||||
'department': 'IT-Service',
|
'department': 'IT-Service',
|
||||||
'work_email': 'max.mustermann@tub.co',
|
'work_email': 'max.mustermann@workdock.de',
|
||||||
'contract_start': '2026-11-01',
|
'contract_start': '2026-11-01',
|
||||||
'employment_type': 'unbefristet',
|
'employment_type': 'unbefristet',
|
||||||
'group_mailboxes_required_choice': 'nein',
|
'group_mailboxes_required_choice': 'nein',
|
||||||
@@ -43,8 +43,8 @@ class OnboardingFlowTests(TestCase):
|
|||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
self.assertIn('/onboarding/new/?saved=1&id=', response['Location'])
|
self.assertIn('/onboarding/new/?saved=1&id=', response['Location'])
|
||||||
|
|
||||||
obj = OnboardingRequest.objects.get(work_email='max.mustermann@tub.co')
|
obj = OnboardingRequest.objects.get(work_email='max.mustermann@workdock.de')
|
||||||
self.assertEqual(obj.full_name, 'Max Mustermann')
|
self.assertEqual(obj.full_name, 'Max Mustermann')
|
||||||
self.assertEqual(obj.onboarded_by_email, 'requester@tub.co')
|
self.assertEqual(obj.onboarded_by_email, 'requester@workdock.de')
|
||||||
self.assertEqual(obj.onboarded_by_name, 'Mia Beispiel')
|
self.assertEqual(obj.onboarded_by_name, 'Mia Beispiel')
|
||||||
mock_delay.assert_called_once_with(obj.id)
|
mock_delay.assert_called_once_with(obj.id)
|
||||||
|
|||||||
96
backend/workflows/tests/test_security_hardening.py
Normal file
96
backend/workflows/tests/test_security_hardening.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import Client, TestCase, override_settings
|
||||||
|
|
||||||
|
from workflows.checks import security_settings_check
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(DEBUG=True)
|
||||||
|
class RateLimitMiddlewareTests(TestCase):
|
||||||
|
@override_settings(RATE_LIMIT_LOGIN_LIMIT=2, RATE_LIMIT_LOGIN_WINDOW=60)
|
||||||
|
def test_login_is_rate_limited(self):
|
||||||
|
client = Client(REMOTE_ADDR='10.10.10.10')
|
||||||
|
for _ in range(2):
|
||||||
|
response = client.post('/accounts/login/', {'username': 'x', 'password': 'y'}, HTTP_HOST='localhost')
|
||||||
|
self.assertNotEqual(response.status_code, 429)
|
||||||
|
|
||||||
|
response = client.post('/accounts/login/', {'username': 'x', 'password': 'y'}, HTTP_HOST='localhost')
|
||||||
|
self.assertEqual(response.status_code, 429)
|
||||||
|
self.assertIn('Retry-After', response)
|
||||||
|
|
||||||
|
@override_settings(RATE_LIMIT_PASSWORD_RESET_LIMIT=1, RATE_LIMIT_PASSWORD_RESET_WINDOW=60)
|
||||||
|
def test_password_reset_is_rate_limited(self):
|
||||||
|
client = Client(REMOTE_ADDR='10.10.10.20')
|
||||||
|
response = client.post('/accounts/password_reset/', {'email': 'nobody@example.com'}, HTTP_HOST='localhost')
|
||||||
|
self.assertNotEqual(response.status_code, 429)
|
||||||
|
|
||||||
|
response = client.post('/accounts/password_reset/', {'email': 'nobody@example.com'}, HTTP_HOST='localhost')
|
||||||
|
self.assertEqual(response.status_code, 429)
|
||||||
|
|
||||||
|
@override_settings(RATE_LIMIT_ADMIN_ACTION_LIMIT=1, RATE_LIMIT_ADMIN_ACTION_WINDOW=60)
|
||||||
|
def test_sensitive_admin_posts_are_rate_limited(self):
|
||||||
|
client = Client(REMOTE_ADDR='10.10.10.30')
|
||||||
|
response = client.post('/admin-tools/branding/save/', {'portal_title': 'A'}, HTTP_HOST='localhost')
|
||||||
|
self.assertNotEqual(response.status_code, 429)
|
||||||
|
|
||||||
|
response = client.post('/admin-tools/branding/save/', {'portal_title': 'B'}, HTTP_HOST='localhost')
|
||||||
|
self.assertEqual(response.status_code, 429)
|
||||||
|
|
||||||
|
@override_settings(RATE_LIMIT_LOGIN_LIMIT=1, RATE_LIMIT_LOGIN_WINDOW=60)
|
||||||
|
def test_get_requests_are_not_rate_limited(self):
|
||||||
|
client = Client(REMOTE_ADDR='10.10.10.40')
|
||||||
|
first = client.get('/accounts/login/', HTTP_HOST='localhost')
|
||||||
|
second = client.get('/accounts/login/', HTTP_HOST='localhost')
|
||||||
|
self.assertEqual(first.status_code, 200)
|
||||||
|
self.assertEqual(second.status_code, 200)
|
||||||
|
|
||||||
|
|
||||||
|
class SecurityChecksTests(TestCase):
|
||||||
|
@override_settings(
|
||||||
|
DEBUG=False,
|
||||||
|
SECRET_KEY='unsafe-dev-key',
|
||||||
|
ALLOWED_HOSTS=[],
|
||||||
|
SESSION_COOKIE_SECURE=False,
|
||||||
|
CSRF_COOKIE_SECURE=False,
|
||||||
|
SECURE_SSL_REDIRECT=False,
|
||||||
|
RUN_SECURITY_CHECKS_DURING_TESTS=True,
|
||||||
|
)
|
||||||
|
def test_security_checks_report_production_issues(self):
|
||||||
|
issues = security_settings_check(None)
|
||||||
|
ids = {issue.id for issue in issues}
|
||||||
|
self.assertIn('workdock.E001', ids)
|
||||||
|
self.assertIn('workdock.E002', ids)
|
||||||
|
self.assertIn('workdock.E003', ids)
|
||||||
|
self.assertIn('workdock.E004', ids)
|
||||||
|
self.assertIn('workdock.W001', ids)
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(DEBUG=True)
|
||||||
|
class AuthSessionHardeningTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = get_user_model().objects.create_user(username='security-user', password='secret-12345')
|
||||||
|
|
||||||
|
@override_settings(SESSION_IDLE_TIMEOUT_SECONDS=60)
|
||||||
|
def test_idle_session_forces_relogin(self):
|
||||||
|
client = Client(REMOTE_ADDR='10.10.10.50')
|
||||||
|
client.force_login(self.user)
|
||||||
|
session = client.session
|
||||||
|
session['last_activity_ts'] = 1
|
||||||
|
session['auth_fresh_ts'] = 1
|
||||||
|
session.save()
|
||||||
|
|
||||||
|
response = client.get('/', HTTP_HOST='localhost')
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertIn('/accounts/login/', response['Location'])
|
||||||
|
|
||||||
|
@override_settings(SENSITIVE_ACTION_REAUTH_SECONDS=60)
|
||||||
|
def test_stale_sensitive_post_forces_relogin(self):
|
||||||
|
client = Client(REMOTE_ADDR='10.10.10.60')
|
||||||
|
client.force_login(self.user)
|
||||||
|
session = client.session
|
||||||
|
session['last_activity_ts'] = 9999999999
|
||||||
|
session['auth_fresh_ts'] = 1
|
||||||
|
session.save()
|
||||||
|
|
||||||
|
response = client.post('/admin-tools/branding/save/', {'portal_title': 'Blocked'}, HTTP_HOST='localhost')
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertIn('/accounts/login/', response['Location'])
|
||||||
@@ -12,6 +12,10 @@ from .branding import get_portal_trial_config, is_trial_expired
|
|||||||
from .models import AdminAuditLog, EmployeeProfile, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, ScheduledWelcomeEmail
|
from .models import AdminAuditLog, EmployeeProfile, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, ScheduledWelcomeEmail
|
||||||
from .roles import ROLE_PLATFORM_OWNER, get_user_role_key
|
from .roles import ROLE_PLATFORM_OWNER, get_user_role_key
|
||||||
|
|
||||||
|
# Trial cleanup is intentionally destructive but platform-scoped.
|
||||||
|
# It preserves the platform-owner account so expired demo environments remain
|
||||||
|
# recoverable and inspectable by the product operator.
|
||||||
|
|
||||||
|
|
||||||
def cleanup_trial_workspace_data() -> dict[str, int]:
|
def cleanup_trial_workspace_data() -> dict[str, int]:
|
||||||
user_model = get_user_model()
|
user_model = get_user_model()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from . import views
|
|||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('healthz/', views.healthz, name='healthz'),
|
path('healthz/', views.healthz, name='healthz'),
|
||||||
path('', views.home, name='home'),
|
path('', views.home, name='home'),
|
||||||
|
path('account/', views.account_profile_page, name='account_profile_page'),
|
||||||
path('requests/', views.requests_dashboard, name='requests_dashboard'),
|
path('requests/', views.requests_dashboard, name='requests_dashboard'),
|
||||||
path('onboarding/new/', views.onboarding_create, name='onboarding_create'),
|
path('onboarding/new/', views.onboarding_create, name='onboarding_create'),
|
||||||
path('onboarding/success/<int:request_id>/', views.onboarding_success, name='onboarding_success'),
|
path('onboarding/success/<int:request_id>/', views.onboarding_success, name='onboarding_success'),
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ def healthz(request):
|
|||||||
return JsonResponse(
|
return JsonResponse(
|
||||||
{
|
{
|
||||||
'status': 'ok' if db_ok else 'degraded',
|
'status': 'ok' if db_ok else 'degraded',
|
||||||
'service': 'onoff_v2',
|
'service': 'workdock',
|
||||||
'db': 'ok' if db_ok else 'error',
|
'db': 'ok' if db_ok else 'error',
|
||||||
'time': timezone.now().isoformat(),
|
'time': timezone.now().isoformat(),
|
||||||
},
|
},
|
||||||
@@ -126,6 +126,18 @@ def healthz(request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def account_profile_page(request):
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
'workflows/account_profile.html',
|
||||||
|
{
|
||||||
|
'account_user': request.user,
|
||||||
|
'role_label': get_user_role_label(request.user),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _require_capability(capability: str):
|
def _require_capability(capability: str):
|
||||||
def decorator(view_func):
|
def decorator(view_func):
|
||||||
@wraps(view_func)
|
@wraps(view_func)
|
||||||
|
|||||||
Reference in New Issue
Block a user