22 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
Md Bayazid Bostame
baf53a3274 docs: add tubco setup runbook
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 13:52:55 +02:00
Md Bayazid Bostame
541736a9a2 feat: add stack reset helper and setup runbook 2026-04-01 13:52:55 +02:00
Md Bayazid Bostame
80cb7a409d docs: document tubco pat auth workflow 2026-04-01 13:52:55 +02:00
Md Bayazid Bostame
5867d85e96 chore: harden dual-remote git workflow 2026-04-01 13:52:55 +02:00
Md Bayazid Bostame
8f61e43e9b chore: add dual-remote helper workflow 2026-04-01 13:52:55 +02:00
Md Bayazid Bostame
89cc11e41e fix: allow super admin customer platform apps
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 13:30:49 +02:00
Md Bayazid Bostame
13be9bb461 fix: render branded welcome subject placeholders
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 13:13:26 +02:00
35 changed files with 2065 additions and 17 deletions

View File

@@ -2,6 +2,7 @@ APP_DOMAIN=workdock.example.com
APP_BASE_URL=https://workdock.example.com APP_BASE_URL=https://workdock.example.com
DJANGO_SECRET_KEY=change-me-long-random-value DJANGO_SECRET_KEY=change-me-long-random-value
DJANGO_DEBUG=0 DJANGO_DEBUG=0
FORCE_BRANDED_ERROR_PAGES=0
DJANGO_ALLOWED_HOSTS=workdock.example.com DJANGO_ALLOWED_HOSTS=workdock.example.com
DJANGO_CSRF_TRUSTED_ORIGINS=https://workdock.example.com DJANGO_CSRF_TRUSTED_ORIGINS=https://workdock.example.com
DJANGO_SECURE_COOKIES=1 DJANGO_SECURE_COOKIES=1

View File

@@ -2,6 +2,7 @@ APP_DOMAIN=
APP_BASE_URL= APP_BASE_URL=
DJANGO_SECRET_KEY=change-me-long-random-value DJANGO_SECRET_KEY=change-me-long-random-value
DJANGO_DEBUG=1 DJANGO_DEBUG=1
FORCE_BRANDED_ERROR_PAGES=1
DJANGO_ALLOWED_HOSTS=192.168.2.55,localhost,127.0.0.1 DJANGO_ALLOWED_HOSTS=192.168.2.55,localhost,127.0.0.1
DJANGO_CSRF_TRUSTED_ORIGINS=http://192.168.2.55:8088 DJANGO_CSRF_TRUSTED_ORIGINS=http://192.168.2.55:8088
DJANGO_SECURE_COOKIES=0 DJANGO_SECURE_COOKIES=0

39
.githooks/pre-push Executable file
View File

@@ -0,0 +1,39 @@
#!/usr/bin/env bash
set -euo pipefail
remote_name="${1:-}"
remote_url="${2:-}"
if [[ "$remote_name" != "tubco" && "$remote_url" != *"git.tub.co"* ]]; then
exit 0
fi
allowed=0
while read -r local_ref local_sha remote_ref remote_sha; do
[[ -z "${local_ref:-}" ]] && continue
case "$local_ref" in
refs/heads/release/tubco-*)
allowed=1
;;
refs/tags/tubco-baseline-*)
allowed=1
;;
*)
echo "Blocked push to 'tubco': '$local_ref' is not an approved customer ref." >&2
echo "Allowed refs:" >&2
echo " refs/heads/release/tubco-*" >&2
echo " refs/tags/tubco-baseline-*" >&2
echo "Use origin for normal product work." >&2
exit 1
;;
esac
done
if [[ "$allowed" -eq 0 ]]; then
echo "Blocked push to 'tubco': no approved TUBCO refs were detected." >&2
exit 1
fi
exit 0

View File

@@ -5,6 +5,7 @@ This repository is the standalone productized Workdock platform.
Use this file as the quick-start guide for future coders. It complements: Use this file as the quick-start guide for future coders. It complements:
- `DEPLOYMENT.md` - `DEPLOYMENT.md`
- `TUBCO_SETUP.md`
- `backend/workflows/templates/workflows/project_wiki.html` - `backend/workflows/templates/workflows/project_wiki.html`
- `backend/workflows/templates/workflows/developer_handbook.html` - `backend/workflows/templates/workflows/developer_handbook.html`
@@ -34,6 +35,44 @@ Rule:
Do not let a customer deployment track `develop` directly. Do not let a customer deployment track `develop` directly.
Use `TUBCO_SETUP.md` for the customer-specific bootstrap, reset, config-sync, and deploy sequence.
## Dual Remote Workflow
This repository now has two different Git remotes with different purposes:
- `origin`: the normal GitHub product remote
- `tubco`: the TUBCO customer remote
Use the helper command to avoid mixing them up:
```bash
./scripts/git_remote_target.sh status
```
Common commands:
```bash
./scripts/git_remote_target.sh push-origin
./scripts/git_remote_target.sh push-tubco release/tubco-v1
./scripts/git_remote_target.sh set-own-identity
./scripts/git_remote_target.sh set-tubco-identity
```
Default rule:
- normal product work goes to `origin`
- TUBCO pushes happen only when explicitly requested
Safety rules in this repo:
- plain `git push` should default to `origin`
- pushes to `tubco` are guarded by a repo-local `pre-push` hook
- only these refs should go to `tubco`:
- `release/tubco-*`
- `tubco-baseline-*`
Authentication rule:
- prefer a personal access token for the `tubco` HTTPS remote
- do not rely on a reusable account password long term
- store the PAT through macOS keychain using the repo-local `credential.helper=osxkeychain` setting
## Current Delivery Model ## Current Delivery Model
- GitHub Actions is used for CI - GitHub Actions is used for CI
- the current test server is local/LAN-only - the current test server is local/LAN-only
@@ -45,6 +84,12 @@ Standard test deployment command:
./scripts/deploy_test_from_mac.sh ./scripts/deploy_test_from_mac.sh
``` ```
Destructive fresh reset command:
```bash
RESET_CONFIRM=RESET EXPECTED_BRANCH=develop ./scripts/reset_stack_from_mac.sh
```
Why: Why:
- GitHub-hosted runners cannot reliably reach the private LAN target at `192.168.2.55` - GitHub-hosted runners cannot reliably reach the private LAN target at `192.168.2.55`

View File

@@ -217,6 +217,83 @@ HEALTH_URL=http://192.168.2.55:8088/healthz/ \
./scripts/deploy_test_from_mac.sh ./scripts/deploy_test_from_mac.sh
``` ```
## Reset a stack from scratch
Use this only when you intentionally want a fresh environment with default bootstrap data.
What it does:
1. syncs the current checkout to the target server
2. stops the stack
3. removes compose volumes with `down -v`
4. rebuilds and bootstraps the stack again
5. verifies the health endpoint
Local test server reset:
```bash
git checkout develop
RESET_CONFIRM=RESET EXPECTED_BRANCH=develop ./scripts/reset_stack_from_mac.sh
```
Customer/TUBCO reset example:
```bash
git checkout release/tubco-v1
RESET_CONFIRM=RESET \
EXPECTED_BRANCH=release/tubco-v1 \
DEPLOY_HOST=root@<customer-host> \
DEPLOY_PATH=/opt/workdock \
REMOTE_ENV_FILE=.env.prod \
HEALTH_URL=https://portal.tub.co/healthz/ \
RUN_DJANGO_CHECK=1 \
./scripts/reset_stack_from_mac.sh
```
This is destructive. It wipes:
- database state
- generated documents/media
- staticfiles volume
- backup volume
It does not remove the server-local env file. That file must already exist.
## TUBCO customer setup
For the TUBCO customer line, use the frozen customer branch instead of the normal product branches.
Branch rule:
- `release/tubco-v1` is the deployment branch
- do not deploy TUBCO from `develop`
- do not deploy TUBCO from `main`
Recommended first-time flow:
1. check out `release/tubco-v1`
2. create `.env.prod` on the customer server
3. run a destructive reset/bootstrap from the Mac
4. import the intended TUBCO config baseline
5. verify `https://portal.tub.co/healthz/`
Important TUBCO values in `.env.prod`:
```env
APP_DOMAIN=portal.tub.co
APP_BASE_URL=https://portal.tub.co
DJANGO_DEBUG=0
DJANGO_SECURE_COOKIES=1
DJANGO_SECURE_SSL_REDIRECT=1
```
Customer reset from scratch:
```bash
git checkout release/tubco-v1
RESET_CONFIRM=RESET \
EXPECTED_BRANCH=release/tubco-v1 \
DEPLOY_HOST=root@<customer-host> \
DEPLOY_PATH=/opt/workdock \
REMOTE_ENV_FILE=.env.prod \
HEALTH_URL=https://portal.tub.co/healthz/ \
RUN_DJANGO_CHECK=1 \
./scripts/reset_stack_from_mac.sh
```
The full customer runbook lives in:
- [TUBCO_SETUP.md](/Users/bostame/Documents/workdock-platform/TUBCO_SETUP.md)
## Manual production deployment ## Manual production deployment
For production, use a dedicated helper instead of the test script. For production, use a dedicated helper instead of the test script.
@@ -329,6 +406,9 @@ ssh -4 root@192.168.2.55 '
- PDF letterhead - PDF letterhead
- use dry-run first. Treat config sync as an explicit operator action, not something hidden inside deploy. - use dry-run first. Treat config sync as an explicit operator action, not something hidden inside deploy.
For the customer-specific version of this workflow, including `portal.tub.co` examples, use:
- [TUBCO_SETUP.md](/Users/bostame/Documents/workdock-platform/TUBCO_SETUP.md)
## GitHub Actions workflows ## GitHub Actions workflows
### Test deployment workflow ### Test deployment workflow
File: File:

200
TUBCO_SETUP.md Normal file
View File

@@ -0,0 +1,200 @@
# TUBCO Customer Setup Runbook
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`
TUBCO delivery happens from:
- `release/tubco-v1`
## 1. Use the right branch
```bash
git checkout release/tubco-v1
git pull --ff-only origin release/tubco-v1
```
If you plan to push an approved customer fix to the TUBCO remote:
```bash
./scripts/git_remote_target.sh status
./scripts/git_remote_target.sh push-tubco release/tubco-v1
```
## 2. Prepare the target server once
Target assumptions:
- repo path: `/opt/workdock`
- env file: `.env.prod`
- public URL: `https://portal.tub.co`
Create the server env file from the example:
```bash
ssh root@<customer-host>
cd /opt/workdock
cp .env.prod.example .env.prod
```
Required values to review in `.env.prod`:
- `APP_DOMAIN=portal.tub.co`
- `APP_BASE_URL=https://portal.tub.co`
- `DJANGO_ALLOWED_HOSTS=portal.tub.co,...`
- `DJANGO_CSRF_TRUSTED_ORIGINS=https://portal.tub.co`
- `DJANGO_DEBUG=0`
- `DJANGO_SECURE_COOKIES=1`
- `DJANGO_SECURE_SSL_REDIRECT=1`
- `DJANGO_SECRET_KEY=<strong secret>`
- `POSTGRES_PASSWORD=<strong secret>`
## 3. Optional: export the intended local TUBCO config baseline
Run from the local repo:
```bash
docker compose exec -T web python manage.py export_portal_app_config --output /tmp/portal-app-config.json
docker compose exec -T web python manage.py export_portal_deployment_config --output /tmp/portal-deployment-config.json
docker compose cp web:/tmp/portal-app-config.json /tmp/portal-app-config.json
docker compose cp web:/tmp/portal-deployment-config.json /tmp/portal-deployment-config.json
```
This gives you the local baseline for:
- app registry visibility/order
- branding text and colors
- company metadata
## 4. Reset the customer stack from scratch
This is destructive and wipes:
- database state
- generated media/documents
- staticfiles volume
- backup volume
Run:
```bash
git checkout release/tubco-v1
RESET_CONFIRM=RESET \
EXPECTED_BRANCH=release/tubco-v1 \
DEPLOY_HOST=root@<customer-host> \
DEPLOY_PATH=/opt/workdock \
REMOTE_ENV_FILE=.env.prod \
HEALTH_URL=https://portal.tub.co/healthz/ \
RUN_DJANGO_CHECK=1 \
./scripts/reset_stack_from_mac.sh
```
Use this when you want a fresh customer environment with only default bootstrap data.
## 5. Import the TUBCO config baseline
Copy the exported JSON to the target host:
```bash
scp -4 /tmp/portal-app-config.json /tmp/portal-deployment-config.json root@<customer-host>:/opt/workdock/
```
Copy the files into the running web container:
```bash
ssh -4 root@<customer-host> '
docker cp /opt/workdock/portal-app-config.json workdock-web-1:/tmp/portal-app-config.json &&
docker cp /opt/workdock/portal-deployment-config.json workdock-web-1:/tmp/portal-deployment-config.json
'
```
Dry-run the import first:
```bash
ssh -4 root@<customer-host> '
docker exec workdock-web-1 python manage.py import_portal_app_config /tmp/portal-app-config.json --dry-run &&
docker exec workdock-web-1 python manage.py import_portal_deployment_config /tmp/portal-deployment-config.json --dry-run
'
```
If the dry run looks correct, apply it:
```bash
ssh -4 root@<customer-host> '
docker exec workdock-web-1 python manage.py import_portal_app_config /tmp/portal-app-config.json &&
docker exec workdock-web-1 python manage.py import_portal_deployment_config /tmp/portal-deployment-config.json
'
```
Note:
- uploaded branding assets are not included in this JSON sync
- logo, favicon, and PDF letterhead still need explicit upload on the customer system
## 6. Normal customer deployment after the first setup
After the first reset/bootstrap, regular TUBCO updates should usually be deploys, not resets.
If you do not want a destructive reset, use the normal deploy path:
```bash
git checkout release/tubco-v1
rsync -az --delete \
--filter 'P .env.test' \
--filter 'P .env.prod' \
--exclude '.git' \
--exclude '.github' \
--exclude '.venv' \
--exclude '__pycache__' \
--exclude 'node_modules' \
--exclude 'backend/media' \
--exclude 'backend/staticfiles' \
-e 'ssh -4' \
/Users/bostame/Documents/workdock-platform/ \
root@<customer-host>:/opt/workdock/
ssh -4 root@<customer-host> '
cd /opt/workdock &&
RUN_DJANGO_CHECK=1 DEPLOY_HEALTH_URL="https://portal.tub.co/healthz/" ./scripts/deploy_stack.sh .env.prod docker-compose.prod.yml
'
```
## 7. Verify the deployment
Health:
```bash
curl -I https://portal.tub.co/healthz/
```
Container state:
```bash
ssh -4 root@<customer-host> '
cd /opt/workdock &&
docker compose --env-file .env.prod -f docker-compose.prod.yml ps
'
```
Django checks inside the running app:
```bash
ssh -4 root@<customer-host> '
docker exec workdock-web-1 python manage.py check
'
```
## 8. Customer policy
For TUBCO:
- deploy from `release/tubco-v1`
- do not give them future product features by default
- only backport approved:
- bug fixes
- 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`
## 9. Access and roles
Current intended customer rule:
- highest customer-facing application role: `Super Admin`
- `Platform Owner` remains product-level
TUBCO-specific behavior already in the customer branch:
- `Super Admin` can access:
- Branding
- Company Config
- `Super Admin` cannot access:
- App Registry
- Trial Management
- Django Admin link

View File

@@ -22,6 +22,7 @@ def _hostname_from_url(url: str) -> str:
SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'unsafe-dev-key') SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'unsafe-dev-key')
DEBUG = os.getenv('DJANGO_DEBUG', '0') == '1' DEBUG = os.getenv('DJANGO_DEBUG', '0') == '1'
FORCE_BRANDED_ERROR_PAGES = os.getenv('FORCE_BRANDED_ERROR_PAGES', '0') == '1'
APP_DOMAIN = os.getenv('APP_DOMAIN', '').strip() APP_DOMAIN = os.getenv('APP_DOMAIN', '').strip()
APP_BASE_URL = os.getenv('APP_BASE_URL', '').strip().rstrip('/') APP_BASE_URL = os.getenv('APP_BASE_URL', '').strip().rstrip('/')
ALLOWED_HOSTS = _split_csv_env('DJANGO_ALLOWED_HOSTS', 'localhost,127.0.0.1') ALLOWED_HOSTS = _split_csv_env('DJANGO_ALLOWED_HOSTS', 'localhost,127.0.0.1')
@@ -84,6 +85,7 @@ MIDDLEWARE = [
'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.locale.LocaleMiddleware', 'django.middleware.locale.LocaleMiddleware',
'django.middleware.common.CommonMiddleware', 'django.middleware.common.CommonMiddleware',
'workflows.middleware.FriendlyExceptionMiddleware',
'workflows.middleware.RequestIDMiddleware', 'workflows.middleware.RequestIDMiddleware',
'workflows.middleware.RateLimitMiddleware', 'workflows.middleware.RateLimitMiddleware',
'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.csrf.CsrfViewMiddleware',
@@ -95,6 +97,7 @@ MIDDLEWARE = [
] ]
ROOT_URLCONF = 'config.urls' ROOT_URLCONF = 'config.urls'
CSRF_FAILURE_VIEW = 'workflows.error_views.csrf_failure'
TEMPLATES = [ TEMPLATES = [
{ {

View File

@@ -7,6 +7,11 @@ from django.urls import include, path
from workflows.forms import AppPasswordChangeForm, AppPasswordResetForm, AppSetPasswordForm from workflows.forms import AppPasswordChangeForm, AppPasswordResetForm, AppSetPasswordForm
from workflows import views as workflow_views from workflows import views as workflow_views
handler400 = 'workflows.error_views.bad_request'
handler403 = 'workflows.error_views.permission_denied'
handler404 = 'workflows.error_views.not_found'
handler500 = 'workflows.error_views.server_error'
urlpatterns = [ urlpatterns = [
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
path('i18n/', include('django.conf.urls.i18n')), path('i18n/', include('django.conf.urls.i18n')),

View File

@@ -11,7 +11,7 @@ from .branding import get_portal_trial_config, is_trial_expired
from .forms import PortalBrandingForm, PortalCompanyConfigForm, PortalTrialConfigForm, UserManagementCreateForm from .forms import PortalBrandingForm, PortalCompanyConfigForm, PortalTrialConfigForm, UserManagementCreateForm
from .models import PortalAppConfig, PortalBranding, PortalCompanyConfig, UserNotification, UserProfile from .models import PortalAppConfig, PortalBranding, PortalCompanyConfig, UserNotification, UserProfile
from .notifications import notify_user from .notifications import notify_user
from .roles import ROLE_GROUP_NAMES, ROLE_PLATFORM_OWNER, get_user_role_key from .roles import ROLE_GROUP_NAMES, ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, assign_user_role, get_user_role_key
def portal_app_registry_page_impl(request, *, translate_choice_list): def portal_app_registry_page_impl(request, *, translate_choice_list):

View File

@@ -212,13 +212,13 @@ DEFAULT_ROLE_VISIBILITY = {
ROLE_STAFF: False, ROLE_STAFF: False,
}, },
'branding': { 'branding': {
ROLE_SUPER_ADMIN: False, ROLE_SUPER_ADMIN: True,
ROLE_ADMIN: False, ROLE_ADMIN: False,
ROLE_IT_STAFF: False, ROLE_IT_STAFF: False,
ROLE_STAFF: False, ROLE_STAFF: False,
}, },
'company_config': { 'company_config': {
ROLE_SUPER_ADMIN: False, ROLE_SUPER_ADMIN: True,
ROLE_ADMIN: False, ROLE_ADMIN: False,
ROLE_IT_STAFF: False, ROLE_IT_STAFF: False,
ROLE_STAFF: False, ROLE_STAFF: False,

View File

@@ -268,8 +268,8 @@ def get_default_notification_templates() -> dict[str, dict[str, str]]:
support_email = company_contact['it_contact_email'] or branding_copy['support_email'] or f"it@{branding_copy['company_domain']}" support_email = company_contact['it_contact_email'] or branding_copy['support_email'] or f"it@{branding_copy['company_domain']}"
welcome = templates.get('onboarding_welcome') welcome = templates.get('onboarding_welcome')
if welcome: if welcome:
welcome['subject'] = f'Willkommen bei {company_name}, {{ VORNAME }}' welcome['subject'] = f'Willkommen bei {company_name}, {{{{ VORNAME }}}}'
welcome['subject_en'] = f'Welcome to {company_name}, {{ VORNAME }}' welcome['subject_en'] = f'Welcome to {company_name}, {{{{ VORNAME }}}}'
welcome['body'] = ( welcome['body'] = (
'Hallo {{ FULL_NAME }},\n\n' 'Hallo {{ FULL_NAME }},\n\n'
f'herzlich willkommen bei {company_name}.\n' f'herzlich willkommen bei {company_name}.\n'

View File

@@ -19,5 +19,12 @@ def role_context(request):
) )
else: else:
context.update({'header_notifications': [], 'header_unread_notification_count': 0}) context.update({'header_notifications': [], 'header_unread_notification_count': 0})
context.update({'static_asset_version': settings.STATIC_ASSET_VERSION}) context.update(
{
'static_asset_version': settings.STATIC_ASSET_VERSION,
'session_idle_timeout_seconds': settings.SESSION_IDLE_TIMEOUT_SECONDS,
'session_reauth_timeout_seconds': settings.SENSITIVE_ACTION_REAUTH_SECONDS,
'force_branded_error_pages': settings.FORCE_BRANDED_ERROR_PAGES,
}
)
return context return context

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') username = cleaned_data.get('username')
password = cleaned_data.get('password') password = cleaned_data.get('password')
if username and password: if username and password:
self.user_cache = authenticate(self.request, username=username, password=password) login_value = (username or '').strip()
auth_username = login_value
user_model = get_user_model()
matched_user = user_model.objects.filter(email__iexact=login_value).first()
if matched_user is None:
matched_user = user_model.objects.filter(username__iexact=login_value).first()
if matched_user:
auth_username = matched_user.username
self.user_cache = authenticate(self.request, username=auth_username, password=password)
if self.user_cache is None: if self.user_cache is None:
raise ValidationError(self.error_messages['invalid_login'], code='invalid_login') raise ValidationError(self.error_messages['invalid_login'], code='invalid_login')
if not self.user_cache.is_active: if not self.user_cache.is_active:
@@ -488,7 +496,7 @@ class UserManagementCreateForm(forms.Form):
def clean_username(self): def clean_username(self):
username = (self.cleaned_data.get('username') or '').strip() username = (self.cleaned_data.get('username') or '').strip()
user_model = get_user_model() user_model = get_user_model()
if user_model.objects.filter(username=username).exists(): if user_model.objects.filter(username__iexact=username).exists():
raise forms.ValidationError(_('Dieser Benutzername ist bereits vergeben.')) raise forms.ValidationError(_('Dieser Benutzername ist bereits vergeben.'))
return username return username

View File

@@ -5,17 +5,37 @@ from django.contrib import messages
from django.contrib.auth import logout from django.contrib.auth import logout
from django.contrib.messages.api import MessageFailure from django.contrib.messages.api import MessageFailure
from django.core.cache import cache from django.core.cache import cache
from django.http import HttpResponse from django.core.exceptions import PermissionDenied, SuspiciousOperation
from django.http import Http404, HttpResponse
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from .branding import is_trial_expired, is_trial_mode_enabled from .branding import is_trial_expired, is_trial_mode_enabled
from .error_views import bad_request, not_found, permission_denied, server_error
from .logging_utils import clear_request_id, set_request_id from .logging_utils import clear_request_id, set_request_id
from .roles import ROLE_PLATFORM_OWNER, get_user_role_key from .roles import ROLE_PLATFORM_OWNER, get_user_role_key
class FriendlyExceptionMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if not settings.FORCE_BRANDED_ERROR_PAGES:
return self.get_response(request)
try:
return self.get_response(request)
except Http404 as exc:
return not_found(request, exc)
except PermissionDenied as exc:
return permission_denied(request, exc)
except SuspiciousOperation as exc:
return bad_request(request, exc)
class RequestIDMiddleware: class RequestIDMiddleware:
HEADER_NAME = 'X-Request-ID' HEADER_NAME = 'X-Request-ID'
@@ -107,6 +127,7 @@ class RateLimitMiddleware:
class AuthSessionHardeningMiddleware: class AuthSessionHardeningMiddleware:
EXEMPT_PREFIXES = ( EXEMPT_PREFIXES = (
'/healthz/', '/healthz/',
'/session/keepalive/',
'/i18n/', '/i18n/',
'/accounts/login/', '/accounts/login/',
'/accounts/logout/', '/accounts/logout/',
@@ -134,7 +155,10 @@ class AuthSessionHardeningMiddleware:
def _touch_session(self, request, now_ts: int) -> None: def _touch_session(self, request, now_ts: int) -> None:
request.session['last_activity_ts'] = now_ts request.session['last_activity_ts'] = now_ts
request.session.setdefault('auth_fresh_ts', now_ts) if request.method in {'GET', 'HEAD'}:
request.session['auth_fresh_ts'] = now_ts
else:
request.session.setdefault('auth_fresh_ts', now_ts)
def _warn(self, request, message: str) -> None: def _warn(self, request, message: str) -> None:
try: try:

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_secret = models.CharField(max_length=64, blank=True, default='')
totp_enabled = models.BooleanField(default=False) totp_enabled = models.BooleanField(default=False)
totp_confirmed_at = models.DateTimeField(null=True, blank=True) totp_confirmed_at = models.DateTimeField(null=True, blank=True)
temporary_role_key = models.CharField(max_length=64, blank=True, default='')
temporary_role_expires_at = models.DateTimeField(null=True, blank=True)
temporary_role_reason = models.TextField(blank=True, default='')
totp_recovery_codes = models.JSONField(default=list, blank=True) totp_recovery_codes = models.JSONField(default=list, blank=True)
notification_preferences = models.JSONField(default=dict, blank=True) notification_preferences = models.JSONField(default=dict, blank=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)

View File

@@ -33,8 +33,8 @@ ROLE_LABELS = {
CAPABILITIES = { CAPABILITIES = {
# Platform-only capabilities stay above any customer-company admin role. # Platform-only capabilities stay above any customer-company admin role.
'manage_users': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN}, 'manage_users': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN},
'manage_product_branding': {ROLE_PLATFORM_OWNER}, 'manage_product_branding': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN},
'manage_company_config': {ROLE_PLATFORM_OWNER}, 'manage_company_config': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN},
'manage_trial_lifecycle': {ROLE_PLATFORM_OWNER}, 'manage_trial_lifecycle': {ROLE_PLATFORM_OWNER},
'manage_app_registry': {ROLE_PLATFORM_OWNER}, 'manage_app_registry': {ROLE_PLATFORM_OWNER},
'access_requests_dashboard': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF, ROLE_STAFF}, 'access_requests_dashboard': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF, ROLE_STAFF},

View File

@@ -322,6 +322,135 @@
background: linear-gradient(180deg, rgba(249, 252, 255, 0.96), rgba(243, 248, 255, 0.92)); background: linear-gradient(180deg, rgba(249, 252, 255, 0.96), rgba(243, 248, 255, 0.92));
} }
.session-warning-dialog {
position: relative;
overflow: hidden;
padding-top: 28px;
background:
radial-gradient(circle at top right, rgba(201, 37, 37, 0.12), transparent 30%),
radial-gradient(circle at top left, rgba(255, 191, 120, 0.2), transparent 36%),
linear-gradient(180deg, rgba(255,255,255,0.98), rgba(249,251,255,0.98));
}
.session-warning-orb {
position: absolute;
top: 18px;
right: 18px;
width: 62px;
height: 62px;
display: grid;
place-items: center;
}
.session-warning-orb-ring,
.session-warning-orb-core {
position: absolute;
border-radius: 999px;
}
.session-warning-orb-ring {
inset: 0;
background:
radial-gradient(circle, rgba(255,255,255,0.92) 38%, rgba(255,255,255,0) 39%),
conic-gradient(
from -90deg,
rgba(201, 37, 37, 0.88) 0deg,
rgba(255, 191, 120, 0.74) calc(var(--session-warning-progress, 1) * 360deg),
rgba(225, 233, 242, 0.9) calc(var(--session-warning-progress, 1) * 360deg),
rgba(225, 233, 242, 0.9) 360deg
);
box-shadow: 0 12px 24px rgba(128, 46, 18, 0.12);
transition: background 220ms linear;
}
.session-warning-orb-core {
inset: 9px;
display: grid;
place-items: center;
background: linear-gradient(180deg, #fff5ef, #ffe5d7);
color: #a53b17;
text-align: center;
line-height: 1;
gap: 1px;
}
.session-warning-orb-value {
display: block;
font-size: 22px;
font-weight: 900;
}
.session-warning-orb-label {
display: block;
font-size: 9px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.session-warning-kicker {
color: #9a4a1e;
}
.session-warning-panels {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-bottom: 6px;
}
.session-warning-panel {
display: grid;
gap: 4px;
padding: 12px 14px;
border: 1px solid rgba(217, 227, 238, 0.92);
border-radius: 16px;
background: rgba(255,255,255,0.82);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.88);
}
.session-warning-panel strong {
color: #152743;
font-size: 12px;
line-height: 1.25;
}
.session-warning-panel span {
color: #53657e;
font-size: 12px;
line-height: 1.5;
}
.session-warning-countdown {
margin-bottom: 2px;
color: #9a3c1d;
font-weight: 700;
}
.session-warning-status {
margin: 0 0 4px;
color: #0f6b45;
font-size: 12px;
font-weight: 700;
}
@media (max-width: 760px) {
.session-warning-panels {
grid-template-columns: 1fr;
}
.session-warning-dialog {
padding-top: 74px;
}
.session-warning-orb {
top: 14px;
left: 50%;
right: auto;
transform: translateX(-50%);
}
}
.app-notification-item.is-unread { .app-notification-item.is-unread {
border-color: rgba(0, 0, 120, 0.22); border-color: rgba(0, 0, 120, 0.22);
box-shadow: inset 3px 0 0 rgba(0, 0, 120, 0.9); box-shadow: inset 3px 0 0 rgba(0, 0, 120, 0.9);

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 %} {% block extra_head %}{% endblock %}
</head> </head>
<body{% block body_attrs %}{% endblock %}> <body{% block body_attrs %}{% endblock %}>
{% if request.user.is_authenticated %}
<script>
window.WorkdockSessionConfig = {
idleTimeoutSeconds: {{ session_idle_timeout_seconds|default:0 }},
reauthTimeoutSeconds: {{ session_reauth_timeout_seconds|default:0 }},
keepaliveUrl: "/session/keepalive/",
loginUrl: "/accounts/login/"
};
</script>
{% endif %}
{% block pre_shell %}{% endblock %} {% block pre_shell %}{% endblock %}
{% if portal_trial_enabled %} {% if portal_trial_enabled %}
<div class="app-trial-banner{% if portal_trial_expired %} is-expired{% endif %}"> <div class="app-trial-banner{% if portal_trial_expired %} is-expired{% endif %}">
@@ -93,8 +103,47 @@
</div> </div>
</div> </div>
</div> </div>
<div class="confirm-modal" id="app-session-warning-modal" hidden aria-hidden="true">
<div class="confirm-backdrop"></div>
<div class="confirm-dialog session-warning-dialog" role="dialog" aria-modal="true" aria-labelledby="app-session-warning-title" aria-describedby="app-session-warning-copy">
<div class="session-warning-orb" aria-hidden="true">
<span class="session-warning-orb-ring"></span>
<span class="session-warning-orb-core">
<span class="session-warning-orb-value" id="app-session-warning-seconds">!</span>
<span class="session-warning-orb-label">{% trans "Sek." %}</span>
</span>
</div>
<div class="confirm-dialog-head">
<p class="action-progress-kicker session-warning-kicker">{% trans "Sitzung" %}</p>
<h2 id="app-session-warning-title">{% trans "Ihre Sitzung läuft bald ab" %}</h2>
</div>
<p class="confirm-message" id="app-session-warning-copy">
{% trans "Sie sind weiterhin angemeldet, aber diese Sitzung wird bald ablaufen. Bleiben Sie aktiv, wenn Sie weiterarbeiten möchten." %}
</p>
<div class="session-warning-panels" aria-hidden="true">
<div class="session-warning-panel">
<strong>{% trans "Was passiert?" %}</strong>
<span>{% trans "Ohne Bestätigung endet die aktuelle Anmeldung automatisch." %}</span>
</div>
<div class="session-warning-panel">
<strong>{% trans "Empfohlener Schritt" %}</strong>
<span>{% trans "Verlängern Sie die Sitzung, bevor Sie weiter speichern oder sensible Aktionen ausführen." %}</span>
</div>
</div>
<p class="confirm-message session-warning-countdown" id="app-session-warning-countdown"></p>
<p class="session-warning-status" id="app-session-warning-status" hidden aria-live="polite"></p>
<div class="confirm-actions">
<form method="post" action="{% url 'logout' %}">
{% csrf_token %}
<button class="btn btn-secondary" type="submit">{% trans "Abmelden" %}</button>
</form>
<button class="btn btn-primary" type="button" id="app-session-warning-extend">{% trans "Angemeldet bleiben" %}</button>
</div>
</div>
</div>
<script src="{% static 'workflows/js/confirm_dialog.js' %}?v={{ static_asset_version }}"></script> <script src="{% static 'workflows/js/confirm_dialog.js' %}?v={{ static_asset_version }}"></script>
<script src="{% static 'workflows/js/action_progress.js' %}?v={{ static_asset_version }}"></script> <script src="{% static 'workflows/js/action_progress.js' %}?v={{ static_asset_version }}"></script>
<script src="{% static 'workflows/js/session_warning.js' %}?v={{ static_asset_version }}"></script>
{% block extra_scripts %}{% endblock %} {% block extra_scripts %}{% endblock %}
</body> </body>
</html> </html>

View File

@@ -38,6 +38,7 @@
<a href="#hosts">Hosts & Domains</a> <a href="#hosts">Hosts & Domains</a>
<a href="#cicd">CI/CD</a> <a href="#cicd">CI/CD</a>
<a href="#deploy">Deployment</a> <a href="#deploy">Deployment</a>
<a href="#tubco">TUBCO Setup</a>
<a href="#commands">Commands</a> <a href="#commands">Commands</a>
<a href="#troubleshooting">Troubleshooting</a> <a href="#troubleshooting">Troubleshooting</a>
<a href="#security">Security</a> <a href="#security">Security</a>
@@ -94,6 +95,18 @@
<li>promote <code>develop</code> into <code>main</code> when stable</li> <li>promote <code>develop</code> into <code>main</code> when stable</li>
</ol> </ol>
</div> </div>
<div class="box">
<h3>Dual remote rule</h3>
<ul>
<li><code>origin</code> is the normal product remote on GitHub.</li>
<li><code>tubco</code> is the customer remote for TUBCO only.</li>
<li>Normal day-to-day work continues on <code>origin</code>.</li>
<li>Push to <code>tubco</code> only when you explicitly want to update the customer branch.</li>
</ul>
<pre><code>./scripts/git_remote_target.sh status</code></pre>
<p>Use the helper above before pushing if there is any doubt about which remote should receive the change.</p>
<p>Plain <code>git push</code> should default to <code>origin</code>, and a repo-local <code>pre-push</code> hook blocks accidental pushes to <code>tubco</code> unless the ref is an approved TUBCO branch or baseline tag.</p>
</div>
<div class="box"> <div class="box">
<h3>Customer release branches</h3> <h3>Customer release branches</h3>
<p>Use a dedicated release branch when a customer should receive the current stable product line but not future features by default.</p> <p>Use a dedicated release branch when a customer should receive the current stable product line but not future features by default.</p>
@@ -172,9 +185,10 @@ docker compose exec -T web python manage.py check</code></pre>
<ol> <ol>
<li>Preserve behavior while refactoring.</li> <li>Preserve behavior while refactoring.</li>
<li>Prefer shared components over page-local special cases.</li> <li>Prefer shared components over page-local special cases.</li>
<li>Do not overwrite environment-specific runtime config as a side effect of code deploys.</li> <li>Do not overwrite environment-specific runtime config as a side effect of code deploys.</li>
<li>Keep code-driven behavior and data-driven behavior mentally separate.</li> <li>Keep code-driven behavior and data-driven behavior mentally separate.</li>
<li>Update documentation in the same branch when operational workflow changes.</li> <li>Update documentation in the same branch when operational workflow changes.</li>
<li>Keep branded error handling wired through the root URL handlers so production does not fall back to Django default error pages.</li>
</ol> </ol>
</div> </div>
<div class="box"> <div class="box">
@@ -544,6 +558,9 @@ make backup-verify BACKUP_DIR=backups/backup_YYYYmmdd_HHMMSS</code></pre>
<div class="note"> <div class="note">
The LAN test deployment intentionally uses <code>DJANGO_DEBUG=1</code> in <code>.env.test</code> because the security checks correctly reject insecure cookie settings when <code>DEBUG=0</code> and the deployment is still plain HTTP behind a local test topology. This is acceptable for the test box only. Production must run with HTTPS and <code>DEBUG=0</code>. The LAN test deployment intentionally uses <code>DJANGO_DEBUG=1</code> in <code>.env.test</code> because the security checks correctly reject insecure cookie settings when <code>DEBUG=0</code> and the deployment is still plain HTTP behind a local test topology. This is acceptable for the test box only. Production must run with HTTPS and <code>DEBUG=0</code>.
</div> </div>
<div class="note">
If you still want branded wrong-URL and permission pages on the LAN test server while keeping <code>DJANGO_DEBUG=1</code>, enable <code>FORCE_BRANDED_ERROR_PAGES=1</code> in <code>.env.test</code>. Full branded <code>500</code> behavior still requires <code>DEBUG=0</code>, which remains the correct production-style setup.
</div>
<h2 id="deploy">18) Deployment</h2> <h2 id="deploy">18) Deployment</h2>
<h3>Test server stack</h3> <h3>Test server stack</h3>
@@ -649,6 +666,85 @@ lxc.mount.entry: /dev/null sys/module/apparmor/parameters/enabled none bind 0 0<
<li>Take a snapshot commit before major next-phase work</li> <li>Take a snapshot commit before major next-phase work</li>
</ol> </ol>
<h2 id="tubco">18b) TUBCO Customer Setup</h2>
<div class="box">
<h3>What this branch is for</h3>
<ul>
<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">
<h3>First-time customer setup</h3>
<ol>
<li>Check out <code>release/tubco-v1</code>.</li>
<li>Create <code>.env.prod</code> on the target server.</li>
<li>Run the destructive reset/bootstrap helper from the Mac.</li>
<li>Import the intended TUBCO config baseline.</li>
<li>Verify <code>https://portal.tub.co/healthz/</code>.</li>
</ol>
<pre><code>git checkout release/tubco-v1
RESET_CONFIRM=RESET \
EXPECTED_BRANCH=release/tubco-v1 \
DEPLOY_HOST=root@&lt;customer-host&gt; \
DEPLOY_PATH=/opt/workdock \
REMOTE_ENV_FILE=.env.prod \
HEALTH_URL=https://portal.tub.co/healthz/ \
RUN_DJANGO_CHECK=1 \
./scripts/reset_stack_from_mac.sh</code></pre>
</div>
<div class="box">
<h3>Required production env values</h3>
<pre><code>APP_DOMAIN=portal.tub.co
APP_BASE_URL=https://portal.tub.co
DJANGO_DEBUG=0
DJANGO_SECURE_COOKIES=1
DJANGO_SECURE_SSL_REDIRECT=1</code></pre>
<p>The customer server also needs strong values for <code>DJANGO_SECRET_KEY</code> and <code>POSTGRES_PASSWORD</code>.</p>
</div>
<div class="box">
<h3>Config baseline import</h3>
<p>Export the intended local baseline:</p>
<pre><code>docker compose exec -T web python manage.py export_portal_app_config --output /tmp/portal-app-config.json
docker compose exec -T web python manage.py export_portal_deployment_config --output /tmp/portal-deployment-config.json
docker compose cp web:/tmp/portal-app-config.json /tmp/portal-app-config.json
docker compose cp web:/tmp/portal-deployment-config.json /tmp/portal-deployment-config.json</code></pre>
<p>Copy the payloads to the customer server and then into the running web container:</p>
<pre><code>scp -4 /tmp/portal-app-config.json /tmp/portal-deployment-config.json root@&lt;customer-host&gt;:/opt/workdock/
ssh -4 root@&lt;customer-host&gt; '
docker cp /opt/workdock/portal-app-config.json workdock-web-1:/tmp/portal-app-config.json &amp;&amp;
docker cp /opt/workdock/portal-deployment-config.json workdock-web-1:/tmp/portal-deployment-config.json
'</code></pre>
<p>Dry-run first, then apply:</p>
<pre><code>ssh -4 root@&lt;customer-host&gt; '
docker exec workdock-web-1 python manage.py import_portal_app_config /tmp/portal-app-config.json --dry-run &amp;&amp;
docker exec workdock-web-1 python manage.py import_portal_deployment_config /tmp/portal-deployment-config.json --dry-run
'
ssh -4 root@&lt;customer-host&gt; '
docker exec workdock-web-1 python manage.py import_portal_app_config /tmp/portal-app-config.json &amp;&amp;
docker exec workdock-web-1 python manage.py import_portal_deployment_config /tmp/portal-deployment-config.json
'</code></pre>
<p>Uploaded assets such as logo, favicon, and PDF letterhead are still separate media and need explicit upload.</p>
</div>
<div class="box">
<h3>Normal TUBCO updates</h3>
<p>When you intentionally want to update the customer branch remote:</p>
<pre><code>./scripts/git_remote_target.sh status
./scripts/git_remote_target.sh push-tubco release/tubco-v1</code></pre>
<p>Use a TUBCO personal access token stored in the macOS keychain, not a reusable account password.</p>
</div>
<div class="box">
<h3>Customer role boundary</h3>
<ul>
<li>TUBCO should work primarily with <code>Super Admin</code> and below.</li>
<li>In the customer branch, <code>Super Admin</code> can access Branding and Company Config.</li>
<li><code>App Registry</code>, <code>Trial Management</code>, and the Django admin link remain platform-level.</li>
</ul>
</div>
<h2 id="commands">19) Command Reference</h2> <h2 id="commands">19) Command Reference</h2>
<div class="box"> <div class="box">
<h3>Local development</h3> <h3>Local development</h3>
@@ -657,6 +753,8 @@ lxc.mount.entry: /dev/null sys/module/apparmor/parameters/enabled none bind 0 0<
<pre><code>docker compose restart web <pre><code>docker compose restart web
docker compose restart worker</code></pre> docker compose restart worker</code></pre>
<p>Restart app services after code or template changes.</p> <p>Restart app services after code or template changes.</p>
<pre><code>./scripts/git_remote_target.sh status</code></pre>
<p>Show the current branch, active local identity, and both remotes before pushing.</p>
</div> </div>
<div class="box"> <div class="box">
<h3>Validation</h3> <h3>Validation</h3>
@@ -670,11 +768,53 @@ docker compose restart worker</code></pre>
<pre><code>./scripts/deploy_test_from_mac.sh</code></pre> <pre><code>./scripts/deploy_test_from_mac.sh</code></pre>
<p>Sync the current <code>develop</code> checkout to the LAN test server and deploy it.</p> <p>Sync the current <code>develop</code> checkout to the LAN test server and deploy it.</p>
</div> </div>
<div class="box">
<h3>Reset a stack from scratch</h3>
<pre><code>git checkout develop
RESET_CONFIRM=RESET EXPECTED_BRANCH=develop ./scripts/reset_stack_from_mac.sh</code></pre>
<p>Wipe the current test stack state and rebuild it with default bootstrap data.</p>
<pre><code>git checkout release/tubco-v1
RESET_CONFIRM=RESET \
EXPECTED_BRANCH=release/tubco-v1 \
DEPLOY_HOST=root@&lt;customer-host&gt; \
DEPLOY_PATH=/opt/workdock \
REMOTE_ENV_FILE=.env.prod \
HEALTH_URL=https://portal.tub.co/healthz/ \
RUN_DJANGO_CHECK=1 \
./scripts/reset_stack_from_mac.sh</code></pre>
<p>Use the second form for a customer setup from scratch. This is destructive and removes database/media/static/backups before bootstrapping again.</p>
</div>
<div class="box">
<h3>TUBCO setup</h3>
<pre><code>git checkout release/tubco-v1
RESET_CONFIRM=RESET \
EXPECTED_BRANCH=release/tubco-v1 \
DEPLOY_HOST=root@&lt;customer-host&gt; \
DEPLOY_PATH=/opt/workdock \
REMOTE_ENV_FILE=.env.prod \
HEALTH_URL=https://portal.tub.co/healthz/ \
RUN_DJANGO_CHECK=1 \
./scripts/reset_stack_from_mac.sh</code></pre>
<p>Rebuild a fresh TUBCO environment from the customer branch.</p>
<pre><code>./scripts/git_remote_target.sh push-tubco release/tubco-v1</code></pre>
<p>Push an explicitly approved customer update to the TUBCO remote.</p>
</div>
<div class="box"> <div class="box">
<h3>Production deployment</h3> <h3>Production deployment</h3>
<pre><code>./scripts/deploy_prod_from_mac.sh</code></pre> <pre><code>./scripts/deploy_prod_from_mac.sh</code></pre>
<p>Sync the current <code>main</code> checkout to the production target and deploy it with production checks enabled.</p> <p>Sync the current <code>main</code> checkout to the production target and deploy it with production checks enabled.</p>
</div> </div>
<div class="box">
<h3>Remote targeting</h3>
<pre><code>./scripts/git_remote_target.sh push-origin
./scripts/git_remote_target.sh push-tubco release/tubco-v1</code></pre>
<p>Push to the intended remote explicitly instead of relying on memory.</p>
<pre><code>./scripts/git_remote_target.sh set-own-identity
./scripts/git_remote_target.sh set-tubco-identity</code></pre>
<p>Switch between the normal commit identity and the TUBCO customer identity when needed.</p>
<p>For the TUBCO HTTPS remote, prefer a personal access token instead of a reusable account password.</p>
<p>This repo now uses <code>credential.helper=osxkeychain</code> locally, so the TUBCO PAT should be stored in the macOS keychain instead of being embedded in remote URLs.</p>
</div>
<div class="box"> <div class="box">
<h3>Direct server deployment</h3> <h3>Direct server deployment</h3>
<pre><code>cd /opt/workdock <pre><code>cd /opt/workdock
@@ -700,6 +840,18 @@ make backup-verify BACKUP_DIR=backups/backup_YYYYmmdd_HHMMSS</code></pre>
<p>Create and verify backup bundles.</p> <p>Create and verify backup bundles.</p>
</div> </div>
<div class="box">
<h3>Session handling</h3>
<p>The shell warns authenticated users before the idle timeout is reached.</p>
<ul>
<li><code>SESSION_IDLE_TIMEOUT_SECONDS</code> controls the idle session window.</li>
<li><code>SENSITIVE_ACTION_REAUTH_SECONDS</code> controls when sensitive POST actions require fresh authentication.</li>
<li><code>/session/keepalive/</code> refreshes both session timestamps when the user chooses <code>Angemeldet bleiben</code>.</li>
<li>Open tabs now sync the confirmed session timestamp through browser storage and <code>BroadcastChannel</code>, so extending the session in one tab updates the warning state in the others.</li>
</ul>
<p>This warning is meant to protect work in progress without silently relaxing the security middleware.</p>
</div>
<h2 id="troubleshooting">20) Troubleshooting</h2> <h2 id="troubleshooting">20) Troubleshooting</h2>
<div class="box"> <div class="box">
<h3>Localhost still looks stale after the server is already fixed</h3> <h3>Localhost still looks stale after the server is already fixed</h3>

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.test import Client, TestCase
from django.utils import timezone from django.utils import timezone
from workflows.forms import UserManagementCreateForm
from workflows.models import UserProfile from workflows.models import UserProfile
from workflows.roles import ROLE_PLATFORM_OWNER, assign_user_role from workflows.roles import ROLE_PLATFORM_OWNER, assign_user_role
from workflows.totp import generate_totp_token from workflows.totp import generate_totp_token
@@ -32,6 +33,10 @@ class AccountUISmokeTests(TestCase):
def test_user_profile_is_created_automatically(self): def test_user_profile_is_created_automatically(self):
self.assertTrue(UserProfile.objects.filter(user=self.user).exists()) self.assertTrue(UserProfile.objects.filter(user=self.user).exists())
profile = UserProfile.objects.get(user=self.user)
self.assertEqual(profile.temporary_role_key, '')
self.assertIsNone(profile.temporary_role_expires_at)
self.assertEqual(profile.temporary_role_reason, '')
def test_notification_preferences_can_be_updated(self): def test_notification_preferences_can_be_updated(self):
response = self.client.post( response = self.client.post(
@@ -179,3 +184,39 @@ class AccountUISmokeTests(TestCase):
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
profile.refresh_from_db() profile.refresh_from_db()
self.assertEqual(profile.totp_recovery_codes, []) self.assertEqual(profile.totp_recovery_codes, [])
def test_login_accepts_email_after_password_is_set(self):
client = Client()
response = client.post(
'/accounts/login/',
{'username': 'profile@example.com', 'password': 'secret-12345'},
HTTP_HOST='localhost',
)
self.assertEqual(response.status_code, 302)
def test_login_accepts_username_case_insensitively(self):
client = Client()
response = client.post(
'/accounts/login/',
{'username': 'PROFILE-USER', 'password': 'secret-12345'},
HTTP_HOST='localhost',
)
self.assertEqual(response.status_code, 302)
def test_user_management_create_form_rejects_case_insensitive_username_duplicate(self):
form = UserManagementCreateForm(
data={
'first_name': 'Another',
'last_name': 'User',
'username': 'PROFILE-USER',
'email': 'another@example.com',
'role_key': 'staff',
}
)
self.assertFalse(form.is_valid())
self.assertIn('username', form.errors)

View File

@@ -41,6 +41,13 @@ class AppRegistryPermissionTests(TestCase):
self.assertNotIn('trial_management', self._visible_keys(self.super_admin)) self.assertNotIn('trial_management', self._visible_keys(self.super_admin))
self.assertNotIn('trial_management', self._visible_keys(self.admin)) self.assertNotIn('trial_management', self._visible_keys(self.admin))
def test_super_admin_sees_branding_and_company_config_but_not_app_registry(self):
keys = self._visible_keys(self.super_admin)
self.assertIn('branding', keys)
self.assertIn('company_config', keys)
self.assertNotIn('app_registry', keys)
def test_requests_dashboard_can_be_hidden_from_staff_via_registry(self): def test_requests_dashboard_can_be_hidden_from_staff_via_registry(self):
config = PortalAppConfig.objects.get(key='requests_dashboard') config = PortalAppConfig.objects.get(key='requests_dashboard')
config.visible_to_staff = False config.visible_to_staff = False
@@ -63,3 +70,12 @@ class AppRegistryPermissionTests(TestCase):
response = self.client.get(reverse('portal_app_registry_page')) response = self.client.get(reverse('portal_app_registry_page'))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_super_admin_can_open_branding_and_company_config_pages(self):
self.client.force_login(self.super_admin)
branding_response = self.client.get(reverse('portal_branding_page'))
company_response = self.client.get(reverse('portal_company_config_page'))
self.assertEqual(branding_response.status_code, 200)
self.assertEqual(company_response.status_code, 200)

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') response = client.post('/admin-tools/branding/save/', {'portal_title': 'Blocked'}, HTTP_HOST='localhost')
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertIn('/accounts/login/', response['Location']) self.assertIn('/accounts/login/', response['Location'])
@override_settings(SENSITIVE_ACTION_REAUTH_SECONDS=60)
def test_recent_get_refreshes_fresh_auth_for_sensitive_post(self):
client = Client(REMOTE_ADDR='10.10.10.61')
client.force_login(self.user)
session = client.session
session['last_activity_ts'] = 9999999999
session['auth_fresh_ts'] = 1
session.save()
home_response = client.get('/', HTTP_HOST='localhost')
self.assertEqual(home_response.status_code, 200)
session = client.session
self.assertGreater(session['auth_fresh_ts'], 1)
response = client.post('/admin-tools/branding/save/', {'portal_title': 'Blocked'}, HTTP_HOST='localhost')
self.assertEqual(response.status_code, 302)
self.assertEqual(response['Location'], '/')

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

@@ -6,6 +6,8 @@ from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from django.utils import timezone from django.utils import timezone
from workflows.branding import get_default_notification_templates
from workflows.email_workflows import render_notification_template
from workflows.models import NotificationTemplate, OnboardingRequest, ScheduledWelcomeEmail, WorkflowConfig from workflows.models import NotificationTemplate, OnboardingRequest, ScheduledWelcomeEmail, WorkflowConfig
from workflows.tasks import process_onboarding_request, send_scheduled_welcome_email from workflows.tasks import process_onboarding_request, send_scheduled_welcome_email
@@ -160,3 +162,32 @@ class WelcomeEmailScheduleTests(TestCase):
kwargs = mock_send_templated.call_args.kwargs kwargs = mock_send_templated.call_args.kwargs
self.assertEqual(kwargs.get('attachments'), []) self.assertEqual(kwargs.get('attachments'), [])
self.assertEqual(kwargs.get('from_email'), 'admin.sender@tub.co') self.assertEqual(kwargs.get('from_email'), 'admin.sender@tub.co')
def test_branded_default_welcome_subject_renders_first_name(self):
default_templates = get_default_notification_templates()
self.assertIn('{{ VORNAME }}', default_templates['onboarding_welcome']['subject'])
self.assertIn('{{ VORNAME }}', default_templates['onboarding_welcome']['subject_en'])
context = {
'VORNAME': 'Anika',
'FULL_NAME': 'Anika Anan',
'DEPARTMENT': 'IT',
'CONTRACT_START': '2026-04-01',
'EMAIL': 'anika@tub.co',
}
subject_de, _body_de = render_notification_template(
'onboarding_welcome',
context,
language_code='de',
)
subject_en, _body_en = render_notification_template(
'onboarding_welcome',
context,
language_code='en',
)
self.assertIn('Anika', subject_de)
self.assertNotIn('{ VORNAME }', subject_de)
self.assertIn('Anika', subject_en)
self.assertNotIn('{ VORNAME }', subject_en)

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 = [ urlpatterns = [
path('healthz/', views.healthz, name='healthz'), path('healthz/', views.healthz, name='healthz'),
path('session/keepalive/', views.session_keepalive, name='session_keepalive'),
path('', views.home, name='home'), path('', views.home, name='home'),
path('account/', views.account_profile_page, name='account_profile_page'), path('account/', views.account_profile_page, name='account_profile_page'),
path('notifications/<int:notification_id>/read/', views.mark_notification_read, name='mark_notification_read'), path('notifications/<int:notification_id>/read/', views.mark_notification_read, name='mark_notification_read'),
@@ -65,4 +66,5 @@ urlpatterns = [
path('requests/delete/<str:kind>/<int:request_id>/', views.delete_request_from_dashboard, name='delete_request_from_dashboard'), path('requests/delete/<str:kind>/<int:request_id>/', views.delete_request_from_dashboard, name='delete_request_from_dashboard'),
path('requests/retry/<str:kind>/<int:request_id>/', views.retry_request_from_dashboard, name='retry_request_from_dashboard'), path('requests/retry/<str:kind>/<int:request_id>/', views.retry_request_from_dashboard, name='retry_request_from_dashboard'),
path('requests/timeline/<str:kind>/<int:request_id>/', views.request_timeline_page, name='request_timeline_page'), path('requests/timeline/<str:kind>/<int:request_id>/', views.request_timeline_page, name='request_timeline_page'),
re_path(r'^.*$', error_views.not_found, name='not_found_fallback'),
] ]

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()) UserNotification.objects.filter(user=request.user, read_at__isnull=True).update(read_at=timezone.now())
return _redirect_back(request, 'home') return _redirect_back(request, 'home')
@login_required
@require_POST
def session_keepalive(request):
now_ts = int(timezone.now().timestamp())
request.session['last_activity_ts'] = now_ts
request.session['auth_fresh_ts'] = now_ts
return JsonResponse(
{
'status': 'ok',
'idle_timeout_seconds': settings.SESSION_IDLE_TIMEOUT_SECONDS,
'reauth_timeout_seconds': settings.SENSITIVE_ACTION_REAUTH_SECONDS,
'refreshed_at': now_ts,
}
)
def healthz(request): def healthz(request):
db_ok = True db_ok = True
try: try:

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)

116
scripts/git_remote_target.sh Executable file
View File

@@ -0,0 +1,116 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"
TUBCO_NAME="bostame"
TUBCO_EMAIL="mdbayazid@tub.co"
usage() {
cat <<'EOF'
Usage:
./scripts/git_remote_target.sh status
./scripts/git_remote_target.sh push-origin [ref]
./scripts/git_remote_target.sh push-tubco [ref]
./scripts/git_remote_target.sh set-own-identity
./scripts/git_remote_target.sh set-tubco-identity
Commands:
status
Show current branch, current commit, active local git identity, configured remotes,
and the local push-safety settings.
push-origin [ref]
Push a ref to the main product remote "origin".
Default ref: current branch
push-tubco [ref]
Push a ref to the TUBCO customer remote "tubco".
Default ref: release/tubco-v1
set-own-identity
Remove the repo-local git user.name and user.email so this repo falls back to your normal identity.
set-tubco-identity
Set the repo-local git identity to the TUBCO customer identity.
EOF
}
require_remote() {
local remote="$1"
git remote get-url "$remote" >/dev/null 2>&1 || {
echo "Missing git remote: $remote" >&2
exit 1
}
}
current_branch() {
git branch --show-current
}
show_identity() {
local local_name local_email
local_name="$(git config --local --get user.name || true)"
local_email="$(git config --local --get user.email || true)"
if [[ -n "$local_name" || -n "$local_email" ]]; then
echo "Local repo identity: ${local_name:-<unset>} <${local_email:-unset}>"
else
echo "Local repo identity: not set in this repo (falls back to global/default git config)"
fi
}
show_push_safety() {
local push_default hooks_path
push_default="$(git config --local --get remote.pushDefault || true)"
hooks_path="$(git config --local --get core.hooksPath || true)"
echo "remote.pushDefault: ${push_default:-<unset>}"
echo "core.hooksPath: ${hooks_path:-<unset>}"
}
case "${1:-}" in
status)
echo "Branch: $(current_branch)"
echo "Commit: $(git rev-parse --short HEAD)"
show_identity
show_push_safety
echo
echo "Remotes:"
git remote -v
echo
echo "Recommended targets:"
echo " develop/main/internal work -> origin"
echo " release/tubco-v1 and approved customer backports -> tubco"
;;
push-origin)
require_remote origin
ref="${2:-$(current_branch)}"
echo "Pushing '$ref' to origin..."
git push origin "$ref"
;;
push-tubco)
require_remote tubco
ref="${2:-release/tubco-v1}"
echo "Pushing '$ref' to tubco..."
git push tubco "$ref"
;;
set-own-identity)
git config --local --unset-all user.name >/dev/null 2>&1 || true
git config --local --unset-all user.email >/dev/null 2>&1 || true
echo "Cleared repo-local git identity. This repo will now use your normal git identity."
;;
set-tubco-identity)
git config --local user.name "$TUBCO_NAME"
git config --local user.email "$TUBCO_EMAIL"
echo "Set repo-local git identity to: $TUBCO_NAME <$TUBCO_EMAIL>"
;;
*)
usage
exit 1
;;
esac

View File

@@ -0,0 +1,82 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DEPLOY_HOST="${DEPLOY_HOST:-root@192.168.2.55}"
DEPLOY_PATH="${DEPLOY_PATH:-/opt/workdock}"
HEALTH_URL="${HEALTH_URL:-http://192.168.2.55:8088/healthz/}"
REMOTE_ENV_FILE="${REMOTE_ENV_FILE:-.env.test}"
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.prod.yml}"
RUN_DJANGO_CHECK="${RUN_DJANGO_CHECK:-0}"
SSH_CMD="${SSH_CMD:-ssh -4}"
RSYNC_SSH="${RSYNC_SSH:-ssh -4}"
EXPECTED_BRANCH="${EXPECTED_BRANCH:-}"
RESET_CONFIRM="${RESET_CONFIRM:-}"
cd "$REPO_ROOT"
current_branch="$(git branch --show-current)"
if [[ -n "$EXPECTED_BRANCH" && "$current_branch" != "$EXPECTED_BRANCH" ]]; then
echo "Expected branch '$EXPECTED_BRANCH' for this reset, got '$current_branch'." >&2
exit 1
fi
if [[ "$RESET_CONFIRM" != "RESET" ]]; then
cat >&2 <<EOF
Refusing destructive reset.
This command wipes the stack state for:
$DEPLOY_HOST:$DEPLOY_PATH
It will remove:
- database volume/data
- generated media
- staticfiles volume
- backup volume
Run again with:
RESET_CONFIRM=RESET ./scripts/reset_stack_from_mac.sh
EOF
exit 1
fi
echo "Current branch: $current_branch"
echo "Updating local branch from origin/$current_branch ..."
git pull --ff-only origin "$current_branch"
echo "Checking remote env file..."
$SSH_CMD "$DEPLOY_HOST" "test -f '$DEPLOY_PATH/$REMOTE_ENV_FILE'" || {
echo "Missing remote env file: $DEPLOY_PATH/$REMOTE_ENV_FILE" >&2
echo "Create or restore the server env file before resetting." >&2
exit 1
}
echo "Syncing repository to ${DEPLOY_HOST}:${DEPLOY_PATH} ..."
rsync -az --delete \
--filter 'P .env.test' \
--filter 'P .env.prod' \
--exclude '.git' \
--exclude '.github' \
--exclude '.venv' \
--exclude '__pycache__' \
--exclude 'node_modules' \
--exclude 'backend/media' \
--exclude 'backend/staticfiles' \
-e "$RSYNC_SSH" \
"$REPO_ROOT"/ \
"${DEPLOY_HOST}:${DEPLOY_PATH}/"
echo "Resetting remote stack state..."
$SSH_CMD "$DEPLOY_HOST" "cd '$DEPLOY_PATH' && export APP_ENV_FILE='$REMOTE_ENV_FILE' && docker compose --env-file '$REMOTE_ENV_FILE' -f '$COMPOSE_FILE' down -v"
echo "Rebuilding and bootstrapping fresh stack..."
$SSH_CMD "$DEPLOY_HOST" \
"cd '$DEPLOY_PATH' && RUN_DJANGO_CHECK='$RUN_DJANGO_CHECK' DEPLOY_HEALTH_URL='$HEALTH_URL' ./scripts/deploy_stack.sh '$REMOTE_ENV_FILE' '$COMPOSE_FILE'"
echo "Verifying health endpoint..."
curl --fail --silent --show-error --max-time 10 "$HEALTH_URL" >/dev/null
commit_sha="$(git rev-parse --short HEAD)"
echo "Reset deployment healthy: $HEALTH_URL"
echo "Deployed commit: $commit_sha"
echo "Branch: $current_branch"