Compare commits

15 Commits

Author SHA1 Message Date
Md Bayazid Bostame
507fabd050 docs: reference tubco maintenance policy in handbook
Some checks failed
CI / python-validation (push) Has been cancelled
CI / docker-release-gate (push) Has been cancelled
i18n / compile-translations (push) Has been cancelled
2026-04-15 10:15:08 +02:00
Md Bayazid Bostame
7f60a0785c docs: add tubco maintenance policy 2026-04-15 10:11:58 +02:00
Md Bayazid Bostame
209679584e Revert "fix: align tubco onboarding models with deployed schema"
This reverts commit 054558fda2.
2026-04-15 09:54:19 +02:00
Md Bayazid Bostame
054558fda2 fix: align tubco onboarding models with deployed schema 2026-04-15 09:51:00 +02:00
Md Bayazid Bostame
9911cc5f82 fix: prevent stale session warning redirect loops
Some checks failed
i18n / compile-translations (push) Has been cancelled
CI / python-validation (push) Has been cancelled
CI / docker-release-gate (push) Has been cancelled
2026-04-08 14:30:05 +02:00
Md Bayazid Bostame
5b1fd6dc14 fix: harden tubco login matching
Some checks failed
CI / python-validation (push) Has been cancelled
CI / docker-release-gate (push) Has been cancelled
i18n / compile-translations (push) Has been cancelled
2026-04-08 13:52:00 +02:00
Md Bayazid Bostame
b60d9eaeb7 fix: restore tubco user onboarding access
Some checks failed
CI / python-validation (push) Has been cancelled
CI / docker-release-gate (push) Has been cancelled
i18n / compile-translations (push) Has been cancelled
2026-04-08 13:38:30 +02:00
Md Bayazid Bostame
0a38e04606 fix: restore admin role updates
Some checks failed
CI / python-validation (push) Has been cancelled
CI / docker-release-gate (push) Has been cancelled
i18n / compile-translations (push) Has been cancelled
2026-04-08 09:24:34 +02:00
Md Bayazid Bostame
7312dc0514 feat: sync session warning across tabs
Some checks failed
CI / python-validation (push) Has been cancelled
CI / docker-release-gate (push) Has been cancelled
i18n / compile-translations (push) Has been cancelled
2026-04-01 22:25:07 +02:00
Md Bayazid Bostame
6d8c727b29 feat: add session warning countdown ring
Some checks failed
CI / python-validation (push) Has been cancelled
CI / docker-release-gate (push) Has been cancelled
i18n / compile-translations (push) Has been cancelled
2026-04-01 22:12:22 +02:00
Md Bayazid Bostame
da2af7fb3b feat: polish session warning experience
Some checks failed
CI / python-validation (push) Has been cancelled
CI / docker-release-gate (push) Has been cancelled
i18n / compile-translations (push) Has been cancelled
2026-04-01 22:08:58 +02:00
Md Bayazid Bostame
e47b1b3110 feat: add session expiry warning
Some checks failed
CI / python-validation (push) Has been cancelled
CI / docker-release-gate (push) Has been cancelled
i18n / compile-translations (push) Has been cancelled
2026-04-01 22:05:17 +02:00
Md Bayazid Bostame
5fab01d57a fix: refresh auth freshness during active sessions
Some checks failed
CI / python-validation (push) Has been cancelled
CI / docker-release-gate (push) Has been cancelled
i18n / compile-translations (push) Has been cancelled
2026-04-01 17:50:53 +02:00
Md Bayazid Bostame
6254a059b4 feat: improve error page design
Some checks failed
CI / python-validation (push) Has been cancelled
CI / docker-release-gate (push) Has been cancelled
i18n / compile-translations (push) Has been cancelled
2026-04-01 17:43:20 +02:00
Md Bayazid Bostame
6b305e930d feat: add branded error pages
Some checks failed
CI / python-validation (push) Has been cancelled
CI / docker-release-gate (push) Has been cancelled
i18n / compile-translations (push) Has been cancelled
2026-04-01 17:36:18 +02:00
25 changed files with 1325 additions and 11 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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`

View File

@@ -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 = [
{

View File

@@ -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')),

View File

@@ -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):

View File

@@ -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

View 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.'),
)

View File

@@ -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

View File

@@ -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:

View File

@@ -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=''),
),
],
),
]

View File

@@ -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)

View File

@@ -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);

View 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);
})();

View File

@@ -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>

View File

@@ -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>

View 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 %}

View 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')),
]

View File

@@ -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)

View 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)

View File

@@ -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'], '/')

View 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)

View File

@@ -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'),
]

View File

@@ -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:

View 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)