Compare commits
15 Commits
baf53a3274
...
release/tu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
507fabd050 | ||
|
|
7f60a0785c | ||
|
|
209679584e | ||
|
|
054558fda2 | ||
|
|
9911cc5f82 | ||
|
|
5b1fd6dc14 | ||
|
|
b60d9eaeb7 | ||
|
|
0a38e04606 | ||
|
|
7312dc0514 | ||
|
|
6d8c727b29 | ||
|
|
da2af7fb3b | ||
|
|
e47b1b3110 | ||
|
|
5fab01d57a | ||
|
|
6254a059b4 | ||
|
|
6b305e930d |
@@ -2,6 +2,7 @@ APP_DOMAIN=workdock.example.com
|
|||||||
APP_BASE_URL=https://workdock.example.com
|
APP_BASE_URL=https://workdock.example.com
|
||||||
DJANGO_SECRET_KEY=change-me-long-random-value
|
DJANGO_SECRET_KEY=change-me-long-random-value
|
||||||
DJANGO_DEBUG=0
|
DJANGO_DEBUG=0
|
||||||
|
FORCE_BRANDED_ERROR_PAGES=0
|
||||||
DJANGO_ALLOWED_HOSTS=workdock.example.com
|
DJANGO_ALLOWED_HOSTS=workdock.example.com
|
||||||
DJANGO_CSRF_TRUSTED_ORIGINS=https://workdock.example.com
|
DJANGO_CSRF_TRUSTED_ORIGINS=https://workdock.example.com
|
||||||
DJANGO_SECURE_COOKIES=1
|
DJANGO_SECURE_COOKIES=1
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ APP_DOMAIN=
|
|||||||
APP_BASE_URL=
|
APP_BASE_URL=
|
||||||
DJANGO_SECRET_KEY=change-me-long-random-value
|
DJANGO_SECRET_KEY=change-me-long-random-value
|
||||||
DJANGO_DEBUG=1
|
DJANGO_DEBUG=1
|
||||||
|
FORCE_BRANDED_ERROR_PAGES=1
|
||||||
DJANGO_ALLOWED_HOSTS=192.168.2.55,localhost,127.0.0.1
|
DJANGO_ALLOWED_HOSTS=192.168.2.55,localhost,127.0.0.1
|
||||||
DJANGO_CSRF_TRUSTED_ORIGINS=http://192.168.2.55:8088
|
DJANGO_CSRF_TRUSTED_ORIGINS=http://192.168.2.55:8088
|
||||||
DJANGO_SECURE_COOKIES=0
|
DJANGO_SECURE_COOKIES=0
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
Use this runbook when you want to set up or rebuild the TUBCO customer deployment from scratch.
|
Use this runbook when you want to set up or rebuild the TUBCO customer deployment from scratch.
|
||||||
|
|
||||||
|
Maintenance policy reference:
|
||||||
|
- [docs/TUBCO_MAINTENANCE_POLICY.md](/Users/bostame/Documents/workdock-platform/docs/TUBCO_MAINTENANCE_POLICY.md)
|
||||||
|
|
||||||
This is the customer-specific path. Normal product work still happens on:
|
This is the customer-specific path. Normal product work still happens on:
|
||||||
- `develop`
|
- `develop`
|
||||||
- `main`
|
- `main`
|
||||||
@@ -171,6 +174,13 @@ For TUBCO:
|
|||||||
- security updates
|
- security updates
|
||||||
- UI improvements
|
- UI improvements
|
||||||
|
|
||||||
|
Important:
|
||||||
|
- keep TUBCO on the old TUBCO database schema
|
||||||
|
- do not solve customer drift by importing the newer product schema into TUBCO
|
||||||
|
- if a deployed TUBCO environment starts failing on unknown non-null columns, verify DB/schema alignment first
|
||||||
|
- for the full maintenance rules, use:
|
||||||
|
- [docs/TUBCO_MAINTENANCE_POLICY.md](/Users/bostame/Documents/workdock-platform/docs/TUBCO_MAINTENANCE_POLICY.md)
|
||||||
|
|
||||||
Do not deploy TUBCO from:
|
Do not deploy TUBCO from:
|
||||||
- `develop`
|
- `develop`
|
||||||
- `main`
|
- `main`
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ def _hostname_from_url(url: str) -> str:
|
|||||||
|
|
||||||
SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'unsafe-dev-key')
|
SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'unsafe-dev-key')
|
||||||
DEBUG = os.getenv('DJANGO_DEBUG', '0') == '1'
|
DEBUG = os.getenv('DJANGO_DEBUG', '0') == '1'
|
||||||
|
FORCE_BRANDED_ERROR_PAGES = os.getenv('FORCE_BRANDED_ERROR_PAGES', '0') == '1'
|
||||||
APP_DOMAIN = os.getenv('APP_DOMAIN', '').strip()
|
APP_DOMAIN = os.getenv('APP_DOMAIN', '').strip()
|
||||||
APP_BASE_URL = os.getenv('APP_BASE_URL', '').strip().rstrip('/')
|
APP_BASE_URL = os.getenv('APP_BASE_URL', '').strip().rstrip('/')
|
||||||
ALLOWED_HOSTS = _split_csv_env('DJANGO_ALLOWED_HOSTS', 'localhost,127.0.0.1')
|
ALLOWED_HOSTS = _split_csv_env('DJANGO_ALLOWED_HOSTS', 'localhost,127.0.0.1')
|
||||||
@@ -84,6 +85,7 @@ MIDDLEWARE = [
|
|||||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
'django.middleware.locale.LocaleMiddleware',
|
'django.middleware.locale.LocaleMiddleware',
|
||||||
'django.middleware.common.CommonMiddleware',
|
'django.middleware.common.CommonMiddleware',
|
||||||
|
'workflows.middleware.FriendlyExceptionMiddleware',
|
||||||
'workflows.middleware.RequestIDMiddleware',
|
'workflows.middleware.RequestIDMiddleware',
|
||||||
'workflows.middleware.RateLimitMiddleware',
|
'workflows.middleware.RateLimitMiddleware',
|
||||||
'django.middleware.csrf.CsrfViewMiddleware',
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
@@ -95,6 +97,7 @@ MIDDLEWARE = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
ROOT_URLCONF = 'config.urls'
|
ROOT_URLCONF = 'config.urls'
|
||||||
|
CSRF_FAILURE_VIEW = 'workflows.error_views.csrf_failure'
|
||||||
|
|
||||||
TEMPLATES = [
|
TEMPLATES = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ from django.urls import include, path
|
|||||||
from workflows.forms import AppPasswordChangeForm, AppPasswordResetForm, AppSetPasswordForm
|
from workflows.forms import AppPasswordChangeForm, AppPasswordResetForm, AppSetPasswordForm
|
||||||
from workflows import views as workflow_views
|
from workflows import views as workflow_views
|
||||||
|
|
||||||
|
handler400 = 'workflows.error_views.bad_request'
|
||||||
|
handler403 = 'workflows.error_views.permission_denied'
|
||||||
|
handler404 = 'workflows.error_views.not_found'
|
||||||
|
handler500 = 'workflows.error_views.server_error'
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('admin/', admin.site.urls),
|
path('admin/', admin.site.urls),
|
||||||
path('i18n/', include('django.conf.urls.i18n')),
|
path('i18n/', include('django.conf.urls.i18n')),
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from .branding import get_portal_trial_config, is_trial_expired
|
|||||||
from .forms import PortalBrandingForm, PortalCompanyConfigForm, PortalTrialConfigForm, UserManagementCreateForm
|
from .forms import PortalBrandingForm, PortalCompanyConfigForm, PortalTrialConfigForm, UserManagementCreateForm
|
||||||
from .models import PortalAppConfig, PortalBranding, PortalCompanyConfig, UserNotification, UserProfile
|
from .models import PortalAppConfig, PortalBranding, PortalCompanyConfig, UserNotification, UserProfile
|
||||||
from .notifications import notify_user
|
from .notifications import notify_user
|
||||||
from .roles import ROLE_GROUP_NAMES, ROLE_PLATFORM_OWNER, get_user_role_key
|
from .roles import ROLE_GROUP_NAMES, ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, assign_user_role, get_user_role_key
|
||||||
|
|
||||||
|
|
||||||
def portal_app_registry_page_impl(request, *, translate_choice_list):
|
def portal_app_registry_page_impl(request, *, translate_choice_list):
|
||||||
|
|||||||
@@ -19,5 +19,12 @@ def role_context(request):
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
context.update({'header_notifications': [], 'header_unread_notification_count': 0})
|
context.update({'header_notifications': [], 'header_unread_notification_count': 0})
|
||||||
context.update({'static_asset_version': settings.STATIC_ASSET_VERSION})
|
context.update(
|
||||||
|
{
|
||||||
|
'static_asset_version': settings.STATIC_ASSET_VERSION,
|
||||||
|
'session_idle_timeout_seconds': settings.SESSION_IDLE_TIMEOUT_SECONDS,
|
||||||
|
'session_reauth_timeout_seconds': settings.SENSITIVE_ACTION_REAUTH_SECONDS,
|
||||||
|
'force_branded_error_pages': settings.FORCE_BRANDED_ERROR_PAGES,
|
||||||
|
}
|
||||||
|
)
|
||||||
return context
|
return context
|
||||||
|
|||||||
71
backend/workflows/error_views.py
Normal file
71
backend/workflows/error_views.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
from django.shortcuts import render
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
|
|
||||||
|
def _render_error(request, *, status: int, title: str, code: str, heading: str, message: str):
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
'workflows/errors/error_page.html',
|
||||||
|
{
|
||||||
|
'error_title': title,
|
||||||
|
'error_code': code,
|
||||||
|
'error_heading': heading,
|
||||||
|
'error_message': message,
|
||||||
|
},
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def bad_request(request, exception=None):
|
||||||
|
return _render_error(
|
||||||
|
request,
|
||||||
|
status=400,
|
||||||
|
title=_('Ungültige Anfrage'),
|
||||||
|
code='400',
|
||||||
|
heading=_('Die Anfrage konnte nicht verarbeitet werden'),
|
||||||
|
message=_('Die übermittelten Daten waren unvollständig oder ungültig. Bitte gehen Sie zurück und versuchen Sie es erneut.'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def permission_denied(request, exception=None):
|
||||||
|
return _render_error(
|
||||||
|
request,
|
||||||
|
status=403,
|
||||||
|
title=_('Kein Zugriff'),
|
||||||
|
code='403',
|
||||||
|
heading=_('Für diese Seite fehlt die Berechtigung'),
|
||||||
|
message=_('Sie sind angemeldet, aber für diesen Bereich nicht freigeschaltet. Wenn das nicht erwartet ist, wenden Sie sich an die Administration.'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def not_found(request, exception=None):
|
||||||
|
return _render_error(
|
||||||
|
request,
|
||||||
|
status=404,
|
||||||
|
title=_('Seite nicht gefunden'),
|
||||||
|
code='404',
|
||||||
|
heading=_('Diese Seite gibt es nicht'),
|
||||||
|
message=_('Die gewünschte Adresse ist nicht vorhanden oder wurde verschoben. Nutzen Sie die Startseite oder das Dashboard, um weiterzugehen.'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def server_error(request):
|
||||||
|
return _render_error(
|
||||||
|
request,
|
||||||
|
status=500,
|
||||||
|
title=_('Serverfehler'),
|
||||||
|
code='500',
|
||||||
|
heading=_('Etwas ist schiefgelaufen'),
|
||||||
|
message=_('Der Fehler wurde nicht sauber verarbeitet. Bitte laden Sie die Seite neu. Wenn das Problem bleibt, prüfen Sie die Server-Logs.'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def csrf_failure(request, reason=''):
|
||||||
|
return _render_error(
|
||||||
|
request,
|
||||||
|
status=400,
|
||||||
|
title=_('Sicherheitsprüfung fehlgeschlagen'),
|
||||||
|
code='400',
|
||||||
|
heading=_('Die Sitzung konnte nicht bestätigt werden'),
|
||||||
|
message=_('Bitte laden Sie die Seite neu und senden Sie das Formular erneut. Wenn das weiter passiert, prüfen Sie Host-, HTTPS- und CSRF-Einstellungen.'),
|
||||||
|
)
|
||||||
@@ -131,7 +131,15 @@ class AppLoginForm(forms.Form):
|
|||||||
username = cleaned_data.get('username')
|
username = cleaned_data.get('username')
|
||||||
password = cleaned_data.get('password')
|
password = cleaned_data.get('password')
|
||||||
if username and password:
|
if username and password:
|
||||||
self.user_cache = authenticate(self.request, username=username, password=password)
|
login_value = (username or '').strip()
|
||||||
|
auth_username = login_value
|
||||||
|
user_model = get_user_model()
|
||||||
|
matched_user = user_model.objects.filter(email__iexact=login_value).first()
|
||||||
|
if matched_user is None:
|
||||||
|
matched_user = user_model.objects.filter(username__iexact=login_value).first()
|
||||||
|
if matched_user:
|
||||||
|
auth_username = matched_user.username
|
||||||
|
self.user_cache = authenticate(self.request, username=auth_username, password=password)
|
||||||
if self.user_cache is None:
|
if self.user_cache is None:
|
||||||
raise ValidationError(self.error_messages['invalid_login'], code='invalid_login')
|
raise ValidationError(self.error_messages['invalid_login'], code='invalid_login')
|
||||||
if not self.user_cache.is_active:
|
if not self.user_cache.is_active:
|
||||||
@@ -488,7 +496,7 @@ class UserManagementCreateForm(forms.Form):
|
|||||||
def clean_username(self):
|
def clean_username(self):
|
||||||
username = (self.cleaned_data.get('username') or '').strip()
|
username = (self.cleaned_data.get('username') or '').strip()
|
||||||
user_model = get_user_model()
|
user_model = get_user_model()
|
||||||
if user_model.objects.filter(username=username).exists():
|
if user_model.objects.filter(username__iexact=username).exists():
|
||||||
raise forms.ValidationError(_('Dieser Benutzername ist bereits vergeben.'))
|
raise forms.ValidationError(_('Dieser Benutzername ist bereits vergeben.'))
|
||||||
return username
|
return username
|
||||||
|
|
||||||
|
|||||||
@@ -5,17 +5,37 @@ from django.contrib import messages
|
|||||||
from django.contrib.auth import logout
|
from django.contrib.auth import logout
|
||||||
from django.contrib.messages.api import MessageFailure
|
from django.contrib.messages.api import MessageFailure
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.http import HttpResponse
|
from django.core.exceptions import PermissionDenied, SuspiciousOperation
|
||||||
|
from django.http import Http404, HttpResponse
|
||||||
from django.shortcuts import redirect, render
|
from django.shortcuts import redirect, render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext as _
|
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 .error_views import bad_request, not_found, permission_denied, server_error
|
||||||
from .logging_utils import clear_request_id, set_request_id
|
from .logging_utils import clear_request_id, set_request_id
|
||||||
from .roles import ROLE_PLATFORM_OWNER, get_user_role_key
|
from .roles import ROLE_PLATFORM_OWNER, get_user_role_key
|
||||||
|
|
||||||
|
|
||||||
|
class FriendlyExceptionMiddleware:
|
||||||
|
def __init__(self, get_response):
|
||||||
|
self.get_response = get_response
|
||||||
|
|
||||||
|
def __call__(self, request):
|
||||||
|
if not settings.FORCE_BRANDED_ERROR_PAGES:
|
||||||
|
return self.get_response(request)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return self.get_response(request)
|
||||||
|
except Http404 as exc:
|
||||||
|
return not_found(request, exc)
|
||||||
|
except PermissionDenied as exc:
|
||||||
|
return permission_denied(request, exc)
|
||||||
|
except SuspiciousOperation as exc:
|
||||||
|
return bad_request(request, exc)
|
||||||
|
|
||||||
|
|
||||||
class RequestIDMiddleware:
|
class RequestIDMiddleware:
|
||||||
HEADER_NAME = 'X-Request-ID'
|
HEADER_NAME = 'X-Request-ID'
|
||||||
|
|
||||||
@@ -107,6 +127,7 @@ class RateLimitMiddleware:
|
|||||||
class AuthSessionHardeningMiddleware:
|
class AuthSessionHardeningMiddleware:
|
||||||
EXEMPT_PREFIXES = (
|
EXEMPT_PREFIXES = (
|
||||||
'/healthz/',
|
'/healthz/',
|
||||||
|
'/session/keepalive/',
|
||||||
'/i18n/',
|
'/i18n/',
|
||||||
'/accounts/login/',
|
'/accounts/login/',
|
||||||
'/accounts/logout/',
|
'/accounts/logout/',
|
||||||
@@ -134,7 +155,10 @@ class AuthSessionHardeningMiddleware:
|
|||||||
|
|
||||||
def _touch_session(self, request, now_ts: int) -> None:
|
def _touch_session(self, request, now_ts: int) -> None:
|
||||||
request.session['last_activity_ts'] = now_ts
|
request.session['last_activity_ts'] = now_ts
|
||||||
request.session.setdefault('auth_fresh_ts', now_ts)
|
if request.method in {'GET', 'HEAD'}:
|
||||||
|
request.session['auth_fresh_ts'] = now_ts
|
||||||
|
else:
|
||||||
|
request.session.setdefault('auth_fresh_ts', now_ts)
|
||||||
|
|
||||||
def _warn(self, request, message: str) -> None:
|
def _warn(self, request, message: str) -> None:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('workflows', '0058_alter_formsectionconfig_options_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.SeparateDatabaseAndState(
|
||||||
|
database_operations=[
|
||||||
|
migrations.RunSQL(
|
||||||
|
sql=(
|
||||||
|
"ALTER TABLE workflows_userprofile "
|
||||||
|
"ADD COLUMN IF NOT EXISTS temporary_role_key varchar(64) NOT NULL DEFAULT '';"
|
||||||
|
),
|
||||||
|
reverse_sql=(
|
||||||
|
"ALTER TABLE workflows_userprofile "
|
||||||
|
"DROP COLUMN IF EXISTS temporary_role_key;"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunSQL(
|
||||||
|
sql=(
|
||||||
|
"ALTER TABLE workflows_userprofile "
|
||||||
|
"ADD COLUMN IF NOT EXISTS temporary_role_expires_at timestamptz NULL;"
|
||||||
|
),
|
||||||
|
reverse_sql=(
|
||||||
|
"ALTER TABLE workflows_userprofile "
|
||||||
|
"DROP COLUMN IF EXISTS temporary_role_expires_at;"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunSQL(
|
||||||
|
sql=(
|
||||||
|
"ALTER TABLE workflows_userprofile "
|
||||||
|
"ADD COLUMN IF NOT EXISTS temporary_role_reason text NOT NULL DEFAULT '';"
|
||||||
|
),
|
||||||
|
reverse_sql=(
|
||||||
|
"ALTER TABLE workflows_userprofile "
|
||||||
|
"DROP COLUMN IF EXISTS temporary_role_reason;"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
state_operations=[
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='userprofile',
|
||||||
|
name='temporary_role_key',
|
||||||
|
field=models.CharField(blank=True, default='', max_length=64),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='userprofile',
|
||||||
|
name='temporary_role_expires_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='userprofile',
|
||||||
|
name='temporary_role_reason',
|
||||||
|
field=models.TextField(blank=True, default=''),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -61,6 +61,9 @@ class UserProfile(models.Model):
|
|||||||
totp_secret = models.CharField(max_length=64, blank=True, default='')
|
totp_secret = models.CharField(max_length=64, blank=True, default='')
|
||||||
totp_enabled = models.BooleanField(default=False)
|
totp_enabled = models.BooleanField(default=False)
|
||||||
totp_confirmed_at = models.DateTimeField(null=True, blank=True)
|
totp_confirmed_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
temporary_role_key = models.CharField(max_length=64, blank=True, default='')
|
||||||
|
temporary_role_expires_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
temporary_role_reason = models.TextField(blank=True, default='')
|
||||||
totp_recovery_codes = models.JSONField(default=list, blank=True)
|
totp_recovery_codes = models.JSONField(default=list, blank=True)
|
||||||
notification_preferences = models.JSONField(default=dict, blank=True)
|
notification_preferences = models.JSONField(default=dict, blank=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|||||||
@@ -322,6 +322,135 @@
|
|||||||
background: linear-gradient(180deg, rgba(249, 252, 255, 0.96), rgba(243, 248, 255, 0.92));
|
background: linear-gradient(180deg, rgba(249, 252, 255, 0.96), rgba(243, 248, 255, 0.92));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.session-warning-dialog {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
padding-top: 28px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top right, rgba(201, 37, 37, 0.12), transparent 30%),
|
||||||
|
radial-gradient(circle at top left, rgba(255, 191, 120, 0.2), transparent 36%),
|
||||||
|
linear-gradient(180deg, rgba(255,255,255,0.98), rgba(249,251,255,0.98));
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-warning-orb {
|
||||||
|
position: absolute;
|
||||||
|
top: 18px;
|
||||||
|
right: 18px;
|
||||||
|
width: 62px;
|
||||||
|
height: 62px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-warning-orb-ring,
|
||||||
|
.session-warning-orb-core {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-warning-orb-ring {
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle, rgba(255,255,255,0.92) 38%, rgba(255,255,255,0) 39%),
|
||||||
|
conic-gradient(
|
||||||
|
from -90deg,
|
||||||
|
rgba(201, 37, 37, 0.88) 0deg,
|
||||||
|
rgba(255, 191, 120, 0.74) calc(var(--session-warning-progress, 1) * 360deg),
|
||||||
|
rgba(225, 233, 242, 0.9) calc(var(--session-warning-progress, 1) * 360deg),
|
||||||
|
rgba(225, 233, 242, 0.9) 360deg
|
||||||
|
);
|
||||||
|
box-shadow: 0 12px 24px rgba(128, 46, 18, 0.12);
|
||||||
|
transition: background 220ms linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-warning-orb-core {
|
||||||
|
inset: 9px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: linear-gradient(180deg, #fff5ef, #ffe5d7);
|
||||||
|
color: #a53b17;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-warning-orb-value {
|
||||||
|
display: block;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-warning-orb-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-warning-kicker {
|
||||||
|
color: #9a4a1e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-warning-panels {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-warning-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid rgba(217, 227, 238, 0.92);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255,255,255,0.82);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255,255,255,0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-warning-panel strong {
|
||||||
|
color: #152743;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-warning-panel span {
|
||||||
|
color: #53657e;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-warning-countdown {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
color: #9a3c1d;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-warning-status {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
color: #0f6b45;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.session-warning-panels {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-warning-dialog {
|
||||||
|
padding-top: 74px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-warning-orb {
|
||||||
|
top: 14px;
|
||||||
|
left: 50%;
|
||||||
|
right: auto;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.app-notification-item.is-unread {
|
.app-notification-item.is-unread {
|
||||||
border-color: rgba(0, 0, 120, 0.22);
|
border-color: rgba(0, 0, 120, 0.22);
|
||||||
box-shadow: inset 3px 0 0 rgba(0, 0, 120, 0.9);
|
box-shadow: inset 3px 0 0 rgba(0, 0, 120, 0.9);
|
||||||
|
|||||||
217
backend/workflows/static/workflows/js/session_warning.js
Normal file
217
backend/workflows/static/workflows/js/session_warning.js
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
(function () {
|
||||||
|
const config = window.WorkdockSessionConfig;
|
||||||
|
if (!config || !config.idleTimeoutSeconds) return;
|
||||||
|
|
||||||
|
const warningLeadSeconds = Math.min(300, Math.max(60, Math.floor(config.idleTimeoutSeconds / 6)));
|
||||||
|
const modal = document.getElementById("app-session-warning-modal");
|
||||||
|
const countdown = document.getElementById("app-session-warning-countdown");
|
||||||
|
const status = document.getElementById("app-session-warning-status");
|
||||||
|
const orb = modal.querySelector(".session-warning-orb-ring");
|
||||||
|
const orbValue = document.getElementById("app-session-warning-seconds");
|
||||||
|
const extendButton = document.getElementById("app-session-warning-extend");
|
||||||
|
if (!modal || !countdown || !extendButton || !status || !orb || !orbValue) return;
|
||||||
|
|
||||||
|
const storageKey = "workdock.session.lastConfirmedAt";
|
||||||
|
const syncChannel = typeof BroadcastChannel !== "undefined"
|
||||||
|
? new BroadcastChannel("workdock-session-warning")
|
||||||
|
: null;
|
||||||
|
let lastConfirmedAt = Date.now();
|
||||||
|
let warningVisible = false;
|
||||||
|
let keepaliveInFlight = false;
|
||||||
|
let timeoutCheckInFlight = false;
|
||||||
|
let redirectInFlight = false;
|
||||||
|
|
||||||
|
function getCsrfToken() {
|
||||||
|
const cookie = document.cookie
|
||||||
|
.split(";")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.find((item) => item.startsWith("csrftoken="));
|
||||||
|
return cookie ? decodeURIComponent(cookie.split("=")[1]) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideWarning() {
|
||||||
|
if (!warningVisible) return;
|
||||||
|
modal.hidden = true;
|
||||||
|
modal.setAttribute("aria-hidden", "true");
|
||||||
|
warningVisible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showStatus(message) {
|
||||||
|
status.textContent = message;
|
||||||
|
status.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideStatus() {
|
||||||
|
status.hidden = true;
|
||||||
|
status.textContent = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function redirectToLogin() {
|
||||||
|
if (redirectInFlight) return;
|
||||||
|
redirectInFlight = true;
|
||||||
|
window.location.href = config.loginUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStoredConfirmedAt() {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(storageKey);
|
||||||
|
const parsed = raw ? Number.parseInt(raw, 10) : NaN;
|
||||||
|
if (!Number.isFinite(parsed)) return null;
|
||||||
|
const maxAgeMs = config.idleTimeoutSeconds * 1000;
|
||||||
|
if (Date.now() - parsed >= maxAgeMs) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
} catch (_error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncConfirmedAt(timestamp, source) {
|
||||||
|
lastConfirmedAt = timestamp;
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(storageKey, String(timestamp));
|
||||||
|
} catch (_error) {
|
||||||
|
// Ignore storage write failures.
|
||||||
|
}
|
||||||
|
if (syncChannel && source !== "broadcast") {
|
||||||
|
syncChannel.postMessage({ type: "confirmed-at", value: timestamp });
|
||||||
|
}
|
||||||
|
if (source !== "self") {
|
||||||
|
hideWarning();
|
||||||
|
hideStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showWarning(secondsLeft) {
|
||||||
|
countdown.textContent = `Noch etwa ${secondsLeft} Sekunden bis zur automatischen Abmeldung.`;
|
||||||
|
orb.style.setProperty("--session-warning-progress", String(Math.max(0, Math.min(1, secondsLeft / warningLeadSeconds))));
|
||||||
|
orbValue.textContent = String(secondsLeft);
|
||||||
|
hideStatus();
|
||||||
|
if (warningVisible) return;
|
||||||
|
modal.hidden = false;
|
||||||
|
modal.setAttribute("aria-hidden", "false");
|
||||||
|
warningVisible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendKeepalive() {
|
||||||
|
if (keepaliveInFlight) return;
|
||||||
|
keepaliveInFlight = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch(config.keepaliveUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-CSRFToken": getCsrfToken(),
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
},
|
||||||
|
credentials: "same-origin",
|
||||||
|
body: JSON.stringify({ keepalive: true }),
|
||||||
|
});
|
||||||
|
const contentType = (response.headers.get("content-type") || "").toLowerCase();
|
||||||
|
if (!response.ok || response.redirected || !contentType.includes("application/json")) {
|
||||||
|
redirectToLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
syncConfirmedAt(Date.now(), "self");
|
||||||
|
showStatus("Sitzung erfolgreich verlängert.");
|
||||||
|
orb.style.setProperty("--session-warning-progress", "1");
|
||||||
|
orbValue.textContent = "OK";
|
||||||
|
window.setTimeout(function () {
|
||||||
|
hideWarning();
|
||||||
|
hideStatus();
|
||||||
|
}, 1200);
|
||||||
|
} catch (_error) {
|
||||||
|
redirectToLogin();
|
||||||
|
} finally {
|
||||||
|
keepaliveInFlight = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmSessionOrRedirect() {
|
||||||
|
if (timeoutCheckInFlight || keepaliveInFlight || redirectInFlight) return;
|
||||||
|
timeoutCheckInFlight = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch(config.keepaliveUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-CSRFToken": getCsrfToken(),
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
},
|
||||||
|
credentials: "same-origin",
|
||||||
|
body: JSON.stringify({ keepalive: true, timeout_check: true }),
|
||||||
|
});
|
||||||
|
const contentType = (response.headers.get("content-type") || "").toLowerCase();
|
||||||
|
if (!response.ok || response.redirected || !contentType.includes("application/json")) {
|
||||||
|
try {
|
||||||
|
window.localStorage.removeItem(storageKey);
|
||||||
|
} catch (_error) {
|
||||||
|
// Ignore storage cleanup failures.
|
||||||
|
}
|
||||||
|
redirectToLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
syncConfirmedAt(Date.now(), "self");
|
||||||
|
hideWarning();
|
||||||
|
hideStatus();
|
||||||
|
} catch (_error) {
|
||||||
|
redirectToLogin();
|
||||||
|
} finally {
|
||||||
|
timeoutCheckInFlight = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const storedConfirmedAt = readStoredConfirmedAt();
|
||||||
|
if (storedConfirmedAt) {
|
||||||
|
lastConfirmedAt = storedConfirmedAt;
|
||||||
|
} else {
|
||||||
|
syncConfirmedAt(lastConfirmedAt, "self");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (syncChannel) {
|
||||||
|
syncChannel.addEventListener("message", function (event) {
|
||||||
|
if (event.data && event.data.type === "confirmed-at" && Number.isFinite(event.data.value)) {
|
||||||
|
syncConfirmedAt(event.data.value, "broadcast");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("storage", function (event) {
|
||||||
|
if (event.key !== storageKey || !event.newValue) return;
|
||||||
|
const parsed = Number.parseInt(event.newValue, 10);
|
||||||
|
if (Number.isFinite(parsed)) {
|
||||||
|
syncConfirmedAt(parsed, "storage");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", function () {
|
||||||
|
if (document.visibilityState !== "visible") return;
|
||||||
|
const latest = readStoredConfirmedAt();
|
||||||
|
if (latest && latest > lastConfirmedAt) {
|
||||||
|
syncConfirmedAt(latest, "storage");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
const elapsedSeconds = Math.floor((Date.now() - lastConfirmedAt) / 1000);
|
||||||
|
const secondsLeft = config.idleTimeoutSeconds - elapsedSeconds;
|
||||||
|
if (secondsLeft <= 0) {
|
||||||
|
confirmSessionOrRedirect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (secondsLeft <= warningLeadSeconds) {
|
||||||
|
showWarning(secondsLeft);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extendButton.addEventListener("click", function () {
|
||||||
|
extendButton.disabled = true;
|
||||||
|
sendKeepalive();
|
||||||
|
setTimeout(function () {
|
||||||
|
extendButton.disabled = false;
|
||||||
|
}, 800);
|
||||||
|
});
|
||||||
|
|
||||||
|
setInterval(tick, 1000);
|
||||||
|
})();
|
||||||
@@ -14,6 +14,16 @@
|
|||||||
{% block extra_head %}{% endblock %}
|
{% block extra_head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body{% block body_attrs %}{% endblock %}>
|
<body{% block body_attrs %}{% endblock %}>
|
||||||
|
{% if request.user.is_authenticated %}
|
||||||
|
<script>
|
||||||
|
window.WorkdockSessionConfig = {
|
||||||
|
idleTimeoutSeconds: {{ session_idle_timeout_seconds|default:0 }},
|
||||||
|
reauthTimeoutSeconds: {{ session_reauth_timeout_seconds|default:0 }},
|
||||||
|
keepaliveUrl: "/session/keepalive/",
|
||||||
|
loginUrl: "/accounts/login/"
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
{% block pre_shell %}{% endblock %}
|
{% block pre_shell %}{% endblock %}
|
||||||
{% if portal_trial_enabled %}
|
{% if portal_trial_enabled %}
|
||||||
<div class="app-trial-banner{% if portal_trial_expired %} is-expired{% endif %}">
|
<div class="app-trial-banner{% if portal_trial_expired %} is-expired{% endif %}">
|
||||||
@@ -93,8 +103,47 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="confirm-modal" id="app-session-warning-modal" hidden aria-hidden="true">
|
||||||
|
<div class="confirm-backdrop"></div>
|
||||||
|
<div class="confirm-dialog session-warning-dialog" role="dialog" aria-modal="true" aria-labelledby="app-session-warning-title" aria-describedby="app-session-warning-copy">
|
||||||
|
<div class="session-warning-orb" aria-hidden="true">
|
||||||
|
<span class="session-warning-orb-ring"></span>
|
||||||
|
<span class="session-warning-orb-core">
|
||||||
|
<span class="session-warning-orb-value" id="app-session-warning-seconds">!</span>
|
||||||
|
<span class="session-warning-orb-label">{% trans "Sek." %}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="confirm-dialog-head">
|
||||||
|
<p class="action-progress-kicker session-warning-kicker">{% trans "Sitzung" %}</p>
|
||||||
|
<h2 id="app-session-warning-title">{% trans "Ihre Sitzung läuft bald ab" %}</h2>
|
||||||
|
</div>
|
||||||
|
<p class="confirm-message" id="app-session-warning-copy">
|
||||||
|
{% trans "Sie sind weiterhin angemeldet, aber diese Sitzung wird bald ablaufen. Bleiben Sie aktiv, wenn Sie weiterarbeiten möchten." %}
|
||||||
|
</p>
|
||||||
|
<div class="session-warning-panels" aria-hidden="true">
|
||||||
|
<div class="session-warning-panel">
|
||||||
|
<strong>{% trans "Was passiert?" %}</strong>
|
||||||
|
<span>{% trans "Ohne Bestätigung endet die aktuelle Anmeldung automatisch." %}</span>
|
||||||
|
</div>
|
||||||
|
<div class="session-warning-panel">
|
||||||
|
<strong>{% trans "Empfohlener Schritt" %}</strong>
|
||||||
|
<span>{% trans "Verlängern Sie die Sitzung, bevor Sie weiter speichern oder sensible Aktionen ausführen." %}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="confirm-message session-warning-countdown" id="app-session-warning-countdown"></p>
|
||||||
|
<p class="session-warning-status" id="app-session-warning-status" hidden aria-live="polite"></p>
|
||||||
|
<div class="confirm-actions">
|
||||||
|
<form method="post" action="{% url 'logout' %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button class="btn btn-secondary" type="submit">{% trans "Abmelden" %}</button>
|
||||||
|
</form>
|
||||||
|
<button class="btn btn-primary" type="button" id="app-session-warning-extend">{% trans "Angemeldet bleiben" %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<script src="{% static 'workflows/js/confirm_dialog.js' %}?v={{ static_asset_version }}"></script>
|
<script src="{% static 'workflows/js/confirm_dialog.js' %}?v={{ static_asset_version }}"></script>
|
||||||
<script src="{% static 'workflows/js/action_progress.js' %}?v={{ static_asset_version }}"></script>
|
<script src="{% static 'workflows/js/action_progress.js' %}?v={{ static_asset_version }}"></script>
|
||||||
|
<script src="{% static 'workflows/js/session_warning.js' %}?v={{ static_asset_version }}"></script>
|
||||||
{% block extra_scripts %}{% endblock %}
|
{% block extra_scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -185,9 +185,10 @@ docker compose exec -T web python manage.py check</code></pre>
|
|||||||
<ol>
|
<ol>
|
||||||
<li>Preserve behavior while refactoring.</li>
|
<li>Preserve behavior while refactoring.</li>
|
||||||
<li>Prefer shared components over page-local special cases.</li>
|
<li>Prefer shared components over page-local special cases.</li>
|
||||||
<li>Do not overwrite environment-specific runtime config as a side effect of code deploys.</li>
|
<li>Do not overwrite environment-specific runtime config as a side effect of code deploys.</li>
|
||||||
<li>Keep code-driven behavior and data-driven behavior mentally separate.</li>
|
<li>Keep code-driven behavior and data-driven behavior mentally separate.</li>
|
||||||
<li>Update documentation in the same branch when operational workflow changes.</li>
|
<li>Update documentation in the same branch when operational workflow changes.</li>
|
||||||
|
<li>Keep branded error handling wired through the root URL handlers so production does not fall back to Django default error pages.</li>
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
<div class="box">
|
<div class="box">
|
||||||
@@ -557,6 +558,9 @@ make backup-verify BACKUP_DIR=backups/backup_YYYYmmdd_HHMMSS</code></pre>
|
|||||||
<div class="note">
|
<div class="note">
|
||||||
The LAN test deployment intentionally uses <code>DJANGO_DEBUG=1</code> in <code>.env.test</code> because the security checks correctly reject insecure cookie settings when <code>DEBUG=0</code> and the deployment is still plain HTTP behind a local test topology. This is acceptable for the test box only. Production must run with HTTPS and <code>DEBUG=0</code>.
|
The LAN test deployment intentionally uses <code>DJANGO_DEBUG=1</code> in <code>.env.test</code> because the security checks correctly reject insecure cookie settings when <code>DEBUG=0</code> and the deployment is still plain HTTP behind a local test topology. This is acceptable for the test box only. Production must run with HTTPS and <code>DEBUG=0</code>.
|
||||||
</div>
|
</div>
|
||||||
|
<div class="note">
|
||||||
|
If you still want branded wrong-URL and permission pages on the LAN test server while keeping <code>DJANGO_DEBUG=1</code>, enable <code>FORCE_BRANDED_ERROR_PAGES=1</code> in <code>.env.test</code>. Full branded <code>500</code> behavior still requires <code>DEBUG=0</code>, which remains the correct production-style setup.
|
||||||
|
</div>
|
||||||
|
|
||||||
<h2 id="deploy">18) Deployment</h2>
|
<h2 id="deploy">18) Deployment</h2>
|
||||||
<h3>Test server stack</h3>
|
<h3>Test server stack</h3>
|
||||||
@@ -669,6 +673,7 @@ lxc.mount.entry: /dev/null sys/module/apparmor/parameters/enabled none bind 0 0<
|
|||||||
<li><code>release/tubco-v1</code> is the frozen TUBCO customer branch.</li>
|
<li><code>release/tubco-v1</code> is the frozen TUBCO customer branch.</li>
|
||||||
<li>It should receive only approved bug fixes, security updates, and UI improvements.</li>
|
<li>It should receive only approved bug fixes, security updates, and UI improvements.</li>
|
||||||
<li>Do not deploy TUBCO from <code>develop</code> or <code>main</code>.</li>
|
<li>Do not deploy TUBCO from <code>develop</code> or <code>main</code>.</li>
|
||||||
|
<li>Keep TUBCO on the old customer database schema and review <code>docs/TUBCO_MAINTENANCE_POLICY.md</code> before backporting fixes.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="box">
|
<div class="box">
|
||||||
@@ -835,6 +840,18 @@ make backup-verify BACKUP_DIR=backups/backup_YYYYmmdd_HHMMSS</code></pre>
|
|||||||
<p>Create and verify backup bundles.</p>
|
<p>Create and verify backup bundles.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="box">
|
||||||
|
<h3>Session handling</h3>
|
||||||
|
<p>The shell warns authenticated users before the idle timeout is reached.</p>
|
||||||
|
<ul>
|
||||||
|
<li><code>SESSION_IDLE_TIMEOUT_SECONDS</code> controls the idle session window.</li>
|
||||||
|
<li><code>SENSITIVE_ACTION_REAUTH_SECONDS</code> controls when sensitive POST actions require fresh authentication.</li>
|
||||||
|
<li><code>/session/keepalive/</code> refreshes both session timestamps when the user chooses <code>Angemeldet bleiben</code>.</li>
|
||||||
|
<li>Open tabs now sync the confirmed session timestamp through browser storage and <code>BroadcastChannel</code>, so extending the session in one tab updates the warning state in the others.</li>
|
||||||
|
</ul>
|
||||||
|
<p>This warning is meant to protect work in progress without silently relaxing the security middleware.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h2 id="troubleshooting">20) Troubleshooting</h2>
|
<h2 id="troubleshooting">20) Troubleshooting</h2>
|
||||||
<div class="box">
|
<div class="box">
|
||||||
<h3>Localhost still looks stale after the server is already fixed</h3>
|
<h3>Localhost still looks stale after the server is already fixed</h3>
|
||||||
|
|||||||
351
backend/workflows/templates/workflows/errors/error_page.html
Normal file
351
backend/workflows/templates/workflows/errors/error_page.html
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
{% extends 'workflows/base_shell.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block title %}{{ error_title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
<style>
|
||||||
|
.error-page-shell {
|
||||||
|
width: min(1180px, 100%);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding-top: 28px;
|
||||||
|
padding-bottom: 36px;
|
||||||
|
gap: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-hero {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.2fr) minmax(260px, 360px);
|
||||||
|
gap: 28px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 34px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(216, 226, 239, 0.94);
|
||||||
|
border-radius: 28px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 14% 18%, rgba(0, 0, 120, 0.16), rgba(0, 0, 120, 0) 34%),
|
||||||
|
radial-gradient(circle at 88% 22%, rgba(31, 79, 214, 0.18), rgba(31, 79, 214, 0) 32%),
|
||||||
|
radial-gradient(circle at 72% 88%, rgba(163, 32, 32, 0.12), rgba(163, 32, 32, 0) 28%),
|
||||||
|
linear-gradient(145deg, rgba(247, 250, 255, 0.98), rgba(255, 255, 255, 0.94));
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.92),
|
||||||
|
0 28px 60px rgba(15, 23, 42, 0.09);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-hero::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(15, 27, 45, 0.02) 1px, transparent 1px),
|
||||||
|
linear-gradient(rgba(15, 27, 45, 0.02) 1px, transparent 1px);
|
||||||
|
background-size: 26px 26px;
|
||||||
|
mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.75), transparent 88%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-hero-copy,
|
||||||
|
.error-code-orb,
|
||||||
|
.error-detail-grid {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-kicker,
|
||||||
|
.error-panel-kicker {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(0, 0, 120, 0.12);
|
||||||
|
background: rgba(0, 0, 120, 0.06);
|
||||||
|
color: var(--ds-brand-strong);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-hero-copy h1 {
|
||||||
|
margin: 16px 0 0;
|
||||||
|
max-width: 10ch;
|
||||||
|
color: var(--ds-ink-strong);
|
||||||
|
font-size: clamp(42px, 7vw, 78px);
|
||||||
|
line-height: 0.94;
|
||||||
|
letter-spacing: -0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
max-width: 58ch;
|
||||||
|
margin: 16px 0 0;
|
||||||
|
color: #4b5e78;
|
||||||
|
font-size: clamp(16px, 2vw, 19px);
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-code-orb {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-code-ring {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: min(32vw, 300px);
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid rgba(0, 0, 120, 0.12);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 50% 35%, rgba(255, 255, 255, 0.96), rgba(239, 245, 255, 0.94) 54%, rgba(224, 234, 249, 0.88) 100%);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 10px 32px rgba(255, 255, 255, 0.8),
|
||||||
|
0 20px 45px rgba(15, 23, 42, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-code-ring::before,
|
||||||
|
.error-code-ring::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px dashed rgba(23, 63, 141, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-code-ring::after {
|
||||||
|
inset: -16px;
|
||||||
|
border-style: solid;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: rgba(31, 79, 214, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-code-label {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: #6d7f97;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-code-ring strong {
|
||||||
|
color: #10203a;
|
||||||
|
font-size: clamp(60px, 10vw, 120px);
|
||||||
|
line-height: 0.9;
|
||||||
|
letter-spacing: -0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr);
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-panel {
|
||||||
|
padding: 24px;
|
||||||
|
border: 1px solid rgba(216, 226, 239, 0.94);
|
||||||
|
border-radius: 22px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top right, rgba(31, 79, 214, 0.05), rgba(31, 79, 214, 0) 34%),
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(247, 250, 255, 0.94));
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.95),
|
||||||
|
0 16px 30px rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-panel-primary {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(0, 0, 120, 0.06), rgba(0, 0, 120, 0) 30%),
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(245, 249, 255, 0.96));
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-panel-head {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-panel h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: #17345e;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1.08;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-panel p {
|
||||||
|
margin: 0;
|
||||||
|
color: #53677f;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-signal-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-signal {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px solid rgba(200, 213, 229, 0.92);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.78);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-signal span {
|
||||||
|
color: #687b93;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-signal strong {
|
||||||
|
color: #162841;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.35;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-checklist {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-checklist li {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 26px;
|
||||||
|
color: #53677f;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-checklist li::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 8px;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(180deg, #1f4fd6, #173f8d);
|
||||||
|
box-shadow: 0 0 0 5px rgba(31, 79, 214, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.error-hero,
|
||||||
|
.error-detail-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-code-orb {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-code-ring {
|
||||||
|
width: min(72vw, 260px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.error-page-shell {
|
||||||
|
padding-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-hero,
|
||||||
|
.error-panel {
|
||||||
|
padding: 22px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-signal-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-actions .btn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block shell_body %}
|
||||||
|
{% include 'workflows/includes/app_header.html' with header_show_home=1 header_inside_shell=1 %}
|
||||||
|
<div class="page-stack error-page-shell">
|
||||||
|
<section class="error-hero">
|
||||||
|
<div class="error-hero-copy">
|
||||||
|
<div class="error-kicker">{% trans "System Response" %}</div>
|
||||||
|
<h1>{{ error_heading }}</h1>
|
||||||
|
<p class="error-message">{{ error_message }}</p>
|
||||||
|
<div class="error-actions">
|
||||||
|
<a class="btn btn-primary" href="/">{% trans "Zur Startseite" %}</a>
|
||||||
|
{% if request.user.is_authenticated %}
|
||||||
|
<a class="btn btn-secondary" href="/requests/">{% trans "Zum Dashboard" %}</a>
|
||||||
|
{% else %}
|
||||||
|
<a class="btn btn-secondary" href="/accounts/login/">{% trans "Anmelden" %}</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="error-code-orb" aria-hidden="true">
|
||||||
|
<div class="error-code-ring">
|
||||||
|
<span class="error-code-label">{% trans "Status" %}</span>
|
||||||
|
<strong>{{ error_code }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="error-detail-grid">
|
||||||
|
<article class="error-panel error-panel-primary">
|
||||||
|
<div class="error-panel-head">
|
||||||
|
<div class="error-panel-kicker">{% trans "What Happened" %}</div>
|
||||||
|
<h2>{{ error_title }}</h2>
|
||||||
|
</div>
|
||||||
|
<p>{{ error_message }}</p>
|
||||||
|
<div class="error-signal-row">
|
||||||
|
<div class="error-signal">
|
||||||
|
<span>{% trans "HTTP" %}</span>
|
||||||
|
<strong>{{ error_code }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="error-signal">
|
||||||
|
<span>{% trans "Path" %}</span>
|
||||||
|
<strong>{{ request.path|default:"/" }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="error-panel">
|
||||||
|
<div class="error-panel-head">
|
||||||
|
<div class="error-panel-kicker">{% trans "Next Step" %}</div>
|
||||||
|
<h2>{% trans "How to continue" %}</h2>
|
||||||
|
</div>
|
||||||
|
<ul class="error-checklist">
|
||||||
|
<li>{% trans "Gehen Sie zur Startseite zurück und öffnen Sie den gewünschten Bereich erneut." %}</li>
|
||||||
|
<li>{% trans "Prüfen Sie die URL, falls Sie eine Adresse manuell eingegeben haben." %}</li>
|
||||||
|
<li>{% trans "Wenn das Problem bestehen bleibt, prüfen Sie die Server-Logs oder wenden Sie sich an die Administration." %}</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
28
backend/workflows/tests/error_test_urls.py
Normal file
28
backend/workflows/tests/error_test_urls.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from django.urls import include, path
|
||||||
|
|
||||||
|
|
||||||
|
def ok_view(request):
|
||||||
|
return HttpResponse('ok')
|
||||||
|
|
||||||
|
|
||||||
|
def deny_view(request):
|
||||||
|
raise PermissionDenied('forbidden')
|
||||||
|
|
||||||
|
|
||||||
|
def explode_view(request):
|
||||||
|
raise RuntimeError('boom')
|
||||||
|
|
||||||
|
|
||||||
|
handler400 = 'workflows.error_views.bad_request'
|
||||||
|
handler403 = 'workflows.error_views.permission_denied'
|
||||||
|
handler404 = 'workflows.error_views.not_found'
|
||||||
|
handler500 = 'workflows.error_views.server_error'
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('healthz/', ok_view, name='healthz'),
|
||||||
|
path('raise-403/', deny_view, name='raise_403'),
|
||||||
|
path('raise-500/', explode_view, name='raise_500'),
|
||||||
|
path('', include('workflows.urls')),
|
||||||
|
]
|
||||||
@@ -2,6 +2,7 @@ from django.contrib.auth import get_user_model
|
|||||||
from django.test import Client, TestCase
|
from django.test import Client, TestCase
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from workflows.forms import UserManagementCreateForm
|
||||||
from workflows.models import UserProfile
|
from workflows.models import UserProfile
|
||||||
from workflows.roles import ROLE_PLATFORM_OWNER, assign_user_role
|
from workflows.roles import ROLE_PLATFORM_OWNER, assign_user_role
|
||||||
from workflows.totp import generate_totp_token
|
from workflows.totp import generate_totp_token
|
||||||
@@ -32,6 +33,10 @@ class AccountUISmokeTests(TestCase):
|
|||||||
|
|
||||||
def test_user_profile_is_created_automatically(self):
|
def test_user_profile_is_created_automatically(self):
|
||||||
self.assertTrue(UserProfile.objects.filter(user=self.user).exists())
|
self.assertTrue(UserProfile.objects.filter(user=self.user).exists())
|
||||||
|
profile = UserProfile.objects.get(user=self.user)
|
||||||
|
self.assertEqual(profile.temporary_role_key, '')
|
||||||
|
self.assertIsNone(profile.temporary_role_expires_at)
|
||||||
|
self.assertEqual(profile.temporary_role_reason, '')
|
||||||
|
|
||||||
def test_notification_preferences_can_be_updated(self):
|
def test_notification_preferences_can_be_updated(self):
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
@@ -179,3 +184,39 @@ class AccountUISmokeTests(TestCase):
|
|||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
profile.refresh_from_db()
|
profile.refresh_from_db()
|
||||||
self.assertEqual(profile.totp_recovery_codes, [])
|
self.assertEqual(profile.totp_recovery_codes, [])
|
||||||
|
|
||||||
|
def test_login_accepts_email_after_password_is_set(self):
|
||||||
|
client = Client()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
'/accounts/login/',
|
||||||
|
{'username': 'profile@example.com', 'password': 'secret-12345'},
|
||||||
|
HTTP_HOST='localhost',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
def test_login_accepts_username_case_insensitively(self):
|
||||||
|
client = Client()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
'/accounts/login/',
|
||||||
|
{'username': 'PROFILE-USER', 'password': 'secret-12345'},
|
||||||
|
HTTP_HOST='localhost',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
def test_user_management_create_form_rejects_case_insensitive_username_duplicate(self):
|
||||||
|
form = UserManagementCreateForm(
|
||||||
|
data={
|
||||||
|
'first_name': 'Another',
|
||||||
|
'last_name': 'User',
|
||||||
|
'username': 'PROFILE-USER',
|
||||||
|
'email': 'another@example.com',
|
||||||
|
'role_key': 'staff',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(form.is_valid())
|
||||||
|
self.assertIn('username', form.errors)
|
||||||
|
|||||||
62
backend/workflows/tests/test_error_pages.py
Normal file
62
backend/workflows/tests/test_error_pages.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
from django.test import Client, RequestFactory, TestCase, override_settings
|
||||||
|
|
||||||
|
from workflows.error_views import csrf_failure
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
DEBUG=False,
|
||||||
|
ROOT_URLCONF='workflows.tests.error_test_urls',
|
||||||
|
ALLOWED_HOSTS=['testserver'],
|
||||||
|
)
|
||||||
|
class ErrorPageTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = Client()
|
||||||
|
self.client.raise_request_exception = False
|
||||||
|
|
||||||
|
def test_custom_404_page_is_rendered(self):
|
||||||
|
response = self.client.get('/missing-page/')
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
self.assertTemplateUsed(response, 'workflows/errors/error_page.html')
|
||||||
|
self.assertContains(response, '404', status_code=404)
|
||||||
|
|
||||||
|
def test_custom_403_page_is_rendered(self):
|
||||||
|
response = self.client.get('/raise-403/')
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
self.assertTemplateUsed(response, 'workflows/errors/error_page.html')
|
||||||
|
self.assertContains(response, '403', status_code=403)
|
||||||
|
|
||||||
|
def test_custom_500_page_is_rendered(self):
|
||||||
|
response = self.client.get('/raise-500/')
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 500)
|
||||||
|
self.assertTemplateUsed(response, 'workflows/errors/error_page.html')
|
||||||
|
self.assertContains(response, '500', status_code=500)
|
||||||
|
|
||||||
|
def test_csrf_failure_view_uses_custom_400_page(self):
|
||||||
|
request = RequestFactory().post('/test/')
|
||||||
|
|
||||||
|
response = csrf_failure(request)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.assertContains(response, '400', status_code=400)
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
DEBUG=True,
|
||||||
|
FORCE_BRANDED_ERROR_PAGES=True,
|
||||||
|
ROOT_URLCONF='workflows.tests.error_test_urls',
|
||||||
|
ALLOWED_HOSTS=['testserver'],
|
||||||
|
)
|
||||||
|
class ForcedBrandedErrorPageTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = Client()
|
||||||
|
self.client.raise_request_exception = False
|
||||||
|
|
||||||
|
def test_missing_url_uses_branded_404_even_with_debug_enabled(self):
|
||||||
|
response = self.client.get('/missing-page/')
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
self.assertTemplateUsed(response, 'workflows/errors/error_page.html')
|
||||||
|
self.assertContains(response, '404', status_code=404)
|
||||||
@@ -94,3 +94,22 @@ class AuthSessionHardeningTests(TestCase):
|
|||||||
response = client.post('/admin-tools/branding/save/', {'portal_title': 'Blocked'}, HTTP_HOST='localhost')
|
response = client.post('/admin-tools/branding/save/', {'portal_title': 'Blocked'}, HTTP_HOST='localhost')
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
self.assertIn('/accounts/login/', response['Location'])
|
self.assertIn('/accounts/login/', response['Location'])
|
||||||
|
|
||||||
|
@override_settings(SENSITIVE_ACTION_REAUTH_SECONDS=60)
|
||||||
|
def test_recent_get_refreshes_fresh_auth_for_sensitive_post(self):
|
||||||
|
client = Client(REMOTE_ADDR='10.10.10.61')
|
||||||
|
client.force_login(self.user)
|
||||||
|
session = client.session
|
||||||
|
session['last_activity_ts'] = 9999999999
|
||||||
|
session['auth_fresh_ts'] = 1
|
||||||
|
session.save()
|
||||||
|
|
||||||
|
home_response = client.get('/', HTTP_HOST='localhost')
|
||||||
|
self.assertEqual(home_response.status_code, 200)
|
||||||
|
|
||||||
|
session = client.session
|
||||||
|
self.assertGreater(session['auth_fresh_ts'], 1)
|
||||||
|
|
||||||
|
response = client.post('/admin-tools/branding/save/', {'portal_title': 'Blocked'}, HTTP_HOST='localhost')
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response['Location'], '/')
|
||||||
|
|||||||
52
backend/workflows/tests/test_session_warning.py
Normal file
52
backend/workflows/tests/test_session_warning.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import Client, TestCase
|
||||||
|
from django.test.utils import override_settings
|
||||||
|
|
||||||
|
|
||||||
|
TEST_STORAGES = {
|
||||||
|
'default': {
|
||||||
|
'BACKEND': 'django.core.files.storage.FileSystemStorage',
|
||||||
|
},
|
||||||
|
'staticfiles': {
|
||||||
|
'BACKEND': 'django.contrib.staticfiles.storage.StaticFilesStorage',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(STORAGES=TEST_STORAGES)
|
||||||
|
class SessionWarningTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = get_user_model().objects.create_user(
|
||||||
|
username='session-user',
|
||||||
|
email='session@example.com',
|
||||||
|
password='secret-12345',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_base_shell_exposes_session_warning_config(self):
|
||||||
|
client = Client(HTTP_HOST='localhost')
|
||||||
|
client.force_login(self.user)
|
||||||
|
|
||||||
|
response = client.get('/')
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, 'WorkdockSessionConfig')
|
||||||
|
self.assertContains(response, '/session/keepalive/')
|
||||||
|
self.assertContains(response, 'Ihre Sitzung läuft bald ab')
|
||||||
|
self.assertContains(response, 'app-session-warning-status')
|
||||||
|
self.assertContains(response, 'app-session-warning-seconds')
|
||||||
|
self.assertContains(response, 'Was passiert?')
|
||||||
|
|
||||||
|
def test_keepalive_refreshes_session_timestamps(self):
|
||||||
|
client = Client(HTTP_HOST='localhost')
|
||||||
|
client.force_login(self.user)
|
||||||
|
session = client.session
|
||||||
|
session['last_activity_ts'] = 1
|
||||||
|
session['auth_fresh_ts'] = 1
|
||||||
|
session.save()
|
||||||
|
|
||||||
|
response = client.post('/session/keepalive/', {}, HTTP_HOST='localhost')
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
session = client.session
|
||||||
|
self.assertGreater(session['last_activity_ts'], 1)
|
||||||
|
self.assertGreater(session['auth_fresh_ts'], 1)
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
from django.urls import path
|
from django.urls import path, re_path
|
||||||
|
|
||||||
from . import views
|
from . import error_views, views
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('healthz/', views.healthz, name='healthz'),
|
path('healthz/', views.healthz, name='healthz'),
|
||||||
|
path('session/keepalive/', views.session_keepalive, name='session_keepalive'),
|
||||||
path('', views.home, name='home'),
|
path('', views.home, name='home'),
|
||||||
path('account/', views.account_profile_page, name='account_profile_page'),
|
path('account/', views.account_profile_page, name='account_profile_page'),
|
||||||
path('notifications/<int:notification_id>/read/', views.mark_notification_read, name='mark_notification_read'),
|
path('notifications/<int:notification_id>/read/', views.mark_notification_read, name='mark_notification_read'),
|
||||||
@@ -65,4 +66,5 @@ urlpatterns = [
|
|||||||
path('requests/delete/<str:kind>/<int:request_id>/', views.delete_request_from_dashboard, name='delete_request_from_dashboard'),
|
path('requests/delete/<str:kind>/<int:request_id>/', views.delete_request_from_dashboard, name='delete_request_from_dashboard'),
|
||||||
path('requests/retry/<str:kind>/<int:request_id>/', views.retry_request_from_dashboard, name='retry_request_from_dashboard'),
|
path('requests/retry/<str:kind>/<int:request_id>/', views.retry_request_from_dashboard, name='retry_request_from_dashboard'),
|
||||||
path('requests/timeline/<str:kind>/<int:request_id>/', views.request_timeline_page, name='request_timeline_page'),
|
path('requests/timeline/<str:kind>/<int:request_id>/', views.request_timeline_page, name='request_timeline_page'),
|
||||||
|
re_path(r'^.*$', error_views.not_found, name='not_found_fallback'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -138,6 +138,22 @@ def mark_all_notifications_read(request):
|
|||||||
UserNotification.objects.filter(user=request.user, read_at__isnull=True).update(read_at=timezone.now())
|
UserNotification.objects.filter(user=request.user, read_at__isnull=True).update(read_at=timezone.now())
|
||||||
return _redirect_back(request, 'home')
|
return _redirect_back(request, 'home')
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_POST
|
||||||
|
def session_keepalive(request):
|
||||||
|
now_ts = int(timezone.now().timestamp())
|
||||||
|
request.session['last_activity_ts'] = now_ts
|
||||||
|
request.session['auth_fresh_ts'] = now_ts
|
||||||
|
return JsonResponse(
|
||||||
|
{
|
||||||
|
'status': 'ok',
|
||||||
|
'idle_timeout_seconds': settings.SESSION_IDLE_TIMEOUT_SECONDS,
|
||||||
|
'reauth_timeout_seconds': settings.SENSITIVE_ACTION_REAUTH_SECONDS,
|
||||||
|
'refreshed_at': now_ts,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
def healthz(request):
|
def healthz(request):
|
||||||
db_ok = True
|
db_ok = True
|
||||||
try:
|
try:
|
||||||
|
|||||||
136
docs/TUBCO_MAINTENANCE_POLICY.md
Normal file
136
docs/TUBCO_MAINTENANCE_POLICY.md
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
# TUBCO Maintenance Policy
|
||||||
|
|
||||||
|
Use this document whenever we maintain the TUBCO customer line.
|
||||||
|
|
||||||
|
This is the rulebook for TUBCO. It is intentionally stricter than normal product work.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
TUBCO stays on its own older customer baseline.
|
||||||
|
|
||||||
|
That means:
|
||||||
|
- keep the older TUBCO application behavior
|
||||||
|
- keep the older TUBCO database schema
|
||||||
|
- only cherry-pick approved fixes
|
||||||
|
- do not quietly turn TUBCO into the newer product
|
||||||
|
|
||||||
|
## Source of truth
|
||||||
|
|
||||||
|
TUBCO delivery happens from:
|
||||||
|
- `release/tubco-v1`
|
||||||
|
|
||||||
|
Normal product work happens on:
|
||||||
|
- `develop`
|
||||||
|
- `main`
|
||||||
|
|
||||||
|
Do not deploy TUBCO from:
|
||||||
|
- `develop`
|
||||||
|
- `main`
|
||||||
|
|
||||||
|
## Core maintenance rules
|
||||||
|
|
||||||
|
For TUBCO, we may backport only:
|
||||||
|
- bug fixes
|
||||||
|
- security fixes
|
||||||
|
- carefully approved UX improvements
|
||||||
|
- required operational fixes for the customer environment
|
||||||
|
|
||||||
|
For TUBCO, we do not backport by default:
|
||||||
|
- new product workflows
|
||||||
|
- new approval/account-rule systems
|
||||||
|
- schema expansions from the newer product
|
||||||
|
- general feature growth unless explicitly approved
|
||||||
|
|
||||||
|
## Database rule
|
||||||
|
|
||||||
|
TUBCO must use the old TUBCO schema.
|
||||||
|
|
||||||
|
This is the most important rule.
|
||||||
|
|
||||||
|
If the code stays old but the database is newer, old TUBCO flows can fail with database errors such as:
|
||||||
|
- missing values for newer non-null columns
|
||||||
|
- forms saving rows that the old code does not know how to populate
|
||||||
|
|
||||||
|
If we see errors like:
|
||||||
|
- `null value in column ... violates not-null constraint`
|
||||||
|
|
||||||
|
then first verify whether the TUBCO environment is still using the old schema.
|
||||||
|
|
||||||
|
Do not solve this by default with:
|
||||||
|
- broad schema compatibility backports
|
||||||
|
- importing the newer product data model into TUBCO
|
||||||
|
|
||||||
|
That would move TUBCO toward the new product instead of preserving the customer baseline.
|
||||||
|
|
||||||
|
## LAN server rule
|
||||||
|
|
||||||
|
TUBCO is hosted on our local LAN server.
|
||||||
|
|
||||||
|
Before deploying any TUBCO fix, verify:
|
||||||
|
- the server is running `release/tubco-v1`
|
||||||
|
- the environment points to the intended old TUBCO database
|
||||||
|
- no newer product migrations were applied there by mistake
|
||||||
|
|
||||||
|
If the LAN server is connected to a newer migrated database, code-only fixes may not be enough.
|
||||||
|
|
||||||
|
In that case, the correct options are:
|
||||||
|
1. repoint TUBCO to the old TUBCO database
|
||||||
|
2. or restore the database to the old TUBCO schema
|
||||||
|
|
||||||
|
## Safe TUBCO release workflow
|
||||||
|
|
||||||
|
1. start from:
|
||||||
|
- `release/tubco-v1`
|
||||||
|
2. implement only the approved fix
|
||||||
|
3. keep the database model old unless there is explicit customer approval to change it
|
||||||
|
4. test the affected flow
|
||||||
|
5. deploy only to the LAN-hosted TUBCO environment
|
||||||
|
6. do not run newer product migrations on that environment
|
||||||
|
|
||||||
|
## Cherry-pick rule
|
||||||
|
|
||||||
|
When a fix exists on another branch:
|
||||||
|
- inspect the commit carefully
|
||||||
|
- cherry-pick only if it does not pull in newer product behavior or newer schema assumptions
|
||||||
|
|
||||||
|
If a fix depends on the newer schema:
|
||||||
|
- stop
|
||||||
|
- rework it as a TUBCO-only fix
|
||||||
|
- or do not ship it
|
||||||
|
|
||||||
|
## Operational checklist before deploying a TUBCO fix
|
||||||
|
|
||||||
|
Check all of these:
|
||||||
|
- branch is `release/tubco-v1`
|
||||||
|
- worktree is clean
|
||||||
|
- fix is limited to the approved TUBCO scope
|
||||||
|
- no new-product migrations are included
|
||||||
|
- no new-product workflows are included
|
||||||
|
- LAN server target is the intended TUBCO environment
|
||||||
|
- database is the old TUBCO schema
|
||||||
|
|
||||||
|
## Example warning signs
|
||||||
|
|
||||||
|
Pause and verify immediately if you see:
|
||||||
|
- approval-related fields appearing in old TUBCO flows
|
||||||
|
- new account-rule behavior on TUBCO
|
||||||
|
- migration files copied from the product branch
|
||||||
|
- database errors mentioning columns unknown to the old branch
|
||||||
|
|
||||||
|
Those usually indicate code/schema drift.
|
||||||
|
|
||||||
|
## Decision rule
|
||||||
|
|
||||||
|
If there is a conflict between:
|
||||||
|
- shipping a quick fix
|
||||||
|
- and keeping TUBCO on the old customer baseline
|
||||||
|
|
||||||
|
choose the old customer baseline first.
|
||||||
|
|
||||||
|
We only widen the TUBCO schema or behavior if that change is explicitly intended for TUBCO.
|
||||||
|
|
||||||
|
## Related documents
|
||||||
|
|
||||||
|
- [TUBCO_SETUP.md](/Users/bostame/Documents/workdock-platform/TUBCO_SETUP.md)
|
||||||
|
- [DEPLOYMENT.md](/Users/bostame/Documents/workdock-platform/DEPLOYMENT.md)
|
||||||
|
- [CONTRIBUTING.md](/Users/bostame/Documents/workdock-platform/CONTRIBUTING.md)
|
||||||
Reference in New Issue
Block a user