Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7d8d1a856 | ||
|
|
77dffd46f0 | ||
|
|
c28c7364cb | ||
|
|
238dc11848 | ||
|
|
9317a6131f | ||
|
|
dd79dde532 | ||
|
|
90c85083bc | ||
|
|
6fd5e21008 | ||
|
|
7be231409f | ||
|
|
74c1e7130c |
31
.env.production.example
Normal file
31
.env.production.example
Normal file
@ -0,0 +1,31 @@
|
||||
# Copy to .env.production or enter in Dokploy's Compose environment settings.
|
||||
# Verify that both release images have been published before deployment.
|
||||
TASKMANAGER_VERSION=1.0.1
|
||||
POSTGRES_USER=taskmanager
|
||||
POSTGRES_DB=taskmanager
|
||||
POSTGRES_PASSWORD=
|
||||
# Match the DB credentials above. URL-encode special characters in this URL.
|
||||
DATABASE_URL=
|
||||
NEXTAUTH_URL=
|
||||
NEXTAUTH_SECRET=
|
||||
# Required only when no system administrator exists. Remove after bootstrap.
|
||||
BOOTSTRAP_EMAIL=
|
||||
BOOTSTRAP_PASSWORD=
|
||||
# Explicit target volumes: restore your backup here BEFORE deploying app/worker.
|
||||
POSTGRES_VOLUME=
|
||||
UPLOADS_VOLUME=
|
||||
TRAEFIK_NETWORK=dokploy-network
|
||||
# Only docker-compose.production.direct.yml (no Traefik):
|
||||
APP_BIND_ADDRESS=127.0.0.1
|
||||
APP_PORT=3000
|
||||
APP_TIMEZONE=Europe/Berlin
|
||||
TRUST_PROXY=false
|
||||
GROUP_UPLOAD_QUOTA_MB=1024
|
||||
ARCHIVE_RETENTION_DAYS=0
|
||||
SMTP_URL=
|
||||
MAIL_FROM=
|
||||
OIDC_ISSUER=
|
||||
OIDC_CLIENT_ID=
|
||||
OIDC_CLIENT_SECRET=
|
||||
OIDC_REQUIRED_ACR=
|
||||
WEBHOOK_ALLOWED_HOSTS=
|
||||
2
.github/workflows/check.yml
vendored
2
.github/workflows/check.yml
vendored
@ -62,6 +62,8 @@ jobs:
|
||||
RELEASE_REF: ${{ github.ref }}
|
||||
run: |
|
||||
node scripts/release-version.mjs >> "$GITHUB_OUTPUT"
|
||||
- name: Verify HTTPS registry authentication endpoint
|
||||
run: node scripts/check-registry.mjs
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.jfritzsche.de
|
||||
|
||||
96
PRODUCTION.md
Normal file
96
PRODUCTION.md
Normal file
@ -0,0 +1,96 @@
|
||||
# Produktion mit Dokploy und Registry-Images
|
||||
|
||||
`docker-compose.production.yml` ist eine eigenständige Compose-Datei. Sie benötigt keine andere Compose-Datei und keinen lokalen Build. App, Migration und Worker verwenden dieselbe explizite Release-Version. Ein erfolgreicher Registry-Publish beider Images ist Voraussetzung; ein Git-Tag allein bedeutet noch nicht, dass die Images verfuegbar sind. Siehe [REGISTRY.md](REGISTRY.md).
|
||||
|
||||
## Konfiguration
|
||||
|
||||
### Variante ohne Traefik
|
||||
|
||||
`docker-compose.production.direct.yml` ist ebenfalls eigenständig und benötigt weder Dokploy noch ein externes Proxy-Netz. Sie veröffentlicht die App standardmäßig auf `127.0.0.1:3000` des Docker-Hosts. Die übrigen Dienste, Daten-Volumes und Migrationen entsprechen der Traefik-Variante. Nur **eine** der beiden Compose-Dateien verwenden.
|
||||
|
||||
`APP_PORT` bestimmt den Host-Port. `APP_BIND_ADDRESS=127.0.0.1` eignet sich für einen auf demselben Host laufenden HTTPS-Proxy, beispielsweise Nginx oder Caddy. Bei einem separaten vorgeschalteten Proxy die erreichbare private Host-IP einstellen und den Port entsprechend auf diesen Proxy begrenzen. `0.0.0.0` bindet an alle IPv4-Schnittstellen. `TRAEFIK_NETWORK` wird in dieser Variante nicht verwendet.
|
||||
|
||||
Die App selbst stellt HTTP bereit. Die öffentliche Produktionsadresse in `NEXTAUTH_URL` bleibt HTTPS; TLS muss deshalb ein vorhandener HTTPS-Proxy oder Load Balancer übernehmen. Die Datei stellt keinen eigenen TLS-Dienst bereit und deaktiviert die HTTPS-Prüfung der Anwendung nicht. Eine Nginx-Vorlage liegt unter [deploy/nginx.conf.example](deploy/nginx.conf.example).
|
||||
|
||||
Für alle nachfolgenden CLI-Schritte und die Datenübernahme diese Funktion verwenden:
|
||||
|
||||
```bash
|
||||
dc() { docker compose --env-file .env.production -p taskmanager-next -f docker-compose.production.direct.yml "$@"; }
|
||||
dc config --quiet
|
||||
```
|
||||
|
||||
Den Projektnamen konsistent auf den Namen der vorbereiteten Zielinstallation setzen. Die spätere Funktionsdefinition für die Dokploy-Variante überspringen. Nach Restore, Migration und Start lässt sich die App lokal prüfen:
|
||||
|
||||
```bash
|
||||
curl --fail http://127.0.0.1:3000/api/health
|
||||
```
|
||||
|
||||
Bei abweichendem `APP_PORT` oder `APP_BIND_ADDRESS` die Prüfadresse entsprechend anpassen.
|
||||
|
||||
### Variante mit Dokploy/Traefik
|
||||
|
||||
In Dokploy ein Compose-Projekt mit `docker-compose.production.yml` konfigurieren. Die Werte aus `.env.production.example` in die Compose-Umgebung übernehmen. Alternativ auf dem Server:
|
||||
|
||||
```bash
|
||||
cp .env.production.example .env.production
|
||||
chmod 600 .env.production
|
||||
openssl rand -hex 32 # NEXTAUTH_SECRET
|
||||
openssl rand -hex 24 # Passwort für eine neue Ziel-Datenbank
|
||||
```
|
||||
|
||||
Pflichtwerte:
|
||||
|
||||
| Variable | Wert |
|
||||
| --- | --- |
|
||||
| `TASKMANAGER_VERSION` | Tatsächlich veröffentlichte Version, z.B. `1.0.1` |
|
||||
| `POSTGRES_PASSWORD` | Passwort der Ziel-Datenbank |
|
||||
| `DATABASE_URL` | `postgresql://taskmanager:PASSWORT@db:5432/taskmanager` (Sonderzeichen URL-kodieren) |
|
||||
| `NEXTAUTH_URL` | Öffentliche HTTPS-Adresse der Aufgabenverwaltung |
|
||||
| `NEXTAUTH_SECRET` | Zufälliges Secret mit mindestens 32 Zeichen |
|
||||
| `POSTGRES_VOLUME` | Exakter Name des vorbereiteten Ziel-Volumes für PostgreSQL 16 |
|
||||
| `UPLOADS_VOLUME` | Exakter Name des Ziel-Volumes mit wiederhergestellten Anhängen |
|
||||
| `TRAEFIK_NETWORK` | Vorhandenes Traefik-Netz, standardmäßig `dokploy-network` |
|
||||
|
||||
`POSTGRES_USER` und `POSTGRES_DB` sind standardmäßig `taskmanager`; bei Änderungen die DATABASE_URL entsprechend anpassen. Bei einem bereits initialisierten Daten-Volume ändern diese Variablen weder vorhandene Benutzer noch Passwörter. Keine PostgreSQL-Daten einer anderen Hauptversion direkt einhängen.
|
||||
|
||||
Die App hängt am privaten Compose-Netz und am Proxy-Netz. DB, Migrator und Worker hängen nur am Compose-Netz; kein Dienst veröffentlicht Host-Ports. In Dokploy unter Domains die gewünschte Domain dem Dienst **app**, Container-Port **3000**, zuweisen und HTTPS mit Zertifikat aktivieren. Dokploy verwaltet dafür die Traefik-Router. Die Compose-Datei allein legt keinen Domain-Router an. Keine alten statischen `/uploads`-Freigaben übernehmen. `TRUST_PROXY=false` belassen, bis der Proxy eingehendes `X-Real-IP` nachweislich zuverlässig ersetzt.
|
||||
|
||||
## Bestehende Daten übernehmen
|
||||
|
||||
Vor dem ersten vollständigen Deployment [UPGRADE.md](UPGRADE.md) ausführen: Altanwendung stoppen, zusammengehöriges Datenbank-/Dateibackup erstellen, in separate Ziel-Volumes wiederherstellen und abnehmen. Die Compose-Datei kopiert keine Daten aus einer anderen Installation. Externe Volumes werden absichtlich nicht automatisch angelegt: ein falscher Name soll zum Fehler führen, statt unbemerkt eine leere Datenbank zu starten.
|
||||
|
||||
Nur für eine **neue, separate Zielinstallation** zwei noch nicht vorhandene Volume-Namen wählen, deren Nichtexistenz prüfen und sie dann mit `docker volume create NAME` anlegen. Namen in der Umgebung hinterlegen. Alte Volumes unangetastet lassen. Für die Befehle aus UPGRADE.md stets denselben Projektnamen und diese Compose-Datei verwenden. In Bash hilft folgende Funktion (auch für Restore und Abnahme):
|
||||
|
||||
```bash
|
||||
dc() { docker compose --env-file .env.production -p taskmanager-next -f docker-compose.production.yml "$@"; }
|
||||
dc config --quiet
|
||||
docker login git.jfritzsche.de --username jf
|
||||
dc pull
|
||||
dc up -d --wait db
|
||||
# Jetzt DB-Dump und Anhänge gemäß UPGRADE.md wiederherstellen.
|
||||
# Dort "docker compose -p taskmanager-next" jeweils durch "dc" ersetzen.
|
||||
```
|
||||
|
||||
In Dokploy stattdessen dessen tatsächlichen Projektnamen und dieselbe Umgebung verwenden; nicht parallel ein zweites CLI-Projekt auf denselben DB-Volumes starten. Bei privaten Images die Registry-Anmeldung auch für Dokploy hinterlegen. Zum Wiederherstellen der Dateien nutzt UPGRADE.md kurzzeitig UID 0; die regulären Anwendungsdienste laufen als UID 1001. Anhänge müssen dieser UID gehören. Eine gegebenenfalls erforderliche Prisma-Baseline nach UPGRADE.md prüfen, nicht automatisch setzen.
|
||||
|
||||
Nach Restore und Prüfung der Migrationshistorie:
|
||||
|
||||
```bash
|
||||
dc run --rm migrate
|
||||
# Nur bei Erfolg fortfahren; Bestandsmengen gemäß UPGRADE.md vergleichen.
|
||||
dc up -d --wait app
|
||||
# Login, Bestandsdaten und Downloads prüfen, noch im Wartungsmodus.
|
||||
dc up -d --wait worker
|
||||
dc ps -a
|
||||
dc logs --tail=100 migrate app worker
|
||||
```
|
||||
|
||||
Beim regulären vollständigen Compose-Start erzwingt die Abhängigkeitskette: gesunde DB → erfolgreiche Schema-, RBAC- und Datei-Migration → gesunde App → Worker. `migrate-access.ts` führt den RBAC-Seed aus; anschließend an die Datei-Migration läuft `prisma/seed.ts` (einschließlich idempotentem RBAC-Seed). Ist bereits ein Systemadministrator vorhanden, wird dessen Anlage übersprungen. Andernfalls sind `BOOTSTRAP_EMAIL` und `BOOTSTRAP_PASSWORD` erforderlich; ohne diese Werte bricht der Start ab. Bestehende Konten und Passwörter werden nicht überschrieben. Bei einem Upgrade ohne Administrator zuerst die wiederhergestellten Bestandsdaten prüfen, bevor ein neues Administratorkonto angelegt wird. Ein fehlgeschlagener Migrator blockiert den Start abhängiger Dienste, stoppt aber keine bereits laufende alte App: deshalb vor Updates immer App und Worker stoppen.
|
||||
|
||||
## Weitere Updates
|
||||
|
||||
Zusammengehöriges Backup erstellen, Schreibzugriffe verhindern und `dc stop app worker` ausführen. Dann `TASKMANAGER_VERSION` auf die nächste geprüfte Version setzen, `dc pull`, `dc run --rm migrate` und erst nach erfolgreicher Migration `dc up -d --wait app worker` ausführen. Keine gleichzeitigen Deployments oder Migratoren. `ARCHIVE_RETENTION_DAYS=0` verhindert automatische dauerhafte Archivbereinigung. Rollback und Backups stehen in UPGRADE.md und README.md.
|
||||
|
||||
### Erstes Administratorkonto
|
||||
|
||||
Bei einer bestätigten Neuinstallation `BOOTSTRAP_EMAIL` und `BOOTSTRAP_PASSWORD` setzen (Passwort mindestens 12 Zeichen, maximal 72 UTF-8-Bytes). Diese Werte erhält in den Produktionsdateien ausschließlich der Migrator. Nach erfolgreichem Bootstrap aus der Umgebung entfernen und den beendeten Migrationscontainer entfernen, damit dessen alte Umgebung nicht gespeichert bleibt (`dc rm -f migrate`). Bei der ersten Anmeldung ist eine Passwortänderung erforderlich. Bei vorhandenen Administratoren können beide Werte leer bleiben. `scripts/seed-demo.ts` wird niemals beim Produktionsstart ausgeführt.
|
||||
@ -6,6 +6,7 @@ Aufgabenverwaltung mit Next.js, PostgreSQL, privaten Anhängen und RBAC aus Benu
|
||||
|
||||
## Inhalt
|
||||
|
||||
- [Produktions-Compose für Dokploy/Traefik](PRODUCTION.md)
|
||||
- [Fertige Docker-Images, Versionen und Registry-Deployment](REGISTRY.md)
|
||||
- [Bestehende Installation übernehmen](UPGRADE.md)
|
||||
- [Voraussetzungen](#voraussetzungen)
|
||||
@ -37,7 +38,7 @@ Die folgenden Serverbefehle verwenden Bash. Alle Befehle im Checkout ausführen.
|
||||
```bash
|
||||
git clone https://git.jfritzsche.de/jf/taskmanager.git
|
||||
cd taskmanager
|
||||
git switch feature/security-rbac
|
||||
git switch main
|
||||
cp .env.example .env
|
||||
chmod 600 .env
|
||||
openssl rand -hex 32 # als NEXTAUTH_SECRET eintragen
|
||||
@ -64,10 +65,9 @@ Passwörter in `DATABASE_URL` müssen URL-kodiert sein; die oben generierten Hex
|
||||
docker compose -p taskmanager build
|
||||
docker compose -p taskmanager up -d --wait db
|
||||
docker compose -p taskmanager run --rm migrate
|
||||
docker compose -p taskmanager run --rm --no-deps migrate npm run prisma:seed
|
||||
```
|
||||
|
||||
Bootstrap-Werte danach aus `.env` entfernen. Seed legt ausschließlich bei fehlendem Administrator einen neuen an; keine Standardzugänge. Das Startpasswort muss bei der ersten Anmeldung geändert werden.
|
||||
Der Migrationsdienst führt auch die Seeds aus: RBAC über `migrate-access.ts`, danach Administrator-Bootstrap über `prisma/seed.ts`. Bootstrap-Werte danach aus `.env` entfernen. Seed legt ausschließlich bei fehlendem Administrator einen neuen an; keine Standardzugänge. Das Startpasswort muss bei der ersten Anmeldung geändert werden.
|
||||
|
||||
```bash
|
||||
docker compose -p taskmanager up -d --wait app worker
|
||||
|
||||
48
REGISTRY.md
48
REGISTRY.md
@ -28,6 +28,51 @@ Ein bereits vorhandenes Secret bleibt unverändert. Der Workflow kann alternativ
|
||||
|
||||
Gitea-Pakete gehören einem Benutzer/einer Organisation. Sichtbarkeit nach dem ersten Publish prüfen und Pakete bei Bedarf mit `jf/taskmanager` verknüpfen. Private Images erfordern beim Deployment einen separaten Token mit `read:package`. Dokumentation: [Gitea Container Registry](https://docs.gitea.com/usage/packages/container/).
|
||||
|
||||
### Gitea hinter einem HTTPS-Proxy
|
||||
|
||||
Gitea muss seine **externe** URL kennen. In `app.ini`:
|
||||
|
||||
```ini
|
||||
[server]
|
||||
ROOT_URL = https://git.jfritzsche.de/
|
||||
```
|
||||
|
||||
Alternativ im Gitea-Container `GITEA__server__ROOT_URL=https://git.jfritzsche.de/` setzen und Gitea neu starten. Der interne Listener darf weiterhin HTTP auf Port 3000 verwenden. HTTPS am Proxy allein korrigiert nicht automatisch die Registry-Token-URL.
|
||||
|
||||
Bei einer geänderten Compose-Umgebungsvariable reicht `docker restart` nicht: den **Gitea-Dienst** aus dessen eigenem Compose-Projekt neu erstellen (`docker compose up -d --force-recreate DIENSTNAME`). Das ist nicht der Taskmanager-Dienst. In der Gitea-Administrationsansicht die tatsächlich geladene Serverkonfiguration prüfen; eine Umgebungsvariable kann eine manuell bearbeitete `app.ini` beim Start wieder überschreiben. Keine Secrets oder vollständigen Umgebungsvariablen in Fehlerlogs veröffentlichen.
|
||||
|
||||
Neue Release-Workflows prüfen den HTTPS-Realm bereits vor Login und Image-Build mit `node scripts/check-registry.mjs`. Ein erneuter Lauf des alten Tags `v1.0.0` verwendet weiterhin den Workflow dieses Tags; die Serverkorrektur ist auch dafür erforderlich.
|
||||
|
||||
**Gitea 1.22.3: Proxy-Header haben Vorrang.** Der Registry-Code verwendet `GuessCurrentHostURL`; ein vom Proxy angeliefertes `X-Forwarded-Proto: http` übersteuert eine korrekt gesetzte HTTPS-`ROOT_URL`. Danach berücksichtigt Gitea auch `X-Forwarded-Protocol`, `X-Url-Scheme`, `Front-End-Https` und `X-Forwarded-Ssl`. Siehe [Registry-Code](https://github.com/go-gitea/gitea/blob/v1.22.3/routers/api/packages/container/container.go) und [URL-Ermittlung](https://github.com/go-gitea/gitea/blob/v1.22.3/modules/httplib/url.go).
|
||||
|
||||
Wenn Umgebungsvariable und `app.ini` bereits HTTPS enthalten, nicht wiederholt ROOT_URL ändern. Im Gitea-Container ohne Proxy prüfen:
|
||||
|
||||
```sh
|
||||
wget -S -O /dev/null http://127.0.0.1:3000/v2/ 2>&1
|
||||
```
|
||||
|
||||
HTTP 401 ist dabei erwartbar. Liefert der direkte Aufruf einen HTTPS-Realm, der öffentliche Aufruf aber HTTP, liegt der Unterschied in der Proxy-Kette. Der vertrauenswürdige Proxy muss für externe HTTPS-Aufrufe das ursprüngliche Protokoll korrekt an Gitea weiterreichen. Bei Cloudflare auch SSL/TLS-Modus und die Verbindung zum Origin prüfen; `Full (strict)` benötigt ein gültiges Origin-Zertifikat. Keine ungeprüften Client-Header pauschal vertrauen. Die konkrete Korrektur hängt von Nginx, Traefik, Caddy bzw. Cloudflare Tunnel ab.
|
||||
|
||||
```bash
|
||||
curl -sS -D - -o /dev/null https://git.jfritzsche.de/v2/
|
||||
```
|
||||
|
||||
Ohne Anmeldung ist HTTP 401 normal. Der `WWW-Authenticate`-Header muss als Bearer-Realm **`https://git.jfritzsche.de/v2/token`** nennen. Ein HTTP-Realm kann den Upload mit `authorization server did not include a token in the response` scheitern lassen, obwohl `docker login` erfolgreich erschien. Keine HTTP-/TLS-Ausnahmen als Ersatz einrichten. Nach Korrektur den fehlgeschlagenen `publish`-Job erneut starten; dafür weder einen Release-Tag verschieben noch die Anwendung neu versionieren.
|
||||
|
||||
### Upload scheitert mit HTTP 413 von Cloudflare
|
||||
|
||||
Enthält die Fehlerantwort beim Push einer Image-Schicht `413 Payload Too Large` und den HTML-Absender `cloudflare`, blockiert Cloudflare den Upload. Eine funktionierende Registry-Anmeldung und ein korrekter HTTPS-Realm schließen diesen Fehler nicht aus. Cloudflare begrenzt einzelne Upload-Anfragen je Tarif, bei Free/Pro auf 100 MB; kleinere konfigurierte Grenzwerte sind ebenfalls möglich. Siehe [Cloudflare: Error 413](https://developers.cloudflare.com/support/troubleshooting/http-status-codes/4xx-client-error/error-413/).
|
||||
|
||||
Für große Registry-Uploads kann `git.jfritzsche.de` als **DNS only** (graue Wolke) betrieben werden. **Vor der Umstellung** muss Dokploy/Traefik die Domain selbst auf Port 443 mit einem öffentlich vertrauenswürdigen Zertifikat, etwa von Let's Encrypt, bedienen. Ein reiner HTTP-Router auf `web` reicht nicht. Ein nur von Cloudflare vertrautes Origin-CA-Zertifikat reicht für direkte Docker-Clients ebenfalls nicht. Die direkte Erreichbarkeit des Origins muss zur Firewall-Konfiguration passen. Die Umstellung betrifft auch die Gitea-Weboberfläche auf derselben Domain.
|
||||
|
||||
Vorher von außerhalb des Servers mit dessen tatsächlicher öffentlicher IP prüfen:
|
||||
|
||||
```bash
|
||||
curl --resolve git.jfritzsche.de:443:SERVER_IP -sS -D - -o /dev/null https://git.jfritzsche.de/v2/
|
||||
```
|
||||
|
||||
Erwartet: erfolgreiche TLS-Prüfung ohne `-k`, HTTP 401 und HTTPS-Bearer-Realm. Erst danach den DNS-Proxy abschalten, die DNS-Auflösung abwarten und den fehlgeschlagenen Publish-Job erneut starten. Keine Änderung von ROOT_URL, Tokens oder Release-Tags erforderlich. Falls der Cloudflare-Schutz für die Domain erhalten bleiben soll, benötigt der Publisher stattdessen einen gezielt eingerichteten direkten HTTPS-Zugang zum Origin; auch der BuildKit-Container muss diesen verwenden. Ein erneuter Lauf über dieselbe begrenzte Verbindung behebt HTTP 413 nicht.
|
||||
|
||||
### Neues Release erstellen
|
||||
|
||||
```bash
|
||||
@ -53,7 +98,8 @@ export TASKMANAGER_VERSION=1.0.0
|
||||
docker compose -p taskmanager -f docker-compose.yml -f docker-compose.registry.yml pull
|
||||
docker compose -p taskmanager -f docker-compose.yml -f docker-compose.registry.yml up -d --wait db
|
||||
docker compose -p taskmanager -f docker-compose.yml -f docker-compose.registry.yml run --rm migrate
|
||||
# Nur leere Neuinstallation: einmaliges Bootstrap nach README.
|
||||
# Bei Neuinstallation vorher Bootstrap-Werte gemäß README setzen;
|
||||
# der Migrationsdienst führt den Seed mit aus.
|
||||
docker compose -p taskmanager -f docker-compose.yml -f docker-compose.registry.yml up -d --wait app worker
|
||||
```
|
||||
|
||||
|
||||
@ -21,7 +21,7 @@ Festhalten: laufender Git-Commit/Image-ID, Compose-Konfiguration, verschlüsselt
|
||||
Neue Version in **separatem** Verzeichnis bauen:
|
||||
|
||||
```bash
|
||||
git clone --branch feature/security-rbac https://git.jfritzsche.de/jf/taskmanager.git taskmanager-next
|
||||
git clone --branch main https://git.jfritzsche.de/jf/taskmanager.git taskmanager-next
|
||||
cd taskmanager-next
|
||||
git rev-parse HEAD # Release-Commit im Änderungsprotokoll festhalten
|
||||
cp .env.example .env
|
||||
@ -115,6 +115,8 @@ Der Befehl führt nacheinander aus:
|
||||
2. `scripts/migrate-access.ts`: eindeutige E-Mail-Normalisierung, Bestandsmitgliedschaften, alte Rollen nach RBAC, Entwertung alter Sitzungen. Transaktional und über einen Migrationsmarker wiederholbar.
|
||||
3. `scripts/migrate-uploads.ts`: **alle** referenzierten Dateien auf Vorhandensein, Größe und zugelassenen Inhalt prüfen; alte `/uploads/...`-Pfade in zufällige private Speicherschlüssel umstellen und kopierte Inhalte per SHA-256 vergleichen. Originaldateien bleiben als Rückfallkopie im privaten Volume erhalten. Bereits migrierte Dateien werden beim erneuten Lauf geprüft, nicht erneut kopiert.
|
||||
|
||||
4. `prisma/seed.ts`: RBAC-Stammdaten idempotent prüfen und Administrator-Bootstrap ausführen. Ein vorhandener Systemadministrator wird erkannt; kein Konto und kein Passwort werden überschrieben. Fehlt ein Administrator, stoppt der Lauf ohne gültige `BOOTSTRAP_EMAIL`/`BOOTSTRAP_PASSWORD`. In diesem Fall zuerst den Restore und die alten Rollen prüfen; erst danach gegebenenfalls bewusst ein neues Administratorkonto anlegen.
|
||||
|
||||
App hängt vom erfolgreichen Migrationsdienst ab. Kein Start bei Fehlern. Die Dateiübernahme ist pro Datei wiederaufnehmbar, nicht eine globale Dateisystemtransaktion. Nach einem Abbruch Ursache beheben und erneut ausführen. Keine parallelen Migratoren und keine schreibenden alten/neuen Apps währenddessen betreiben.
|
||||
|
||||
### Welche Daten bleiben erhalten?
|
||||
@ -134,6 +136,8 @@ Fehlende oder ungültige Anhänge werden **nicht** übersprungen oder gelöscht.
|
||||
|
||||
### Alte Rollen werden übernommen
|
||||
|
||||
Die Migration `20261008150000_checklist_permission` trennt „Checkliste bearbeiten“ (`tasks.checklist.update`) von „Aufgaben bearbeiten“. Rollen mit bisherigem `tasks.update` erhalten das neue Recht einmalig, mit ihren bestehenden Zuweisungsbereichen. Anschließend lässt es sich unabhängig entziehen oder vergeben; spätere Seeds stellen entzogene Rechte bei diesen Rollen nicht wieder her. Die geschützte Systemadministrator-Rolle besitzt weiterhin alle Rechte. Das neue Recht umfasst Hinzufügen, Ändern, Entfernen und Abhaken von Punkten bestehender Aufgaben. Eine Checkliste beim Erstellen einer neuen Aufgabe bleibt Teil von `tasks.create`.
|
||||
|
||||
| Alte Rolle | Neue direkte Rollen |
|
||||
| --- | --- |
|
||||
| ADMIN | Systemadministrator / GLOBAL |
|
||||
|
||||
123
docker-compose.production.direct.yml
Normal file
123
docker-compose.production.direct.yml
Normal file
@ -0,0 +1,123 @@
|
||||
# Standalone production deployment with a configurable host port; no Traefik required.
|
||||
# Read PRODUCTION.md before deploying an existing installation.
|
||||
x-app-environment: &app-environment
|
||||
NODE_ENV: production
|
||||
DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL with host db and URL-encoded credentials}
|
||||
NEXTAUTH_URL: ${NEXTAUTH_URL:?Set the public HTTPS URL}
|
||||
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:?Set NEXTAUTH_SECRET}
|
||||
ALLOW_LOCAL_HTTP: "false"
|
||||
TRUST_PROXY: ${TRUST_PROXY:-false}
|
||||
APP_TIMEZONE: ${APP_TIMEZONE:-Europe/Berlin}
|
||||
UPLOAD_DIR: /app/private-uploads
|
||||
GROUP_UPLOAD_QUOTA_MB: ${GROUP_UPLOAD_QUOTA_MB:-1024}
|
||||
ARCHIVE_RETENTION_DAYS: ${ARCHIVE_RETENTION_DAYS:-0}
|
||||
SMTP_URL: ${SMTP_URL:-}
|
||||
MAIL_FROM: ${MAIL_FROM:-}
|
||||
OIDC_ISSUER: ${OIDC_ISSUER:-}
|
||||
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
|
||||
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
|
||||
OIDC_REQUIRED_ACR: ${OIDC_REQUIRED_ACR:-}
|
||||
WEBHOOK_ALLOWED_HOSTS: ${WEBHOOK_ALLOWED_HOSTS:-}
|
||||
|
||||
x-logging: &logging
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-taskmanager}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-taskmanager}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
start_period: 10s
|
||||
stop_grace_period: 60s
|
||||
logging: *logging
|
||||
|
||||
migrate:
|
||||
image: git.jfritzsche.de/jf/taskmanager-operations:${TASKMANAGER_VERSION:?Set a published release version}
|
||||
restart: "no"
|
||||
init: true
|
||||
user: "1001:1001"
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
environment:
|
||||
<<: *app-environment
|
||||
LEGACY_UPLOAD_DIR: /app/private-uploads
|
||||
BOOTSTRAP_EMAIL: ${BOOTSTRAP_EMAIL:-}
|
||||
BOOTSTRAP_PASSWORD: ${BOOTSTRAP_PASSWORD:-}
|
||||
command: ["sh", "-c", "npx prisma migrate deploy && npx tsx scripts/migrate-access.ts && npx tsx scripts/migrate-uploads.ts && npx tsx prisma/seed.ts"]
|
||||
volumes:
|
||||
- uploads:/app/private-uploads
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
logging: *logging
|
||||
|
||||
app:
|
||||
image: git.jfritzsche.de/jf/taskmanager:${TASKMANAGER_VERSION:?Set a published release version}
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
user: "1001:1001"
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
cap_drop: ["ALL"]
|
||||
read_only: true
|
||||
tmpfs: ["/tmp:rw,noexec,nosuid,size=64m"]
|
||||
environment: *app-environment
|
||||
ports:
|
||||
- "${APP_BIND_ADDRESS:-127.0.0.1}:${APP_PORT:-3000}:3000"
|
||||
volumes:
|
||||
- uploads:/app/private-uploads
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/health',{signal:AbortSignal.timeout(4000)}).then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 30s
|
||||
retries: 3
|
||||
depends_on:
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
stop_grace_period: 30s
|
||||
logging: *logging
|
||||
|
||||
worker:
|
||||
image: git.jfritzsche.de/jf/taskmanager-operations:${TASKMANAGER_VERSION:?Set a published release version}
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
user: "1001:1001"
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
cap_drop: ["ALL"]
|
||||
environment:
|
||||
<<: *app-environment
|
||||
WORKER_HEARTBEAT_FILE: /tmp/taskmanager-worker-heartbeat
|
||||
command: ["node", "--import", "tsx", "scripts/worker.ts"]
|
||||
volumes:
|
||||
- uploads:/app/private-uploads
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "const fs=require('fs');process.exit(Date.now()-fs.statSync('/tmp/taskmanager-worker-heartbeat').mtimeMs<180000?0:1)"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 60s
|
||||
retries: 3
|
||||
depends_on:
|
||||
app:
|
||||
condition: service_healthy
|
||||
stop_grace_period: 30s
|
||||
logging: *logging
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
external: true
|
||||
name: ${POSTGRES_VOLUME:?Set the prepared PostgreSQL target volume name}
|
||||
uploads:
|
||||
external: true
|
||||
name: ${UPLOADS_VOLUME:?Set the prepared uploads target volume name}
|
||||
128
docker-compose.production.yml
Normal file
128
docker-compose.production.yml
Normal file
@ -0,0 +1,128 @@
|
||||
# Standalone production deployment for Dokploy/Traefik.
|
||||
# Read PRODUCTION.md before deploying an existing installation.
|
||||
x-app-environment: &app-environment
|
||||
NODE_ENV: production
|
||||
DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL with host db and URL-encoded credentials}
|
||||
NEXTAUTH_URL: ${NEXTAUTH_URL:?Set the public HTTPS URL}
|
||||
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:?Set NEXTAUTH_SECRET}
|
||||
ALLOW_LOCAL_HTTP: "false"
|
||||
TRUST_PROXY: ${TRUST_PROXY:-false}
|
||||
APP_TIMEZONE: ${APP_TIMEZONE:-Europe/Berlin}
|
||||
UPLOAD_DIR: /app/private-uploads
|
||||
GROUP_UPLOAD_QUOTA_MB: ${GROUP_UPLOAD_QUOTA_MB:-1024}
|
||||
ARCHIVE_RETENTION_DAYS: ${ARCHIVE_RETENTION_DAYS:-0}
|
||||
SMTP_URL: ${SMTP_URL:-}
|
||||
MAIL_FROM: ${MAIL_FROM:-}
|
||||
OIDC_ISSUER: ${OIDC_ISSUER:-}
|
||||
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
|
||||
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
|
||||
OIDC_REQUIRED_ACR: ${OIDC_REQUIRED_ACR:-}
|
||||
WEBHOOK_ALLOWED_HOSTS: ${WEBHOOK_ALLOWED_HOSTS:-}
|
||||
|
||||
x-logging: &logging
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-taskmanager}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-taskmanager}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
start_period: 10s
|
||||
stop_grace_period: 60s
|
||||
logging: *logging
|
||||
|
||||
migrate:
|
||||
image: git.jfritzsche.de/jf/taskmanager-operations:${TASKMANAGER_VERSION:?Set a published release version}
|
||||
restart: "no"
|
||||
init: true
|
||||
user: "1001:1001"
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
environment:
|
||||
<<: *app-environment
|
||||
LEGACY_UPLOAD_DIR: /app/private-uploads
|
||||
BOOTSTRAP_EMAIL: ${BOOTSTRAP_EMAIL:-}
|
||||
BOOTSTRAP_PASSWORD: ${BOOTSTRAP_PASSWORD:-}
|
||||
command: ["sh", "-c", "npx prisma migrate deploy && npx tsx scripts/migrate-access.ts && npx tsx scripts/migrate-uploads.ts && npx tsx prisma/seed.ts"]
|
||||
volumes:
|
||||
- uploads:/app/private-uploads
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
logging: *logging
|
||||
|
||||
app:
|
||||
image: git.jfritzsche.de/jf/taskmanager:${TASKMANAGER_VERSION:?Set a published release version}
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
user: "1001:1001"
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
cap_drop: ["ALL"]
|
||||
read_only: true
|
||||
tmpfs: ["/tmp:rw,noexec,nosuid,size=64m"]
|
||||
environment: *app-environment
|
||||
expose: ["3000"]
|
||||
volumes:
|
||||
- uploads:/app/private-uploads
|
||||
networks: ["default", "proxy"]
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/health',{signal:AbortSignal.timeout(4000)}).then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 30s
|
||||
retries: 3
|
||||
depends_on:
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
stop_grace_period: 30s
|
||||
logging: *logging
|
||||
|
||||
worker:
|
||||
image: git.jfritzsche.de/jf/taskmanager-operations:${TASKMANAGER_VERSION:?Set a published release version}
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
user: "1001:1001"
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
cap_drop: ["ALL"]
|
||||
environment:
|
||||
<<: *app-environment
|
||||
WORKER_HEARTBEAT_FILE: /tmp/taskmanager-worker-heartbeat
|
||||
command: ["node", "--import", "tsx", "scripts/worker.ts"]
|
||||
volumes:
|
||||
- uploads:/app/private-uploads
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "const fs=require('fs');process.exit(Date.now()-fs.statSync('/tmp/taskmanager-worker-heartbeat').mtimeMs<180000?0:1)"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 60s
|
||||
retries: 3
|
||||
depends_on:
|
||||
app:
|
||||
condition: service_healthy
|
||||
stop_grace_period: 30s
|
||||
logging: *logging
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
external: true
|
||||
name: ${TRAEFIK_NETWORK:-dokploy-network}
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
external: true
|
||||
name: ${POSTGRES_VOLUME:?Set the prepared PostgreSQL target volume name}
|
||||
uploads:
|
||||
external: true
|
||||
name: ${UPLOADS_VOLUME:?Set the prepared uploads target volume name}
|
||||
@ -17,7 +17,7 @@ services:
|
||||
context: .
|
||||
target: operations
|
||||
env_file: ${TASKMANAGER_ENV_FILE:-.env}
|
||||
command: ["sh", "-c", "npx prisma migrate deploy && npx tsx scripts/migrate-access.ts && npx tsx scripts/migrate-uploads.ts"]
|
||||
command: ["sh", "-c", "npx prisma migrate deploy && npx tsx scripts/migrate-access.ts && npx tsx scripts/migrate-uploads.ts && npx tsx prisma/seed.ts"]
|
||||
environment:
|
||||
UPLOAD_DIR: /app/private-uploads
|
||||
LEGACY_UPLOAD_DIR: /app/private-uploads
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "taskmanager",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "taskmanager",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "taskmanager",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
@ -0,0 +1,10 @@
|
||||
-- Preserve existing checklist access once. Subsequent seeds must not restore
|
||||
-- permissions deliberately revoked by an administrator.
|
||||
INSERT INTO "Permission" ("key") VALUES ('tasks.checklist.update')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO "RolePermission" ("roleId", "permissionKey")
|
||||
SELECT "roleId", 'tasks.checklist.update'
|
||||
FROM "RolePermission"
|
||||
WHERE "permissionKey" = 'tasks.update'
|
||||
ON CONFLICT DO NOTHING;
|
||||
21
scripts/check-registry.mjs
Normal file
21
scripts/check-registry.mjs
Normal file
@ -0,0 +1,21 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
// Inspect the unauthenticated challenge before sending credentials or building images.
|
||||
const headers = execFileSync("curl", [
|
||||
"--silent", "--show-error", "--max-time", "20",
|
||||
"--header", "Cache-Control: no-cache", "--dump-header", "-",
|
||||
"--output", process.platform === "win32" ? "NUL" : "/dev/null",
|
||||
"https://git.jfritzsche.de/v2/",
|
||||
], { encoding: "utf8" });
|
||||
const realm = headers.match(/^www-authenticate:\s*Bearer[^\r\n]*realm="([^"]+)"/im)?.[1];
|
||||
if (realm !== "https://git.jfritzsche.de/v2/token") {
|
||||
throw new Error(
|
||||
`Registry advertises an unexpected authentication realm: ${realm ?? "missing"}. ` +
|
||||
"Set Gitea [server] ROOT_URL=https://git.jfritzsche.de/ " +
|
||||
"(Docker: GITEA__server__ROOT_URL), recreate the Gitea container when changing its environment, " +
|
||||
"and check the proxy: in Gitea 1.22.3 forwarded scheme headers override ROOT_URL. " +
|
||||
"The trusted proxy must pass X-Forwarded-Proto=https for the public HTTPS request. " +
|
||||
"Verify the live /v2/ challenge. No credentials were sent by this check.",
|
||||
);
|
||||
}
|
||||
console.log("Registry HTTPS authentication endpoint verified.");
|
||||
@ -66,6 +66,7 @@ export const permissionLabels: Record<string, string> = {
|
||||
"tasks.read": "Aufgaben ansehen",
|
||||
"tasks.create": "Aufgaben erstellen",
|
||||
"tasks.update": "Aufgaben bearbeiten",
|
||||
"tasks.checklist.update": "Checkliste bearbeiten",
|
||||
"tasks.assign": "Aufgaben zuweisen",
|
||||
"tasks.claim": "Aufgaben übernehmen",
|
||||
"tasks.change_status": "Status ändern",
|
||||
|
||||
@ -98,6 +98,7 @@ export function TaskDetail({ id }: { id: string }) {
|
||||
const qc = useQueryClient();
|
||||
const router = useRouter();
|
||||
const [edit, setEdit] = useState(false);
|
||||
const [checklistDraft, setChecklistDraft] = useState<string | null>(null);
|
||||
const [template, setTemplate] = useState(false);
|
||||
const [error, setError] = useState<unknown>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
@ -282,19 +283,35 @@ export function TaskDetail({ id }: { id: string }) {
|
||||
{t.checklist?.filter((c) => c.done).length || 0} von{" "}
|
||||
{t.checklist?.length || 0} erledigt
|
||||
</span>
|
||||
{can("tasks.update") && !t.archivedAt && (
|
||||
<Button size="sm" variant="ghost" onClick={() => setEdit(true)}>
|
||||
{can("tasks.checklist.update") && !t.archivedAt && (
|
||||
<Button size="sm" variant="ghost" onClick={() => setChecklistDraft(t.checklist?.map(c => c.text).join("\n") || "")}>
|
||||
Bearbeiten
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{checklistDraft !== null && can("tasks.checklist.update") && !t.archivedAt && (
|
||||
<div className="mb-4 space-y-3">
|
||||
<label className="text-sm" htmlFor="checklist-draft">Checkliste (ein Punkt pro Zeile)</label>
|
||||
<Textarea id="checklist-draft" value={checklistDraft} disabled={busy}
|
||||
onChange={e => setChecklistDraft(e.target.value)} />
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" disabled={busy} onClick={() => run(async () => {
|
||||
await update({ checklist: checklistDraft.split("\n").map(text => text.trim()).filter(Boolean).map(text => ({
|
||||
text, done: t.checklist?.find(c => c.text === text)?.done || false,
|
||||
})) });
|
||||
setChecklistDraft(null);
|
||||
})}>Checkliste speichern</Button>
|
||||
<Button size="sm" variant="outline" disabled={busy} onClick={() => setChecklistDraft(null)}>Abbrechen</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{t.checklist?.length ? (
|
||||
<div className="space-y-3">
|
||||
{t.checklist.map((c, i) => (
|
||||
<label key={i} className="flex items-start gap-3 text-sm">
|
||||
<Checkbox
|
||||
checked={c.done}
|
||||
disabled={busy || !can("tasks.update") || !!t.archivedAt}
|
||||
disabled={busy || checklistDraft !== null || !can("tasks.checklist.update") || !!t.archivedAt}
|
||||
onCheckedChange={() =>
|
||||
run(() =>
|
||||
update({
|
||||
|
||||
@ -49,6 +49,7 @@ export function TaskEditor({
|
||||
recurrenceDays: task?.recurrenceDays || 0,
|
||||
checklist: task?.checklist?.map((c) => c.text).join("\n") || "",
|
||||
});
|
||||
const editChecklist = !task || allowed(me, "tasks.checklist.update", task);
|
||||
const [error, setError] = useState<unknown>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [templateId, setTemplateId] = useState("");
|
||||
@ -88,13 +89,13 @@ export function TaskEditor({
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean),
|
||||
checklist: form.checklist
|
||||
checklist: editChecklist ? form.checklist
|
||||
.split("\n")
|
||||
.filter((t) => t.trim())
|
||||
.map((text) => ({
|
||||
text,
|
||||
done: task?.checklist?.find((c) => c.text === text)?.done || false,
|
||||
})),
|
||||
})) : undefined,
|
||||
};
|
||||
await request(
|
||||
task ? `/api/tasks/${task.id}` : "/api/tasks",
|
||||
@ -264,6 +265,7 @@ export function TaskEditor({
|
||||
<Field label="Checkliste (ein Punkt pro Zeile)">
|
||||
<Textarea
|
||||
className={inputClass}
|
||||
disabled={!editChecklist}
|
||||
value={form.checklist}
|
||||
onChange={(e) => change("checklist", e.target.value)}
|
||||
/>
|
||||
|
||||
@ -2,6 +2,7 @@ export const PERMISSIONS = [
|
||||
"tasks.read",
|
||||
"tasks.create",
|
||||
"tasks.update",
|
||||
"tasks.checklist.update",
|
||||
"tasks.assign",
|
||||
"tasks.claim",
|
||||
"tasks.change_status",
|
||||
@ -95,6 +96,7 @@ export const LEADER = [
|
||||
...COORDINATOR,
|
||||
"tasks.change_status",
|
||||
"tasks.update",
|
||||
"tasks.checklist.update",
|
||||
"tasks.reopen",
|
||||
"tasks.archive",
|
||||
"tasks.delete",
|
||||
|
||||
@ -194,8 +194,10 @@ export async function updateTask(user: Actor, id: string, raw: unknown) {
|
||||
if (old.version !== version)
|
||||
throw new HttpError(409, "Die Aufgabe wurde geändert. Bitte neu laden.");
|
||||
const fields = Object.keys(data);
|
||||
if (fields.some((k) => k !== "status"))
|
||||
if (fields.some((k) => k !== "status" && k !== "checklist"))
|
||||
requirePermission(user, "tasks.update", old);
|
||||
if (data.checklist !== undefined)
|
||||
requirePermission(user, "tasks.checklist.update", old);
|
||||
if (data.status) {
|
||||
requirePermission(user, "tasks.change_status", old);
|
||||
if (old.status === "ERLEDIGT" && data.status !== "ERLEDIGT")
|
||||
|
||||
@ -26,6 +26,59 @@ async function login(user: string) {
|
||||
return api;
|
||||
}
|
||||
test.afterAll(async () => db.$disconnect());
|
||||
test("checklist permission is independent and scoped, including its editor", async ({ page }) => {
|
||||
test.setTimeout(60000);
|
||||
const roleId = "test-checklist-role";
|
||||
const taskId = "test-checklist-task";
|
||||
await db.task.deleteMany({ where: { id: taskId } });
|
||||
await db.accessRole.deleteMany({ where: { id: roleId } });
|
||||
await db.accessRole.create({ data: { id: roleId, name: roleId, permissions: { create: [
|
||||
{ permissionKey: "tasks.read" }, { permissionKey: "tasks.checklist.update" },
|
||||
] } } });
|
||||
await db.userRole.create({ data: { userId: "test-worker", roleId, scope: "ASSIGNED" } });
|
||||
await db.task.create({ data: { id: taskId, wo: "Test", was: "Checklist permissions", bisWann: new Date("2026-12-01"), creatorId: "test-admin", owningGroupId: "test-a", assigneeId: "test-worker" } });
|
||||
const api = await login("test-worker");
|
||||
try {
|
||||
const patch = async (data: object) => {
|
||||
const task = await db.task.findUniqueOrThrow({ where: { id: taskId } });
|
||||
return api.patch(`/api/tasks/${taskId}`, { data: { version: task.version, ...data } });
|
||||
};
|
||||
expect((await patch({ checklist: [{ text: "Check", done: false }] })).status()).toBe(200);
|
||||
expect((await patch({ was: "Forbidden", checklist: [] })).status()).toBe(403);
|
||||
expect((await db.task.findUniqueOrThrow({ where: { id: taskId } })).was).toBe("Checklist permissions");
|
||||
await page.context().addCookies((await api.storageState()).cookies);
|
||||
await page.goto(`/dashboard/tasks/${taskId}`);
|
||||
await page.getByRole("button", { name: "Bearbeiten", exact: true }).click();
|
||||
await page.getByLabel("Checkliste (ein Punkt pro Zeile)").fill("Check\nSecond");
|
||||
await page.getByRole("button", { name: "Checkliste speichern" }).click();
|
||||
await expect(page.getByText("Second", { exact: true })).toBeVisible();
|
||||
await page.getByRole("checkbox").first().click();
|
||||
await expect.poll(async () => ((await db.task.findUniqueOrThrow({ where: { id: taskId } })).checklist as { done: boolean }[])[0].done).toBe(true);
|
||||
await db.task.update({ where: { id: taskId }, data: { assigneeId: "test-other" } });
|
||||
expect((await patch({ checklist: [] })).status()).toBe(404);
|
||||
await db.task.update({ where: { id: taskId }, data: { assigneeId: "test-worker" } });
|
||||
await db.rolePermission.delete({ where: { roleId_permissionKey: { roleId, permissionKey: "tasks.checklist.update" } } });
|
||||
await db.rolePermission.create({ data: { roleId, permissionKey: "tasks.update" } });
|
||||
expect((await patch({ was: "Allowed" })).status()).toBe(200);
|
||||
expect((await patch({ checklist: [] })).status()).toBe(403);
|
||||
await page.reload();
|
||||
await expect(page.getByRole("checkbox").first()).toBeDisabled();
|
||||
await page.getByRole("button", { name: "Bearbeiten", exact: true }).click();
|
||||
const editor = page.getByRole("dialog");
|
||||
await editor.getByText("Weitere Angaben", { exact: false }).click();
|
||||
await expect(editor.getByLabel("Checkliste (ein Punkt pro Zeile)")).toBeDisabled();
|
||||
await editor.getByLabel(/^Beschreibung/).fill("Editor without checklist access");
|
||||
await editor.getByRole("button", { name: "Speichern", exact: true }).click();
|
||||
await expect(editor).not.toBeVisible();
|
||||
expect((await db.task.findUniqueOrThrow({ where: { id: taskId } })).checklist).toEqual([
|
||||
{ text: "Check", done: true }, { text: "Second", done: false },
|
||||
]);
|
||||
} finally {
|
||||
await api.dispose();
|
||||
await db.task.delete({ where: { id: taskId } });
|
||||
await db.accessRole.delete({ where: { id: roleId } });
|
||||
}
|
||||
});
|
||||
test("unauthenticated APIs and public upload paths are denied", async ({
|
||||
request,
|
||||
}) => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user