Release 0.8.0: Cloud-Variante (Supabase, Multi-Studio, Realtime, Web-Deploy)

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 <noreply@anthropic.com>
This commit is contained in:
2026-05-23 19:08:00 +02:00
parent c71feddf63
commit 27b1057cd4
35 changed files with 4668 additions and 151 deletions
+88
View File
@@ -0,0 +1,88 @@
import React, { useState } from "react";
// Empfangsseite des Passwort-Reset-Links. App.jsx zeigt diese Komponente,
// sobald Supabase den `PASSWORD_RECOVERY`-Event sendet. Der Reset-Token ist
// dann bereits im Auth-Client geparsed; wir setzen nur noch das neue Passwort.
export default function ResetPassword({ onComplete, onCancel }) {
const [pw1, setPw1] = useState("");
const [pw2, setPw2] = useState("");
const [err, setErr] = useState("");
const [busy, setBusy] = useState(false);
const [done, setDone] = useState(false);
const submit = async () => {
if (pw1.length < 6) { setErr("Mindestens 6 Zeichen."); return; }
if (pw1 !== pw2) { setErr("Passwörter stimmen nicht überein."); return; }
setBusy(true); setErr("");
try {
const res = await onComplete(pw1);
if (res?.ok) setDone(true);
else setErr(res?.error || "Konnte nicht gespeichert werden.");
} finally { setBusy(false); }
};
return (
<div style={{
minHeight: "100vh", minWidth: "100vw",
background: "#ebe7e1",
display: "flex", alignItems: "center", justifyContent: "center",
fontFamily: "'DM Mono', 'Courier New', monospace",
position: "fixed", inset: 0, zIndex: 9999,
}}>
<div style={{
background: "#fdfcfa", borderRadius: 20, padding: "48px 44px 32px",
width: "100%", maxWidth: 400, margin: "0 20px",
boxShadow: "0 8px 40px rgba(0,0,0,0.10), 0 2px 8px rgba(0,0,0,0.07)",
border: "1px solid #ddd8d0",
}}>
<div style={{ textAlign: "center", marginBottom: 30 }}>
<div style={{ fontFamily: "Krungthep, 'Archivo Black', sans-serif", fontSize: 32, color: "#1a1a18", letterSpacing: "-0.02em", lineHeight: 1 }}>RAPPORT</div>
<div style={{ fontSize: 9, color: "#b0aca4", letterSpacing: "0.2em", marginTop: 8, fontWeight: 500 }}>NEUES PASSWORT</div>
<div style={{ width: 32, height: 1.5, background: "#ddd8d0", margin: "16px auto 0" }} />
</div>
{done ? (
<>
<div style={{ padding: "12px 14px", background: "#e8f5ee", border: "1px solid #b8dbc4", borderRadius: 8, fontSize: 12, color: "#2d6a4f", textAlign: "center", marginBottom: 16, lineHeight: 1.5 }}>
Passwort aktualisiert. Sie können sich jetzt mit dem neuen Passwort anmelden.
</div>
<button onClick={onCancel} style={{ width: "100%", padding: 13, background: "#1a1a18", color: "#f0ede8", border: "none", borderRadius: 10, fontFamily: "inherit", fontSize: 13, cursor: "pointer" }}>
Zur Anmeldung
</button>
</>
) : (
<>
<p style={{ fontSize: 12, color: "#666", marginBottom: 18, lineHeight: 1.5, textAlign: "center" }}>
Bitte vergeben Sie ein neues Passwort.
</p>
<div style={{ marginBottom: 12 }}>
<label style={{ display: "block", fontSize: 9, letterSpacing: "0.15em", color: "#8c8880", marginBottom: 6, fontWeight: 500 }}>NEUES PASSWORT</label>
<input
type="password" autoFocus value={pw1} onChange={e => { setPw1(e.target.value); setErr(""); }}
style={{ width: "100%", boxSizing: "border-box", background: "#f7f4f0", border: "1.5px solid #ddd8d0", borderRadius: 10, padding: "11px 14px", fontFamily: "inherit", fontSize: 13, outline: "none" }}
placeholder="Mindestens 6 Zeichen"
/>
</div>
<div style={{ marginBottom: err ? 12 : 24 }}>
<label style={{ display: "block", fontSize: 9, letterSpacing: "0.15em", color: "#8c8880", marginBottom: 6, fontWeight: 500 }}>BESTÄTIGEN</label>
<input
type="password" value={pw2} onChange={e => { setPw2(e.target.value); setErr(""); }}
onKeyDown={e => e.key === "Enter" && submit()}
style={{ width: "100%", boxSizing: "border-box", background: "#f7f4f0", border: "1.5px solid #ddd8d0", borderRadius: 10, padding: "11px 14px", fontFamily: "inherit", fontSize: 13, outline: "none" }}
placeholder="Nochmals eingeben"
/>
</div>
{err && <div style={{ marginBottom: 16, padding: "9px 14px", background: "#fff5f0", borderRadius: 8, border: "1px solid #f5c9b0", fontSize: 11, color: "#b5621e", textAlign: "center" }}>{err}</div>}
<button onClick={submit} disabled={busy} style={{ width: "100%", padding: 13, background: busy ? "#c4bbb0" : "#1a1a18", color: "#f0ede8", border: "none", borderRadius: 10, fontFamily: "inherit", fontSize: 13, cursor: busy ? "default" : "pointer" }}>
{busy ? "Wird gespeichert …" : "Passwort speichern"}
</button>
<button onClick={onCancel} style={{ width: "100%", marginTop: 10, padding: 10, background: "transparent", color: "#888", border: "1px solid #ddd8d0", borderRadius: 10, fontFamily: "inherit", fontSize: 12, cursor: "pointer" }}>
Abbrechen
</button>
</>
)}
</div>
</div>
);
}