4 Commits

Author SHA1 Message Date
karim 5a34d0a60f feat(login): Hinweis-Banner wenn Server nicht erreichbar
Ergaenzt den listStudios-Fix: statt stillem Fallback sieht der User jetzt
'Server nicht erreichbar', wenn die API beim Start nicht antwortet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 15:01:22 +02:00
karim 1846a00d07 fix(cloud): API-Fehler nicht als 'leere Instanz' werten
listStudios() gab bei Kong/API-Fehler [] zurück, was App.jsx als '0 Studios'
interpretierte und faelschlich den Init-/Registrierungs-Screen zeigte (statt
Login). Nach Reload war Kong wieder da -> Login. Daher das 'beim Ausloggen
kommt Init'-Symptom.

- supabase-adapter.listStudios(): wirft jetzt statt [] zu verschlucken
- App.jsx: catch setzt cloudStudios=null + cloudUnreachable=true
- Routing-Guard zeigt CloudSetup nur bei !cloudUnreachable && length===0

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 14:57:52 +02:00
karim 4c04f1cb56 fix(storage): split_part statt storage.foldername in Policies
Die Storage-API droppt/erstellt storage.foldername() bei ihren Boot-
Migrations neu. Policies, die davon abhingen, blockierten den Drop
('cannot drop function foldername') und schickten die Storage-API in
eine Crash-Loop. split_part(name,'/',1) liefert dieselbe erste
Pfad-Komponente (studio_id) ohne diese Kopplung.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 14:18:01 +02:00
karim df69a2dc6b fix(storage): public-Spalte aus bucket-Insert entfernen
storage.buckets hat beim Postgres-Init noch keine public-Spalte (fügt die
Storage-API erst beim Boot hinzu). Der Insert brach daher mit
ON_ERROR_STOP ab und verhinderte alle folgenden Migrations — u.a.
ensure_profile (0005), wodurch die User-Anlage im Self-Host scheiterte.
Default von public ist false (Buckets privat), Spalte ist verzichtbar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 14:15:01 +02:00
4 changed files with 47 additions and 18 deletions
+14 -5
View File
@@ -61,6 +61,10 @@ export default function App() {
// Cloud-spezifisch: Liste der Studios auf der Instanz (für Erst-Setup-Check). // Cloud-spezifisch: Liste der Studios auf der Instanz (für Erst-Setup-Check).
// null = noch nicht geladen; Array = geladen. // null = noch nicht geladen; Array = geladen.
const [cloudStudios, setCloudStudios] = useState(null); const [cloudStudios, setCloudStudios] = useState(null);
// true, wenn listStudios() fehlschlug (Kong/API nicht erreichbar). Wird
// genutzt, um NICHT fälschlich den Init-/Registrierungs-Screen zu zeigen —
// ein API-Fehler ist nicht dasselbe wie "Instanz hat 0 Studios".
const [cloudUnreachable, setCloudUnreachable] = useState(false);
// Passwort-Reset-Flow: Supabase löst beim Klick auf Reset-Link in der Mail // 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. // ein PASSWORD_RECOVERY-Event aus → wir zeigen dann das Reset-Formular.
const [passwordRecovery, setPasswordRecovery] = useState(false); const [passwordRecovery, setPasswordRecovery] = useState(false);
@@ -87,13 +91,15 @@ export default function App() {
console.error("Cloud-Resume load failed:", e); console.error("Cloud-Resume load failed:", e);
} }
} }
// Studios der Instanz holen — entscheidet später, ob CloudSetup oder Login zeigt // Studios der Instanz holen — entscheidet später, ob CloudSetup oder Login zeigt.
// Bei Fehler (Kong/API down): NICHT [] setzen (das hieße "Instanz leer" →
// fälschlich Init-Screen). Stattdessen Unreachable-Flag → Login bleibt.
try { try {
const list = await storage.listStudios?.(); const list = await storage.listStudios?.();
if (!cancelled) setCloudStudios(list || []); if (!cancelled) { setCloudStudios(list || []); setCloudUnreachable(false); }
} catch (e) { } catch (e) {
console.error("listStudios failed:", e); console.error("listStudios failed:", e);
if (!cancelled) setCloudStudios([]); if (!cancelled) { setCloudStudios(null); setCloudUnreachable(true); }
} }
if (!cancelled) setLoading(false); if (!cancelled) setLoading(false);
return; return;
@@ -499,7 +505,10 @@ export default function App() {
// ohne Login läuft (sonst kommt man bei einem fehlerhaften Setup-Screen nie // ohne Login läuft (sonst kommt man bei einem fehlerhaften Setup-Screen nie
// an ein neueres Build, das den Bug fixt). // an ein neueres Build, das den Bug fixt).
if (!currentUser) { if (!currentUser) {
if (isCloudBackend && cloudStudios !== null && cloudStudios.length === 0) { // Init-/Registrierungs-Screen NUR wenn der API-Call erfolgreich war UND
// wirklich 0 Studios lieferte. Bei !cloudUnreachable ausgeschlossen, dass
// ein Kong/API-Fehler (cloudStudios === null) hier fälschlich Init zeigt.
if (isCloudBackend && !cloudUnreachable && cloudStudios !== null && cloudStudios.length === 0) {
const cloudUrl = localStorage.getItem("rapport_cloud_url") || ""; const cloudUrl = localStorage.getItem("rapport_cloud_url") || "";
return <> return <>
<CloudSetup cloudInit={cloudInit} cloudUrl={cloudUrl} /> <CloudSetup cloudInit={cloudInit} cloudUrl={cloudUrl} />
@@ -507,7 +516,7 @@ export default function App() {
</>; </>;
} }
return <> return <>
<Login verifyLogin={verifyLogin} settings={data.settings} version="0.8.2" /> <Login verifyLogin={verifyLogin} settings={data.settings} version="0.8.2" cloudUnreachable={cloudUnreachable} />
<UpdateNotifier /> <UpdateNotifier />
</>; </>;
} }
+7 -4
View File
@@ -125,10 +125,13 @@ export class SupabaseAdapter {
// Kein Auth nötig (RPC läuft als SECURITY DEFINER). // Kein Auth nötig (RPC läuft als SECURITY DEFINER).
async listStudios() { async listStudios() {
const { data, error } = await this.client.rpc("list_studios"); const { data, error } = await this.client.rpc("list_studios");
if (error) { // WICHTIG: Fehler NICHT zu [] verschlucken. Ein leeres Array bedeutet
console.error("listStudios:", error.message); // "Instanz hat 0 Studios" → das Frontend zeigt dann den Registrierungs-/
return []; // Init-Screen. Ein Netzwerk-/API-Fehler (Kong down, Port 8000 nicht
} // erreichbar) ist aber NICHT dasselbe wie "leer" — würden wir hier []
// zurückgeben, landet ein eingeloggter User nach Reload fälschlich im
// Init-Flow. Daher: werfen und den Caller entscheiden lassen.
if (error) throw new Error("listStudios: " + error.message);
return data || []; return data || [];
} }
+7 -1
View File
@@ -16,7 +16,7 @@ function writeAttempts(state) {
try { sessionStorage.setItem(ATTEMPT_KEY, JSON.stringify(state)); } catch {} try { sessionStorage.setItem(ATTEMPT_KEY, JSON.stringify(state)); } catch {}
} }
export default function Login({ verifyLogin, settings, version }) { export default function Login({ verifyLogin, settings, version, cloudUnreachable = false }) {
// Backend-Modus aus localStorage (per-Device, ähnlich Dark Mode). // Backend-Modus aus localStorage (per-Device, ähnlich Dark Mode).
// Beim Wechsel wird die App neu geladen, damit der Storage-Adapter neu initialisiert. // Beim Wechsel wird die App neu geladen, damit der Storage-Adapter neu initialisiert.
const [backend, setBackend] = useState(() => localStorage.getItem("rapport_backend") || "local"); const [backend, setBackend] = useState(() => localStorage.getItem("rapport_backend") || "local");
@@ -236,6 +236,12 @@ export default function Login({ verifyLogin, settings, version }) {
<div style={{ width: 32, height: 1.5, background: "#ddd8d0", margin: "16px auto 0" }} /> <div style={{ width: 32, height: 1.5, background: "#ddd8d0", margin: "16px auto 0" }} />
</div> </div>
{cloudUnreachable && (
<div style={{ marginBottom: 18, padding: "9px 14px", background: "#fff5f0", borderRadius: 8, border: "1px solid #f5c9b0", fontSize: 11, color: "#b5621e", textAlign: "center", lineHeight: 1.5 }}>
Server nicht erreichbar. Bitte Verbindung prüfen und neu laden.
</div>
)}
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
{isCloud && studios.length > 1 && ( {isCloud && studios.length > 1 && (
<div style={{ marginBottom: 14 }}> <div style={{ marginBottom: 14 }}>
+19 -8
View File
@@ -11,44 +11,55 @@
-- Buckets sind PRIVATE — Zugriff nur über signierte URLs (zeitlich begrenzt). -- Buckets sind PRIVATE — Zugriff nur über signierte URLs (zeitlich begrenzt).
-- ============================================================================ -- ============================================================================
insert into storage.buckets (id, name, public) -- Hinweis: KEINE `public`-Spalte angeben. Beim Postgres-Init existiert sie in
-- storage.buckets noch nicht (die fügt die Storage-API erst beim Boot per
-- eigener Migration hinzu). Default ist `false` → Buckets sind privat, wie
-- gewünscht. Würden wir `public` referenzieren, bräche der Init hier ab und
-- ALLE folgenden Migrations (inkl. ensure_profile in 0005) liefen nicht mehr.
insert into storage.buckets (id, name)
values values
('receipts', 'receipts', false), ('receipts', 'receipts'),
('logos', 'logos', false) ('logos', 'logos')
on conflict (id) do nothing; on conflict (id) do nothing;
-- ──────────────────────────────────────────────────────────────────────────── -- ────────────────────────────────────────────────────────────────────────────
-- RLS-Policies auf storage.objects -- RLS-Policies auf storage.objects
-- ──────────────────────────────────────────────────────────────────────────── -- ────────────────────────────────────────────────────────────────────────────
-- Prinzip: erste Pfad-Komponente ist studio_id; Zugriff nur wenn Member. -- Prinzip: erste Pfad-Komponente ist studio_id; Zugriff nur wenn Member.
-- `(storage.foldername(name))[1]` gibt die erste Pfad-Komponente zurück. -- `split_part(name, '/', 1)` gibt die erste Pfad-Komponente zurück.
--
-- Bewusst NICHT storage.foldername() benutzen: die Storage-API droppt/erstellt
-- diese Funktion bei ihren eigenen Boot-Migrations neu. Eine Policy-Abhängigkeit
-- darauf würde diesen Drop blockieren ("cannot drop function foldername") und
-- die Storage-API in eine Crash-Loop schicken. split_part ist ein eingebautes
-- Postgres-Builtin ohne diese Kopplung.
create policy "rapport_storage_read" create policy "rapport_storage_read"
on storage.objects for select on storage.objects for select
using ( using (
bucket_id in ('receipts','logos') bucket_id in ('receipts','logos')
and is_studio_member( (storage.foldername(name))[1]::uuid ) and is_studio_member( split_part(name, '/', 1)::uuid )
); );
create policy "rapport_storage_insert" create policy "rapport_storage_insert"
on storage.objects for insert on storage.objects for insert
with check ( with check (
bucket_id in ('receipts','logos') bucket_id in ('receipts','logos')
and is_studio_member( (storage.foldername(name))[1]::uuid ) and is_studio_member( split_part(name, '/', 1)::uuid )
); );
create policy "rapport_storage_update" create policy "rapport_storage_update"
on storage.objects for update on storage.objects for update
using ( using (
bucket_id in ('receipts','logos') bucket_id in ('receipts','logos')
and is_studio_member( (storage.foldername(name))[1]::uuid ) and is_studio_member( split_part(name, '/', 1)::uuid )
); );
create policy "rapport_storage_delete" create policy "rapport_storage_delete"
on storage.objects for delete on storage.objects for delete
using ( using (
bucket_id in ('receipts','logos') bucket_id in ('receipts','logos')
and is_studio_member( (storage.foldername(name))[1]::uuid ) and is_studio_member( split_part(name, '/', 1)::uuid )
); );
-- ──────────────────────────────────────────────────────────────────────────── -- ────────────────────────────────────────────────────────────────────────────