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
|
||||
DJANGO_SECRET_KEY=change-me-long-random-value
|
||||
DJANGO_DEBUG=0
|
||||
FORCE_BRANDED_ERROR_PAGES=0
|
||||
DJANGO_ALLOWED_HOSTS=workdock.example.com
|
||||
DJANGO_CSRF_TRUSTED_ORIGINS=https://workdock.example.com
|
||||
DJANGO_SECURE_COOKIES=1
|
||||
|
||||
@@ -2,6 +2,7 @@ APP_DOMAIN=
|
||||
APP_BASE_URL=
|
||||
DJANGO_SECRET_KEY=change-me-long-random-value
|
||||
DJANGO_DEBUG=1
|
||||
FORCE_BRANDED_ERROR_PAGES=1
|
||||
DJANGO_ALLOWED_HOSTS=192.168.2.55,localhost,127.0.0.1
|
||||
DJANGO_CSRF_TRUSTED_ORIGINS=http://192.168.2.55:8088
|
||||
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.
|
||||
|
||||
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:
|
||||
- `develop`
|
||||
- `main`
|
||||
@@ -171,6 +174,13 @@ For TUBCO:
|
||||
- security updates
|
||||
- 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:
|
||||
- `develop`
|
||||
- `main`
|
||||
|
||||
@@ -22,6 +22,7 @@ def _hostname_from_url(url: str) -> str:
|
||||
|
||||
SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'unsafe-dev-key')
|
||||
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_BASE_URL = os.getenv('APP_BASE_URL', '').strip().rstrip('/')
|
||||
ALLOWED_HOSTS = _split_csv_env('DJANGO_ALLOWED_HOSTS', 'localhost,127.0.0.1')
|
||||
@@ -84,6 +85,7 @@ MIDDLEWARE = [
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.locale.LocaleMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'workflows.middleware.FriendlyExceptionMiddleware',
|
||||
'workflows.middleware.RequestIDMiddleware',
|
||||
'workflows.middleware.RateLimitMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
@@ -95,6 +97,7 @@ MIDDLEWARE = [
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'config.urls'
|
||||
CSRF_FAILURE_VIEW = 'workflows.error_views.csrf_failure'
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
|
||||
@@ -7,6 +7,11 @@ from django.urls import include, path
|
||||
from workflows.forms import AppPasswordChangeForm, AppPasswordResetForm, AppSetPasswordForm
|
||||
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 = [
|
||||
path('admin/', admin.site.urls),
|
||||
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 .models import PortalAppConfig, PortalBranding, PortalCompanyConfig, UserNotification, UserProfile
|
||||
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):
|
||||
|
||||
@@ -19,5 +19,12 @@ def role_context(request):
|
||||
)
|
||||
else:
|
||||
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
|
||||
|
||||
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')
|
||||
password = cleaned_data.get('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:
|
||||
raise ValidationError(self.error_messages['invalid_login'], code='invalid_login')
|
||||
if not self.user_cache.is_active:
|
||||
@@ -488,7 +496,7 @@ class UserManagementCreateForm(forms.Form):
|
||||
def clean_username(self):
|
||||
username = (self.cleaned_data.get('username') or '').strip()
|
||||
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.'))
|
||||
return username
|
||||
|
||||
|
||||
@@ -5,17 +5,37 @@ 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.core.exceptions import PermissionDenied, SuspiciousOperation
|
||||
from django.http import Http404, 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 .error_views import bad_request, not_found, permission_denied, server_error
|
||||
from .logging_utils import clear_request_id, set_request_id
|
||||
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:
|
||||
HEADER_NAME = 'X-Request-ID'
|
||||
|
||||
@@ -107,6 +127,7 @@ class RateLimitMiddleware:
|
||||
class AuthSessionHardeningMiddleware:
|
||||
EXEMPT_PREFIXES = (
|
||||
'/healthz/',
|
||||
'/session/keepalive/',
|
||||
'/i18n/',
|
||||
'/accounts/login/',
|
||||
'/accounts/logout/',
|
||||
@@ -134,7 +155,10 @@ class AuthSessionHardeningMiddleware:
|
||||
|
||||
def _touch_session(self, request, now_ts: int) -> None:
|
||||
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:
|
||||
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_enabled = models.BooleanField(default=False)
|
||||
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)
|
||||
notification_preferences = models.JSONField(default=dict, blank=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));
|
||||
}
|
||||
|
||||
.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 {
|
||||
border-color: rgba(0, 0, 120, 0.22);
|
||||
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 %}
|
||||
</head>
|
||||
<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 %}
|
||||
{% if portal_trial_enabled %}
|
||||
<div class="app-trial-banner{% if portal_trial_expired %} is-expired{% endif %}">
|
||||
@@ -93,8 +103,47 @@
|
||||
</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/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 %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -185,9 +185,10 @@ docker compose exec -T web python manage.py check</code></pre>
|
||||
<ol>
|
||||
<li>Preserve behavior while refactoring.</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>Keep code-driven behavior and data-driven behavior mentally separate.</li>
|
||||
<li>Update documentation in the same branch when operational workflow changes.</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>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>
|
||||
</div>
|
||||
<div class="box">
|
||||
@@ -557,6 +558,9 @@ make backup-verify BACKUP_DIR=backups/backup_YYYYmmdd_HHMMSS</code></pre>
|
||||
<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>.
|
||||
</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>
|
||||
<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>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>Keep TUBCO on the old customer database schema and review <code>docs/TUBCO_MAINTENANCE_POLICY.md</code> before backporting fixes.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<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>
|
||||
</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>
|
||||
<div class="box">
|
||||
<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.utils import timezone
|
||||
|
||||
from workflows.forms import UserManagementCreateForm
|
||||
from workflows.models import UserProfile
|
||||
from workflows.roles import ROLE_PLATFORM_OWNER, assign_user_role
|
||||
from workflows.totp import generate_totp_token
|
||||
@@ -32,6 +33,10 @@ class AccountUISmokeTests(TestCase):
|
||||
|
||||
def test_user_profile_is_created_automatically(self):
|
||||
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):
|
||||
response = self.client.post(
|
||||
@@ -179,3 +184,39 @@ class AccountUISmokeTests(TestCase):
|
||||
self.assertEqual(response.status_code, 302)
|
||||
profile.refresh_from_db()
|
||||
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')
|
||||
self.assertEqual(response.status_code, 302)
|
||||
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 = [
|
||||
path('healthz/', views.healthz, name='healthz'),
|
||||
path('session/keepalive/', views.session_keepalive, name='session_keepalive'),
|
||||
path('', views.home, name='home'),
|
||||
path('account/', views.account_profile_page, name='account_profile_page'),
|
||||
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/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'),
|
||||
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())
|
||||
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):
|
||||
db_ok = True
|
||||
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