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