From 27b1057cd4c90846dabd54a71589243e7242c798 Mon Sep 17 00:00:00 2001 From: karim Date: Sat, 23 May 2026 19:08:00 +0200 Subject: [PATCH] Release 0.8.0: Cloud-Variante (Supabase, Multi-Studio, Realtime, Web-Deploy) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rapport ist jetzt dual: lokal (wie bisher) ODER Cloud auf eigenem Supabase-Server. Beide Modi haben dieselben Funktionen, Cloud zusätzlich Multi-User + Live-Sync. Storage-Architektur - src/storage/adapter.js: einheitliche Promise-API, LocalStorage- und SupabaseAdapter - src/storage/migrations.js: applyMigrations als reine Funktion, für beide Backends - Konfig-driven: VITE_SUPABASE_URL im Production-Build → automatisch Cloud-Modus Postgres-Schema (supabase/migrations/0001–0010) - 29 Tabellen, multi-tenant via studio_id + Row-Level-Security - Audit-Spalten (created_by/updated_by/at) + Trigger - Seed-Trigger pro neuem Studio (Rollen, Templates, Absenz-Typen) - Realtime-Publication für Live-Sync - RPCs: ensure_profile, create_studio_with_admin (mit Personen-Sharing), list_studios, load_persons_for_studio, attach_user_to_studio Cloud-Features (App) - BackendChoice.jsx als Erst-Screen «Lokal oder Cloud» - CloudSetup.jsx: 3-Schritt-Wizard für Erst-Einrichtung - Login.jsx: Modus-Switcher + Server-URL + Studio-Dropdown + Passwort-Vergessen - ResetPassword.jsx: empfängt Mail-Link-Klick via PASSWORD_RECOVERY-Event - Realtime: Änderungen zwischen Browsern ohne Reload sichtbar - Settings → System: Cloud-Verbindung, Studio-Switcher, weiteres Studio anlegen - Settings → Team: Mitarbeiter via Email einladen (Admin-Aktion) - Personen-Sharing: bei neuem Studio Personen aus anderen Studios übernehmen - Reload-Resume: studio_id in sessionStorage, kein erneuter Login nötig Web-Deploy - deploy/docker-compose.yml + nginx.conf: dist/ via nginx-Container, Port 8080 - .env.production.example: Build-time Cloud-URL - DEPLOY.md: Anleitung für LAN-only und extern via Nginx Proxy Manager Doku - README.md: Cloud-Variante prominent erklärt - ARCHITECTURE.md: Storage-Adapter, Migrations, neue Views in Risiko-Tabelle - DEPLOY.md: Schritt-für-Schritt für Mac Mini + NPM Version-Bump auf 0.8.0 in package.json, src-tauri/tauri.conf.json, Cargo.toml. Changelog-Entry im App.jsx-Modal (Karim sieht ihn beim ersten Start). Co-Authored-By: Claude Opus 4.7 --- .env.example | 9 + .env.production.example | 13 + .gitignore | 2 + ARCHITECTURE.md | 46 +- DEPLOY.md | 133 +++ README.md | 45 +- deploy/docker-compose.yml | 23 + deploy/nginx.conf | 30 + package-lock.json | 102 ++- package.json | 3 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- src/App.jsx | 375 ++++++--- src/storage/adapter.js | 100 +++ src/storage/migrations.js | 92 +++ src/storage/supabase-adapter.js | 484 +++++++++++ src/storage/supabase-mappers.js | 649 +++++++++++++++ src/views/BackendChoice.jsx | 113 +++ src/views/CloudSetup.jsx | 238 ++++++ src/views/Login.jsx | 242 +++++- src/views/Quotes.jsx | 21 +- src/views/ResetPassword.jsx | 88 ++ src/views/Settings.jsx | 252 +++++- supabase/.gitignore | 8 + supabase/config.toml | 415 ++++++++++ supabase/migrations/0001_initial.sql | 761 ++++++++++++++++++ supabase/migrations/0002_storage.sql | 73 ++ supabase/migrations/0003_seed_defaults.sql | 116 +++ supabase/migrations/0004_realtime.sql | 39 + supabase/migrations/0005_signup_functions.sql | 65 ++ supabase/migrations/0006_list_studios.sql | 28 + .../0007_share_persons_on_studio_create.sql | 83 ++ supabase/migrations/0008_load_persons.sql | 27 + supabase/migrations/0009_invite_member.sql | 61 ++ .../0010_studio_name_into_settings.sql | 79 ++ 35 files changed, 4668 insertions(+), 151 deletions(-) create mode 100644 .env.example create mode 100644 .env.production.example create mode 100644 DEPLOY.md create mode 100644 deploy/docker-compose.yml create mode 100644 deploy/nginx.conf create mode 100644 src/storage/adapter.js create mode 100644 src/storage/migrations.js create mode 100644 src/storage/supabase-adapter.js create mode 100644 src/storage/supabase-mappers.js create mode 100644 src/views/BackendChoice.jsx create mode 100644 src/views/CloudSetup.jsx create mode 100644 src/views/ResetPassword.jsx create mode 100644 supabase/.gitignore create mode 100644 supabase/config.toml create mode 100644 supabase/migrations/0001_initial.sql create mode 100644 supabase/migrations/0002_storage.sql create mode 100644 supabase/migrations/0003_seed_defaults.sql create mode 100644 supabase/migrations/0004_realtime.sql create mode 100644 supabase/migrations/0005_signup_functions.sql create mode 100644 supabase/migrations/0006_list_studios.sql create mode 100644 supabase/migrations/0007_share_persons_on_studio_create.sql create mode 100644 supabase/migrations/0008_load_persons.sql create mode 100644 supabase/migrations/0009_invite_member.sql create mode 100644 supabase/migrations/0010_studio_name_into_settings.sql diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0b4a108 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# Kopie nach .env.local und Werte einsetzen. +# .env.local ist via .gitignore ausgeschlossen — niemals committen. +# +# Lokales Supabase (OrbStack via `supabase start`): +# URL ist immer http://127.0.0.1:54321, Anon-Key kommt aus `supabase status` +# Mac-Mini im LAN: URL = http://:54321 oder Tailscale-IP + +VITE_SUPABASE_URL=http://127.0.0.1:54321 +VITE_SUPABASE_ANON_KEY= diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..708759b --- /dev/null +++ b/.env.production.example @@ -0,0 +1,13 @@ +# Kopiere dies nach .env.production, wenn die App auf dem Mac Mini deployed wird. +# Vite ersetzt VITE_*-Variablen beim `npm run build` direkt im Bundle. +# +# Beispiel-Werte für ein Mac-Mini-Setup im LAN: +# VITE_SUPABASE_URL=http://mac-mini.local:54321 +# +# Bei extern erreichbarer Instanz (über Nginx Proxy Manager): +# VITE_SUPABASE_URL=https://api.rapport.kgva.ch +# +# Der Anon-Key kommt aus `supabase status` auf dem Mac Mini. + +VITE_SUPABASE_URL=http://mac-mini.local:54321 +VITE_SUPABASE_ANON_KEY= diff --git a/.gitignore b/.gitignore index 4b34a86..d5411f7 100755 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ node_modules dist dist-ssr *.local +.env.production +.env.development # Claude Code .claude/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a907c0d..fe531f2 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -22,6 +22,10 @@ APP/ │ ├── App.jsx Root: State, Navigation, Auth, Migrationen, Sidebar, Modals [823 Z.] │ ├── constants.js STORAGE_KEY, NAV_ITEMS, defaultData, SIA-Phasen, Statusfarben [252 Z.] │ ├── utils.js Business-Logik: Kalkulation, Format, QR-Bill, Hash, CSV, Lohn [678 Z.] +│ ├── storage/adapter.js Storage-Adapter (Promise-API): LocalStorageAdapter + SupabaseAdapter, Modus-Auswahl +│ ├── storage/supabase-adapter.js Cloud-Implementation: load/save/realtime/signIn/signUp/invite/reset +│ ├── storage/supabase-mappers.js fromDB / toDB für ~22 Entities (camelCase ↔ snake_case, JSONB-Spread) +│ ├── storage/migrations.js `applyMigrations(parsed, defaultData)` — Schema-Migrations als reine Funktion │ ├── views/ 31 Top-Level-Screens, lazy-geladen │ ├── components/ UI.jsx (StatusBadge, Modal, FormField, …), UpdateNotifier, UpdatesSupport │ ├── print/ PrintComponents.jsx — alle Druckansichten (Rechnung, QR, Brief, …) @@ -30,6 +34,27 @@ APP/ │ ├── App.css, index.css Globale CSS-Reset + Variablen │ └── index.jsx │ +├── supabase/ Cloud-Schema (Postgres + Supabase Auth + Storage + Realtime) +│ ├── config.toml Lokale Supabase-Konfiguration (site_url, redirects) +│ └── migrations/ 10 SQL-Migrations: +│ 0001 initial schema (29 Tabellen, RLS, Audit-Trigger) +│ 0002 storage buckets (receipts, logos) +│ 0003 seed defaults pro neuem Studio +│ 0004 realtime publication +│ 0005 signup RPCs (ensure_profile, create_studio_with_admin) +│ 0006 list_studios RPC (für Login-Dropdown) +│ 0007 persons sharing across studios +│ 0008 load_persons RPC (lokale + geteilte) +│ 0009 attach_user_to_studio (Mitarbeiter einladen) +│ 0010 studio name + setup_completed in settings +│ +├── deploy/ Static-Hosting der Web-App via nginx-Container +│ ├── docker-compose.yml nginx:alpine, mountet ../dist +│ └── nginx.conf SPA-Routing (try_files $uri /index.html) +│ +├── .env.production.example Template für VITE_SUPABASE_URL / ANON_KEY +├── DEPLOY.md Deploy-Anleitung (LAN-only + extern via NPM) +│ ├── src-tauri/ Rust-Backend (Tauri 2.10.3) │ ├── src/main.rs 6 Zeilen — delegiert zu app_lib::run() │ ├── src/lib.rs 103 Zeilen — Tray, Window-Events, Plugins @@ -67,12 +92,22 @@ View ruft eine der zwei Props auf: │ ▼ setData(newData) - localStorage.setItem("studio_data_v1", JSON.stringify(newData)) + storage.save(newData) ← Storage-Adapter │ ▼ React re-rendert die Hierarchie ``` +**Storage-Adapter** ([src/storage/adapter.js](src/storage/adapter.js)) ist die einzige Stelle, die `studio_data_v1` schreibt. Zwei Implementationen mit identischem Promise-Interface: +- `LocalStorageAdapter` — Browser-localStorage, sync wrapped in Promises +- `SupabaseAdapter` ([supabase-adapter.js](src/storage/supabase-adapter.js)) — Cloud via Postgres + REST + Realtime; pro `data.*`-Array eine eigene Tabelle (Mappers in [supabase-mappers.js](src/storage/supabase-mappers.js)) + +Auswahl-Logik beim Modul-Load: `localStorage.rapport_backend` entscheidet (`"local"` | `"cloud"`). Bei Production-Build mit gesetztem `VITE_SUPABASE_URL` wird automatisch Cloud (für Web-Deploy). Schnittstelle: `hasExistingData()`, `load()`, `save(data)`, `clear()`. SupabaseAdapter zusätzlich: `signIn/signUp/signOut`, `setStudioId`, `myStudios/listStudios`, `createStudio`, `inviteMember`, `requestPasswordReset`, `subscribeToChanges`. App.jsx zeigt Boot-Spinner ([ViewFallback](src/App.jsx)) bis Initial-Load durch ist. + +**Per-Device-UI-State** (Dark Mode, Zoom, Sidebar-Collapse, Changelog-Seen, `rapport_backend`/`rapport_cloud_url`) bleibt außerhalb des Adapters in direktem `localStorage` — kommt nicht in die Cloud. + +**Schema-Migrations** ([src/storage/migrations.js](src/storage/migrations.js)) sind als reine Funktion `applyMigrations(parsed, defaultData)` extrahiert, damit derselbe Code auf Local- und Cloud-geladenen Daten läuft. Beim Initial-Load in App.jsx wird das Ergebnis vom Adapter durch diese Funktion gepiped, bevor es in `useState` landet. + **Wichtig:** - `save()` schreibt **synchron** bei jedem Update — kein Debouncing. Bei großen `data`-Objekten kann das spürbar werden. - Es gibt **kein Backup, kein Conflict-Resolution**. Wenn der User zwei App-Fenster hat (passiert kaum, weil Single-Window), überschreibt das letzte gewinnt. @@ -381,6 +416,15 @@ npm run lint # ESLint (manuell — kein Pre-Commit-Hook) | Views (Invoices/Projects/Time) | **Hoch** | Lange Files mit Edge-Cases (Mahnung, Akonto, Drag&Drop) | | `Settings.jsx` Permissions | **Hoch** | Tangiert Rollen/Berechtigungen, Dashboard-Templates | | `Login.jsx` Hash-Logik | **Hoch** | PBKDF2 + Migration, sicherheitsrelevant | +| `storage/adapter.js` | **Hoch** | Einzige Schreibstelle für `studio_data_v1`. API ist async (Promise) — Direkt-Zugriffe auf `localStorage[STORAGE_KEY]` in Views sind verboten | +| `storage/migrations.js` | **Hoch** | Schema-Migrations. Jede Änderung am `defaultData`-Shape erfordert hier eine Migration-Step + Test mit alten Snapshots | +| `storage/supabase-adapter.js` | **Hoch** | Cloud-Read/Write, Auth, Realtime. Bei neuer Tabelle: load() + save() + Mapper anpassen + Migration anlegen + Realtime-Publication ergänzen | +| `storage/supabase-mappers.js` | **Hoch** | fromDB ↔ toDB pro Entity. Snake/Camel-Konvention; JSONB-Spread bei settings; isShared-Flag für geteilte Personen | +| `supabase/migrations/` | **Hoch** | Cloud-Schema (29 Tabellen + RPCs). Via `supabase db reset` lokal anwendbar. Schema-Änderungen brauchen neue Migration-Datei, kein Bearbeiten bestehender | +| `views/CloudSetup.jsx` | **Mittel** | Erst-Einrichtung der Cloud (3 Schritte). Ruft `cloudInit` in App.jsx | +| `views/BackendChoice.jsx` | **Niedrig** | Modus-Wahl Lokal/Cloud bei frischer Installation | +| `views/ResetPassword.jsx` | **Mittel** | Passwort-Reset nach Mail-Link-Klick. Hängt am `PASSWORD_RECOVERY`-Event | +| `deploy/` + `DEPLOY.md` | **Niedrig** | Nginx-Container + Anleitung. Reine Doku/Hosting-Files | | `lib.rs` Tray/Window | **Mittel** | Wenn Nav-IDs geändert werden, müssen Frontend + Rust synchron bleiben | | `tauri.conf.json` Updater | **Sehr hoch** | Public Key ändern bricht alle bestehenden Installationen | | `release.sh` | **Sehr hoch** | Falsche Änderung → defekte Updates beim User | diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..47d647a --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,133 @@ +# Rapport-Web-App Deploy + +> Wer kein Tauri installieren will, kann Rapport im Browser nutzen. Diese +> Anleitung beschreibt, wie du die Web-Version auf dem Mac Mini hostest. + +--- + +## Option 2 — Im LAN erreichbar (ohne SSL) + +Ziel: jedes Gerät im Studio-Netz kann `http://:8080` öffnen. + +### Voraussetzungen + +- Mac Mini hat OrbStack/Docker und Supabase wie in [README.md](README.md) eingerichtet. +- `supabase start` läuft (Supabase auf Port 54321). +- Der Mac Mini ist im LAN erreichbar (`mac-mini.local` per Bonjour oder feste IP). + +### Schritt-für-Schritt + +1. **Repo auf den Mac Mini kopieren** (falls noch nicht da). + +2. **`.env.production` anlegen** (im Repo-Root): + + ```bash + cp .env.production.example .env.production + ``` + + Dann den Anon-Key aus `supabase status` einsetzen und `VITE_SUPABASE_URL` + auf die LAN-Adresse stellen, z.B. `http://mac-mini.local:54321`. + +3. **Build:** + + ```bash + npm install + npm run build + ``` + + Erzeugt `dist/` mit statischen Files (~500 kB). + +4. **Static-Container starten:** + + ```bash + docker compose -f deploy/docker-compose.yml up -d + ``` + + Liefert `dist/` auf Port `8080` aus. + +5. **Test:** anderes Gerät im LAN öffnet `http://:8080` + im Browser. Login-Screen erscheint, Cloud-Modus ist bereits korrekt + konfiguriert (Server-URL ist gebakene `.env.production`). + +### Update auf neue Version + +```bash +git pull +npm run build +docker compose -f deploy/docker-compose.yml restart +``` + +--- + +## Option 3 — Auch extern erreichbar (mit SSL) + +Zusätzlich zu Option 2: Nginx Proxy Manager (NPM) auf dem Mac Mini macht +Domain-Routing und holt Let's-Encrypt-Zertifikate. + +### Voraussetzungen + +- NPM läuft bereits auf dem Mac Mini (typisch Port 81 für UI, 80/443 für + Traffic). +- DNS-Records `app.rapport.kgva.ch` und `api.rapport.kgva.ch` zeigen auf den + Mac Mini (z.B. via Tailscale Funnel, DDNS oder feste IP). + +### Schritt-für-Schritt + +1. **In NPM-UI zwei Proxy-Hosts anlegen:** + + | Domain | Forward zu | SSL | + |---|---|---| + | `app.rapport.kgva.ch` | `mac-mini-ip:8080` | Force HTTPS + Let's Encrypt | + | `api.rapport.kgva.ch` | `mac-mini-ip:54321` | Force HTTPS + Let's Encrypt | + + Beide mit «Block Common Exploits» und «Cache Assets» an. Für `api.*` + zusätzlich Websocket-Support an (Realtime braucht WS). + +2. **`.env.production` umstellen auf die externe URL:** + + ```env + VITE_SUPABASE_URL=https://api.rapport.kgva.ch + VITE_SUPABASE_ANON_KEY= + ``` + +3. **Supabase-Config anpassen** in `supabase/config.toml`: + + ```toml + [api] + external_url = "https://api.rapport.kgva.ch" + + [auth] + site_url = "https://app.rapport.kgva.ch" + additional_redirect_urls = ["https://app.rapport.kgva.ch"] + ``` + + Dann `supabase stop && supabase start`, damit GoTrue die neuen URLs nutzt. + +4. **Rebuild + restart:** + + ```bash + npm run build + docker compose -f deploy/docker-compose.yml restart + ``` + +5. **Test:** von außen (Smartphone mit Mobile-Data) `https://app.rapport.kgva.ch` + öffnen → Login funktioniert. + +--- + +## Troubleshooting + +**Other devices see "Login" without auto-filled Server-URL?** +→ `.env.production` enthält nicht den richtigen `VITE_SUPABASE_URL`, oder das +Build wurde vor der Änderung gemacht. `npm run build` neu ausführen. + +**Realtime funktioniert nicht von extern?** +→ NPM braucht «Websocket Support» an im Proxy-Host für `api.*`. + +**Auth-Mails kommen nicht an?** +→ Lokales Supabase nutzt Inbucket (Port 54324). Für Production brauchst du +einen echten SMTP-Server in `config.toml`. + +**Mac Mini wird neugestartet, Rapport-App weg?** +→ `restart: unless-stopped` im docker-compose sorgt für Auto-Start. Plus: +Supabase als LaunchAgent registrieren, damit `supabase start` beim Boot läuft. diff --git a/README.md b/README.md index 74b393f..d955aa5 100755 --- a/README.md +++ b/README.md @@ -1,6 +1,12 @@ # Rapport -Desktop-App auf Basis von **Tauri 2** und **React 19**, gebaut mit **Vite**. Daten liegen ausschliesslich lokal im Browser-`localStorage` — es gibt kein Backend. +Studio-Management für Architekturbüros. **Tauri 2** + **React 19** + **Vite**. Läuft als Desktop-App (macOS/Windows/Linux) **oder** als Web-App im Browser. + +Daten liegen wahlweise: +- **Lokal** im Browser-`localStorage` — Solo-Setup, kein Server nötig +- **Cloud** auf einer eigenen Supabase-Instanz (z.B. Mac Mini im Büro) — Multi-User mit Realtime-Sync, Multi-Studio, Mitarbeiter-Einladung + +Der Modus wird beim ersten Öffnen gewählt und kann später umgeschaltet werden. ## Voraussetzungen @@ -83,32 +89,57 @@ Zusätzlich im UI-Changelog: [`src/App.jsx`](src/App.jsx) — Konstante `CHANGEL ``` . ├── src/ Frontend (React) -│ ├── App.jsx Root-Komponente, Routing, Auth-State +│ ├── App.jsx Root-Komponente, Routing, Auth-State, Cloud-Resume │ ├── main.jsx React-Mount │ ├── constants.js Default-Daten, SIA-Phasen, Statusfarben │ ├── utils.js Hashing, Sanitizer, Formatter, Berechnungen +│ ├── storage/ Storage-Adapter (Local + Supabase) + Migrations │ ├── components/UI.jsx Wiederverwendbare UI-Bausteine -│ ├── views/ Module (Dashboard, Time, Invoices, …) +│ ├── views/ Module (Dashboard, Time, Invoices, Setup, CloudSetup, …) │ └── print/ Print-Komponenten (PDF/QR-Rechnung) ├── src-tauri/ Tauri-Wrapper (Rust) │ ├── src/ main.rs + lib.rs │ ├── capabilities/ Permission-Definitionen │ ├── icons/ App-Icons aller Plattformen │ └── tauri.conf.json Window, Bundle, CSP +├── supabase/ Cloud-Schema +│ ├── config.toml lokale Supabase-Konfiguration +│ └── migrations/ SQL-Migrations (~10 Files, multi-tenant + RLS) +├── deploy/ Static-Hosting der Web-App +│ ├── docker-compose.yml nginx-Container, liefert dist/ aus +│ └── nginx.conf SPA-Routing ├── public/ Statische Assets (favicon, icons.svg) ├── index.html Vite-Entry +├── DEPLOY.md Deploy-Anleitung (LAN + extern via NPM) └── vite.config.js ``` ## Daten & Persistenz -- Alles wird unter dem Key `rapport_data` in `localStorage` gehalten — siehe [`src/constants.js`](src/constants.js) → `STORAGE_KEY`. -- Beim ersten Start ohne Daten erscheint ein **Setup-Assistent** ([`src/views/Setup.jsx`](src/views/Setup.jsx)). Bestehende Daten ohne `setupCompleted`-Flag triggern den **Migrations-Screen** ([`src/views/MigrationScreen.jsx`](src/views/MigrationScreen.jsx)). -- Backup/Restore: **Einstellungen → Daten exportieren / importieren** — speichert/lädt den gesamten Store als JSON. +Rapport hat zwei Storage-Backends mit einheitlicher Schnittstelle in [`src/storage/adapter.js`](src/storage/adapter.js): + +### Lokal-Modus (Standard, kein Server) + +- Alles liegt unter dem Key `studio_data_v1` in `localStorage`. +- Beim ersten Start: **BackendChoice** (`Lokal / Cloud`) → bei Lokal-Wahl klassischer **Setup-Assistent** ([`src/views/Setup.jsx`](src/views/Setup.jsx)). +- Backup/Restore: **Einstellungen → Daten exportieren / importieren** als JSON. + +### Cloud-Modus (eigener Server) + +- Postgres-Schema mit ~30 Tabellen, multi-tenant via `studio_id` + Row-Level-Security (siehe [`supabase/migrations/`](supabase/migrations/)). +- **Realtime-Sync**: Änderungen im Browser A erscheinen ohne Reload in Browser B. +- **Multi-Studio**: ein Account kann mehrere Studios verwalten, Personen zwischen Studios teilen. +- **Mitarbeiter-Einladung** als Admin-Aktion in den Settings — keine Self-Registrierung. +- **Passwort-Reset** via Email (`/auth/v1/recover`). +- Cloud-Setup mit Supabase auf einem Mac Mini / Docker-Host: siehe **[DEPLOY.md](DEPLOY.md)**. + +Erst-Einrichtung einer leeren Cloud-Instanz: 3-Schritt-Wizard ([`src/views/CloudSetup.jsx`](src/views/CloudSetup.jsx)) erscheint automatisch, wenn der Browser auf eine Instanz ohne Studios trifft. ## Sicherheit -- **Passwörter**: PBKDF2-SHA-256 mit 100 000 Iterationen und zufälligem Salt; Verifikation in konstanter Zeit. Legacy-Klartext-Passwörter werden beim ersten erfolgreichen Login transparent zu Hashes migriert. +- **Lokal-Modus** Passwörter: PBKDF2-SHA-256 mit 100 000 Iterationen und zufälligem Salt; Verifikation in konstanter Zeit. Legacy-Klartext-Passwörter werden beim ersten erfolgreichen Login transparent zu Hashes migriert. +- **Cloud-Modus** Auth: Supabase Auth (bcrypt-Passwörter, JWT-Sessions), separat von der Lokal-User-Tabelle. +- **Row-Level-Security**: jede Cloud-Datentabelle hat eine Policy `is_studio_member(studio_id)` — DB-Ebene garantiert Tenant-Trennung. - **Login-Schutz**: 5 Fehlversuche → 60 s Sperre pro Tab (`sessionStorage`). - **HTML-Sanitizer**: Brieftexte werden vor Print/Render durch eine Allowlist gefiltert (`sanitizeHtml` in [`src/utils.js`](src/utils.js)) — kein `javascript:`, kein `on*`-Handler. - **Datenexport**: Legacy-Klartext-Passwörter werden beim Export gestrippt; nur Hashes verlassen die App. diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..cebbc6b --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,23 @@ +# Static-Hosting für die Rapport-Web-App. +# +# Liefert das `dist/`-Verzeichnis (npm run build) über nginx aus. +# Läuft parallel zur lokalen Supabase-Instanz auf demselben Mac Mini. +# +# Start: docker compose -f deploy/docker-compose.yml up -d +# Stop: docker compose -f deploy/docker-compose.yml down +# Update: npm run build → docker compose -f deploy/docker-compose.yml restart +# +# Erreichbarkeit: +# - LAN: http://:8080 oder http://:8080 +# - Extern: über Nginx Proxy Manager mit Reverse-Proxy auf Port 8080 + +services: + rapport-app: + image: nginx:alpine + container_name: rapport-app + restart: unless-stopped + ports: + - "8080:80" + volumes: + - ../dist:/usr/share/nginx/html:ro + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100644 index 0000000..1dc17e2 --- /dev/null +++ b/deploy/nginx.conf @@ -0,0 +1,30 @@ +# nginx-Config für die Rapport-Web-App. +# +# Eine SPA hat nur eine echte HTML-Datei (index.html); alle "Routen" werden +# vom React-Frontend gerendert. Daher: für jeden unbekannten Pfad das index.html +# ausliefern, sonst geben Reload/Direct-Links einen 404. + +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # Statische Assets mit langem Cache (Vite hängt Hashes an Dateinamen) + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + # SPA-Fallback: alle anderen Pfade → index.html (kein Cache) + location / { + add_header Cache-Control "no-cache, no-store, must-revalidate"; + try_files $uri /index.html; + } + + # Sicherheitsheader + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; +} diff --git a/package-lock.json b/package-lock.json index 0d65586..769e97f 100755 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,14 @@ { "name": "rapport", - "version": "0.6.0", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rapport", - "version": "0.6.0", + "version": "0.7.0", "dependencies": { + "@supabase/supabase-js": "^2.106.1", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-updater": "^2.10.1", "react": "^19.2.5", @@ -857,6 +858,90 @@ "dev": true, "license": "MIT" }, + "node_modules/@supabase/auth-js": { + "version": "2.106.1", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.106.1.tgz", + "integrity": "sha512-7eyheXfAGwkB9bZewJPs+N3UYt6kra2JG6mIxNEgbkvcO15PLD1e75PTIUEYYl3zrifm3GrpShVl7QZxKrXO/w==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.106.1", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.106.1.tgz", + "integrity": "sha512-XbOPnR2mW7jp/EcW447xmGwCa+/Wc00Hkw8t4tUIJjRsHQ4xAESsLKcyLRhRJjJoUnJVXUlC+w0wUxUCM7CG2A==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/phoenix": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.2.tgz", + "integrity": "sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==", + "license": "MIT" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.106.1", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.106.1.tgz", + "integrity": "sha512-Qbn6d2lqiqeaBX1Uko0e/hL90dtQGRN6CG2wMVQtJpRFstlVW45qmUTyTOsiB8dYUWu1fWYo4YzJuDbokGv3tQ==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.106.1", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.106.1.tgz", + "integrity": "sha512-eQCYri5E8KsjpDgC7g28cOOS2britjUWdNSJluFMainqrMRepzjOnaxqXc3RoAz7H0dxmBrfLUNF6NGP8C+YaA==", + "license": "MIT", + "dependencies": { + "@supabase/phoenix": "^0.4.2", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.106.1", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.106.1.tgz", + "integrity": "sha512-HWcLIhqinhWKpOQ3WzglR2unjW0eh9J7yOu3IZrZNIEkraK4La/HDvTqndljGsNw0itPtyHhuKBxRoPG1VUARw==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.106.1", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.106.1.tgz", + "integrity": "sha512-gP4HurGkGu7Z3xoOCjtAI17BKKp7jpsmwY0Ssbsks9XQRzJ7ZhK7LxfLdBSYgUdgZCQgjRK+Mr7+cl4Gxrk0Rw==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.106.1", + "@supabase/functions-js": "2.106.1", + "@supabase/postgrest-js": "2.106.1", + "@supabase/realtime-js": "2.106.1", + "@supabase/storage-js": "2.106.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@tauri-apps/api": { "version": "2.10.1", "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz", @@ -1760,6 +1845,15 @@ "hermes-estree": "0.25.1" } }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2544,9 +2638,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", diff --git a/package.json b/package.json index 55206c0..443e920 100755 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "rapport", "private": true, - "version": "0.7.0", + "version": "0.8.0", "type": "module", "scripts": { "dev": "vite", @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@supabase/supabase-js": "^2.106.1", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-updater": "^2.10.1", "react": "^19.2.5", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index bbeb0ff..f15234c 100755 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rapport" -version = "0.7.0" +version = "0.8.0" description = "Rapport — Studio-Management für Architekturbüros" authors = ["Karim Gabriele Varano "] license = "AGPL-3.0-or-later" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 31d9fde..1d26184 100755 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "RAPPORT PRE-RELEASE", - "version": "0.7.0", + "version": "0.8.0", "identifier": "com.karimgabrielevarano.rapport", "build": { "frontendDist": "../dist", diff --git a/src/App.jsx b/src/App.jsx index 6c753b2..9d3193e 100755 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,8 +1,13 @@ import React, { useState, useEffect, useCallback, useRef, Suspense, lazy } from "react"; -import { STORAGE_KEY, NAV_ITEMS, defaultData } from "./constants.js"; -import { migrateDashboardLayout, verifyPassword, withHashedPassword, stripCredentials } from "./utils.js"; +import { NAV_ITEMS, defaultData } from "./constants.js"; +import { verifyPassword, withHashedPassword, stripCredentials } from "./utils.js"; +import { storage, isCloudBackend } from "./storage/adapter.js"; +import { applyMigrations } from "./storage/migrations.js"; import Login from "./views/Login.jsx"; import Setup from "./views/Setup.jsx"; +import BackendChoice from "./views/BackendChoice.jsx"; +import CloudSetup from "./views/CloudSetup.jsx"; +import ResetPassword from "./views/ResetPassword.jsx"; import MigrationScreen from "./views/MigrationScreen.jsx"; import UpdateNotifier from "./components/UpdateNotifier.jsx"; @@ -46,88 +51,63 @@ function ViewFallback() { } export default function App() { - const [data, setData] = useState(() => { - try { - const stored = localStorage.getItem(STORAGE_KEY); - if (stored) { - const parsed = JSON.parse(stored); - let merged = { ...defaultData, ...parsed, settings: { ...defaultData.settings, ...parsed.settings } }; - - // Migrate: clients[] + contacts[] → persons[] - if (!merged.persons && (merged.clients?.length || merged.contacts?.length)) { - const idMap = {}; - const persons = []; - const usedContactIds = new Set(); - for (const c of merged.clients || []) { - const linked = (merged.contacts || []).find(ct => ct.id === c.linkedContactId); - persons.push({ - ...c, - isAuftraggeber: true, - isPartner: !!linked, - type: c.type || linked?.type || "", - note: c.note || linked?.note || "", - honorarOffers: c.honorarOffers || linked?.honorarOffers || [], - contacts: c.contacts?.length ? c.contacts : (linked?.contacts || []), - linkedContactId: undefined, - linkedClientId: undefined, - }); - if (linked) { usedContactIds.add(linked.id); idMap[linked.id] = c.id; } - } - for (const ct of merged.contacts || []) { - if (usedContactIds.has(ct.id)) continue; - persons.push({ ...ct, isAuftraggeber: false, isPartner: true, linkedClientId: undefined }); - } - const remapProjects = (merged.projects || []).map(p => ({ - ...p, - projectContacts: (p.projectContacts || []).map(pc => ({ ...pc, contactId: idMap[pc.contactId] || pc.contactId })), - })); - const remapProtocols = (merged.protocols || []).map(p => ({ - ...p, - entries: (p.entries || []).map(e => ({ ...e, assignee: e.assignee ? (idMap[e.assignee] || e.assignee) : e.assignee })), - })); - merged = { ...merged, persons, projects: remapProjects, protocols: remapProtocols, clients: undefined, contacts: undefined }; - } - - // Migrate: projects linked to SIA/manual quotes should be pauschal (not stundensatz) - const allQuotes = merged.quotes || []; - const projects = (merged.projects || []).map(p => { - if ((p.billingType || p.type || "stundensatz") === "stundensatz" && (p.linkedQuotes || []).length > 0) { - const linkedQs = (p.linkedQuotes || []).map(lq => allQuotes.find(q => q.id === lq.quoteId)).filter(Boolean); - if (linkedQs.some(q => q.mode === "sia" || q.mode === "manual")) { - return { ...p, billingType: "pauschal", budget: p.budget || p.budgetAmount || 0 }; + // Initial-Load läuft asynchron, damit derselbe Pfad später Cloud-Backends bedienen kann. + // `data` wird mit defaultData initialisiert (statt null), damit alle synchronen Reads + // wie `data.appRoles` während des Initial-Renders nicht crashen. `loading` zeigt den + // Boot-Spinner, bis der echte Snapshot da ist (LocalStorage <50ms, Cloud ggf. länger). + const [data, setData] = useState(defaultData); + const [loading, setLoading] = useState(true); + const [isNewInstall, setIsNewInstall] = useState(false); + // Cloud-spezifisch: Liste der Studios auf der Instanz (für Erst-Setup-Check). + // null = noch nicht geladen; Array = geladen. + const [cloudStudios, setCloudStudios] = useState(null); + // Passwort-Reset-Flow: Supabase löst beim Klick auf Reset-Link in der Mail + // ein PASSWORD_RECOVERY-Event aus → wir zeigen dann das Reset-Formular. + const [passwordRecovery, setPasswordRecovery] = useState(false); + useEffect(() => { + let cancelled = false; + (async () => { + if (isCloudBackend) { + // Cloud-Reload-Fall: wenn sowohl Session (sessionStorage.rapport_user) + // als auch das gewählte Studio (rapport_studio_id) noch vorhanden sind, + // stellen wir den Adapter wieder her und laden direkt — ohne dass der + // User sich neu einloggen muss. + const sessionUser = (() => { + try { return JSON.parse(sessionStorage.getItem("rapport_user")); } catch { return null; } + })(); + const savedStudioId = sessionStorage.getItem("rapport_studio_id"); + if (sessionUser && savedStudioId) { + storage.setStudioId(savedStudioId); + try { + const parsed = await storage.load(); + if (!cancelled && parsed) { + setData(applyMigrations(parsed, defaultData)); } + } catch (e) { + console.error("Cloud-Resume load failed:", e); } - return p; - }); - // Migrate: add r-projektleiter if missing, seed dashboardTemplateId from defaultData - const roleDefMap = (defaultData.appRoles || []).reduce((acc, r) => { acc[r.id] = r; return acc; }, {}); - const roles = (merged.appRoles || defaultData.appRoles).map(r => ({ - ...r, - dashboardTemplateId: r.dashboardTemplateId || roleDefMap[r.id]?.dashboardTemplateId || null, - permissions: (() => { - let perms = r.permissions; - if (perms && r.id === "r-projektleiter" && !perms.includes("mitarbeiter")) perms = [...perms, "mitarbeiter"]; - if (perms && !perms.includes("settings")) perms = [...perms, "settings"]; - return perms; - })(), - })); - if (!roles.find(r => r.id === "r-projektleiter") && roleDefMap["r-projektleiter"]) { - const adminIdx = roles.findIndex(r => r.id === "r-admin"); - roles.splice(adminIdx + 1, 0, roleDefMap["r-projektleiter"]); } - // Migrate user-level dashboardWidgets to Row[] format - const users = (merged.users || []).map(u => ({ - ...u, - dashboardWidgets: u.dashboardWidgets ? migrateDashboardLayout(u.dashboardWidgets) : undefined, - })); - // Ensure dashboardTemplates exist (old data won't have them) - const dashboardTemplates = merged.dashboardTemplates?.length ? merged.dashboardTemplates : defaultData.dashboardTemplates; - return { ...merged, projects, appRoles: roles, users, dashboardTemplates }; + // Studios der Instanz holen — entscheidet später, ob CloudSetup oder Login zeigt + try { + const list = await storage.listStudios?.(); + if (!cancelled) setCloudStudios(list || []); + } catch (e) { + console.error("listStudios failed:", e); + if (!cancelled) setCloudStudios([]); + } + if (!cancelled) setLoading(false); + return; } - } catch {} - return defaultData; - }); - const [isNewInstall] = useState(() => !localStorage.getItem(STORAGE_KEY)); + const hasData = await storage.hasExistingData(); + if (cancelled) return; + setIsNewInstall(!hasData); + const parsed = await storage.load(); + if (cancelled) return; + setData(parsed ? applyMigrations(parsed, defaultData) : defaultData); + setLoading(false); + })(); + return () => { cancelled = true; }; + }, []); const [currentUser, setCurrentUser] = useState(() => { try { return JSON.parse(sessionStorage.getItem("rapport_user")) || null; } catch { return null; } }); @@ -137,11 +117,11 @@ export default function App() { setCurrentUser(safe); }; // Used by the Login screen — never exposes the user list (with passwords) to the view. - // Async because PBKDF2 hashing happens off the main event loop via WebCrypto. - // Legacy plaintext passwords are accepted ONCE and transparently upgraded to - // PBKDF2 hashes on first successful login. - const verifyLogin = async (username, password) => { - const u = (data.users || []).find(x => x.username === username); + // Routes by backend: cloud → Supabase Auth, local → PBKDF2 gegen data.users. + const verifyLogin = async (usernameOrEmail, password, opts = {}) => { + if (isCloudBackend) return cloudSignIn(usernameOrEmail, password, opts.studioId); + + const u = (data.users || []).find(x => x.username === usernameOrEmail); if (!u) return null; const ok = await verifyPassword(password, u); if (!ok) return null; @@ -153,14 +133,100 @@ export default function App() { return upgraded; } catch (e) { console.error("Passwort-Migration fehlgeschlagen:", e); - // fall through — still let the user in with legacy plaintext } } handleLogin(u); return u; }; + + // Cloud-Erst-Einrichtung: einmaliger Bootstrap-Pfad, wenn die Cloud-Instanz + // noch leer ist (0 Studios). Im laufenden Betrieb gibt es keinen Self-Signup + // — Mitarbeiter werden via Admin-Aktion eingeladen. + // `extraSettings` enthält optionale Stammdaten (Adresse, IBAN, MwSt, …), + // die nach createStudio in die studio_settings geschrieben werden. + const cloudInit = async (email, password, displayName, studioName, extraSettings = {}) => { + try { + const signUpRes = await storage.signUp(email, password); + if (!signUpRes.ok) return { ok: false, error: signUpRes.error }; + + const username = (email.split("@")[0] || "user").replace(/[^a-zA-Z0-9._-]/g, ""); + await storage.ensureProfile(username, displayName); + + const baseSlug = studioName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40); + const slug = `${baseSlug || "studio"}-${Date.now().toString(36)}`; + const studioId = await storage.createStudio(studioName, slug); + + storage.setStudioId(studioId); + sessionStorage.setItem("rapport_studio_id", studioId); + + const parsed = await storage.load(); + let merged = applyMigrations(parsed, defaultData); + + // Optionale Stammdaten anwenden + sofort speichern, damit sie persistiert + // sind, bevor der User irgendwo durchklickt (sonst geht beim nächsten + // Reload Adresse/IBAN verloren). + if (Object.keys(extraSettings).length > 0) { + merged = { ...merged, settings: { ...merged.settings, ...extraSettings } }; + await storage.save(merged); + } + setData(merged); + + handleLogin({ + id: signUpRes.user.id, + username, + displayName, + role: "admin", + appRoleId: "r-admin", + }); + return { ok: true }; + } catch (e) { + console.error("Cloud init fehlgeschlagen:", e); + return { ok: false, error: e?.message || "Unbekannter Fehler" }; + } + }; + + // Cloud-Login: signIn → Studio wählen (Dropdown im Login oder erstes) → data laden. + // `preferredStudioId` kommt aus dem Login-Dropdown wenn Multi-Studio-Instanz. + const cloudSignIn = async (email, password, preferredStudioId = null) => { + try { + const result = await storage.signIn(email, password); + if (!result) return null; + if (result.studios.length === 0) { + console.warn("Cloud-Login OK, aber User ist in keinem Studio Mitglied."); + return null; + } + // Wenn ein Studio vorgewählt wurde, prüfen ob der User dort Member ist + const membership = preferredStudioId + ? result.studios.find(m => m.studio_id === preferredStudioId) + : result.studios[0]; + if (!membership) { + console.warn("User ist im gewählten Studio nicht Mitglied."); + return null; + } + storage.setStudioId(membership.studio_id); + sessionStorage.setItem("rapport_studio_id", membership.studio_id); + const parsed = await storage.load(); + setData(applyMigrations(parsed, defaultData)); + const user = { + id: result.user.id, + username: result.profile?.username || result.user.email, + displayName: result.profile?.display_name || result.user.email, + role: membership.app_role_id === "r-admin" ? "admin" : "user", + appRoleId: membership.app_role_id, + }; + handleLogin(user); + return user; + } catch (e) { + console.error("Cloud signIn fehlgeschlagen:", e); + return null; + } + }; const handleLogout = () => { sessionStorage.removeItem("rapport_user"); + sessionStorage.removeItem("rapport_studio_id"); + if (isCloudBackend) { + storage.signOut?.().catch(() => {}); + } setCurrentUser(null); }; const handleSetupComplete = (newData) => { @@ -231,8 +297,8 @@ export default function App() { const [modal, setModal] = useState(null); const [printContent, setPrintContent] = useState(null); const [darkMode, setDarkMode] = useState(() => localStorage.getItem("rapport_dark") === "1"); - const [showChangelog, setShowChangelog] = useState(() => localStorage.getItem("rapport_changelog_seen") !== "0.7"); - const [changelogVersion, setChangelogVersion] = useState("0.7"); + const [showChangelog, setShowChangelog] = useState(() => localStorage.getItem("rapport_changelog_seen") !== "0.8"); + const [changelogVersion, setChangelogVersion] = useState("0.8"); const [showAbout, setShowAbout] = useState(false); const [navOpen, setNavOpen] = useState(false); const [expandedNav, setExpandedNav] = useState(new Set(["buchhaltung"])); @@ -304,16 +370,75 @@ export default function App() { const save = useCallback((newData) => { setData(newData); - try { localStorage.setItem(STORAGE_KEY, JSON.stringify(newData)); } catch {} + storage.save(newData).catch(e => console.error("Storage save failed:", e)); }, []); const update = useCallback((key, value) => { save({ ...data, [key]: value }); }, [data, save]); + // Cloud: Passwort-Reset-Event abfangen — der Klick auf den Mail-Link führt + // zurück zur App mit hash-Token. Zwei Pfade: + // 1. URL-Hash beim Mount checken (Supabase JS parsed schon vor useEffect) + // 2. onAuthStateChange als Fallback + useEffect(() => { + if (!isCloudBackend || !storage.client) return; + if (typeof window !== "undefined" && window.location.hash.includes("type=recovery")) { + setPasswordRecovery(true); + } + const { data: sub } = storage.client.auth.onAuthStateChange((event) => { + if (event === "PASSWORD_RECOVERY") setPasswordRecovery(true); + }); + return () => { sub?.subscription?.unsubscribe?.(); }; + }, []); + + // Cloud: Realtime-Subscription + Refresh bei Tab-Focus / Visibility-Change. + // Damit sieht User A live, wenn User B im anderen Browser was ändert — und + // wenn der Tab im Hintergrund war, holen wir beim Zurückkommen den aktuellen + // Stand. Refresh ist debounced (500ms), damit Batch-Inserts nicht 25 Loads + // hintereinander triggern. + useEffect(() => { + if (!isCloudBackend || !currentUser) return; + let cancelled = false; + let timer = null; + + const refresh = async () => { + try { + const parsed = await storage.load(); + if (!cancelled && parsed) { + setData(applyMigrations(parsed, defaultData)); + } + } catch (e) { + console.error("Cloud refresh failed:", e); + } + }; + + const scheduleRefresh = () => { + if (timer) clearTimeout(timer); + timer = setTimeout(() => { timer = null; refresh(); }, 500); + }; + + storage.subscribeToChanges?.(scheduleRefresh); + + const onVisibility = () => { + if (document.visibilityState === "visible") scheduleRefresh(); + }; + document.addEventListener("visibilitychange", onVisibility); + window.addEventListener("focus", scheduleRefresh); + + return () => { + cancelled = true; + if (timer) clearTimeout(timer); + storage.unsubscribeFromChanges?.(); + document.removeEventListener("visibilitychange", onVisibility); + window.removeEventListener("focus", scheduleRefresh); + }; + }, [currentUser]); + // Auto-überfällig: einmal pro Tag prüfen (verhindert Endlos-Loop, da save() data ändert). const lastOverdueCheck = useRef(null); useEffect(() => { + if (loading) return; const today = new Date().toISOString().slice(0, 10); if (lastOverdueCheck.current === today) return; lastOverdueCheck.current = today; @@ -323,18 +448,57 @@ export default function App() { ); if (updated.some((inv, i) => inv.status !== data.invoices[i].status)) save({ ...data, invoices: updated }); - }, [data, save]); + }, [data, save, loading]); - if (isNewInstall && !data.settings.setupCompleted) { + // Boot-Spinner während Initial-Load (Adapter ist async, auch wenn LocalStorage <50ms) + if (loading) return ; + + // Erst-Screen einer frischen Installation: «Lokal oder Cloud?». + // Sichtbar solange der User die Wahl noch nicht getroffen hat UND es keine + // lokalen Daten gibt. Sobald er gewählt hat, übernimmt der jeweilige Wizard. + const hasChosenBackend = localStorage.getItem("rapport_backend_chosen") === "1"; + if (!hasChosenBackend && isNewInstall && !data.settings.setupCompleted && !currentUser) { + return ; + } + + // Setup- und Migrations-Screens sind LocalStorage-Spezifika. Im Cloud-Modus + // erfolgt Erst-Einrichtung über den Init-Dialog im Login. + if (!isCloudBackend && isNewInstall && !data.settings.setupCompleted) { return ; } - if (!localStorage.getItem("rapport_v0_5_migrated")) { + if (!isCloudBackend && !localStorage.getItem("rapport_v0_5_migrated")) { return ; } + // Passwort-Reset hat höchste Priorität — User kommt von Mail-Link + if (passwordRecovery) { + return { + try { + const { error } = await storage.client.auth.updateUser({ password: newPw }); + if (error) return { ok: false, error: error.message }; + return { ok: true }; + } catch (e) { + return { ok: false, error: e.message || "Fehler" }; + } + }} + onCancel={async () => { + await storage.client?.auth?.signOut?.(); + setPasswordRecovery(false); + if (window.location.hash) window.location.hash = ""; + }} + />; + } + + // Cloud + Instanz ist leer (0 Studios) → mehrseitiger Setup-Wizard. + // Cloud + Studios vorhanden → klassischer Login. if (!currentUser) { - return ; + if (isCloudBackend && cloudStudios !== null && cloudStudios.length === 0) { + const cloudUrl = localStorage.getItem("rapport_cloud_url") || ""; + return ; + } + return ; } if (printContent) { @@ -607,8 +771,8 @@ export default function App() {
- +
} @@ -652,7 +816,7 @@ export default function App() { {view === "projects" && !selectedProjectId && } {view === "projects" && selectedProjectId && setSelectedProjectId(null)} setPrintContent={setPrintContent} modal={modal} setModal={setModal} currentUser={currentUser} />} {view === "time" &&