7 Commits

Author SHA1 Message Date
karim e0cf5f1381 feat: v0.8.3 — PWA-Support, Desktop-Verhalten, Testmodus-Kennzeichnung
- PWA: manifest.webmanifest + Icons (192/512/180px) + Apple-Touch-Meta-Tags
  → Web-App lässt sich auf Homebildschirm hinzufügen (iOS/Android)
- Desktop: user-select:none global + contextmenu blockiert
- BackendChoice: lokaler Modus als Testmodus mit TEST-Badge und Warnung

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 12:40:12 +02:00
karim f88825ebe0 feat(server-mode): gehostete Web-GUI fest an einen Server binden
VITE_SERVER_MODE=1 (vom Dockerfile.app gesetzt) → die App ist die Web-GUI
GENAU DIESES Servers: keine Lokal/Server-Wahl (BackendChoice), kein
Server-Adress-Wechsel, kein Verbinden auf andere Instanzen. Nur Login auf
diesem Server. Tauri (lokale DMG) setzt die Flag NIE → behält volle Wahl
(Lokal / beliebige Server-IP).

- adapter.js: isServerMode-Export; im Server-Modus fest SupabaseAdapter mit
  Build-URL/Key, localStorage-Werte erzwungen (kein User-Override)
- App.jsx: BackendChoice im Server-Modus überspringen
- Login.jsx: Verbindungs-Switch + Server-Adressfeld im Server-Modus ausblenden

Beide Builds verifiziert: Server-Build brennt URL ein, Normal-Build nicht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 15:03:53 +02:00
karim afc6163b2d feat(schema): create_studio_for_user RPC (service_role) für HOST-Provisioning
RAPPORT-HOST provisioniert serverseitig (kein auth.uid()), daher braucht es
eine service_role-Variante von create_studio_with_admin mit expliziter
User-ID. Legt zusätzlich das Profil an (profiles.username/display_name sind
NOT NULL, fürs erste Instanz-Login nötig). NUR an service_role gegrantet —
nie an authenticated, sonst könnte jeder User sich zum Admin machen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 23:15:22 +02:00
karim 44ddc5ee12 cleanup: verwaiste cloudError-State-Variable entfernt
War redundant zum bereits vorhandenen cloudUnreachable-Mechanismus
(1846a00/5a34d0a) — nirgends gesetzt/gelesen, reiner toter Code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 15:58:40 +02:00
karim 266e7d61d4 fix(cloud): API-Fehler nicht als leere Instanz behandeln
Beim Ausloggen/Reload zeigte die App fälschlich den Erst-Setup-Wizard, wenn
listStudios fehlschlug (Kong/Server kurz nicht erreichbar): der catch setzte
cloudStudios=[] → Routing interpretierte das als '0 Studios' → CloudSetup.

Jetzt: bei Fehler bleibt cloudStudios=null + cloudError=true → Routing zeigt
den Login (mit Hinweis 'Server nicht erreichbar'), nicht den Init-Wizard. Der
Setup erscheint nur noch, wenn listStudios ERFOLGREICH 0 Studios meldet.

Ergänzt den Adapter-Fix (5a34d0a), der listStudios werfen statt [] liefern lässt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 15:50:50 +02:00
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
17 changed files with 197 additions and 30 deletions
+7
View File
@@ -3,8 +3,15 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" /> <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#1a1a18" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Rapport" />
<meta name="description" content="Studio-Management für Architekturbüros" />
<title>Rapport</title> <title>Rapport</title>
</head> </head>
<body> <body>
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "rapport", "name": "rapport",
"private": true, "private": true,
"version": "0.8.2", "version": "0.8.3",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+27
View File
@@ -0,0 +1,27 @@
{
"name": "Rapport",
"short_name": "Rapport",
"description": "Studio-Management für Architekturbüros",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#ebe7e1",
"theme_color": "#1a1a18",
"lang": "de-CH",
"categories": ["business", "productivity"],
"icons": [
{
"src": "/pwa-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/pwa-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

+1 -1
View File
@@ -2880,7 +2880,7 @@ dependencies = [
[[package]] [[package]]
name = "rapport" name = "rapport"
version = "0.8.1" version = "0.8.2"
dependencies = [ dependencies = [
"log", "log",
"serde", "serde",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rapport" name = "rapport"
version = "0.8.2" version = "0.8.3"
description = "Rapport — Studio-Management für Architekturbüros" description = "Rapport — Studio-Management für Architekturbüros"
authors = ["Karim Gabriele Varano <karim@gabrielevarano.ch>"] authors = ["Karim Gabriele Varano <karim@gabrielevarano.ch>"]
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "RAPPORT PRE-RELEASE", "productName": "RAPPORT PRE-RELEASE",
"version": "0.8.2", "version": "0.8.3",
"identifier": "com.karimgabrielevarano.rapport", "identifier": "com.karimgabrielevarano.rapport",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",
+32 -14
View File
@@ -1,7 +1,7 @@
import React, { useState, useEffect, useCallback, useRef, Suspense, lazy } from "react"; import React, { useState, useEffect, useCallback, useRef, Suspense, lazy } from "react";
import { NAV_ITEMS, defaultData } from "./constants.js"; import { NAV_ITEMS, defaultData } from "./constants.js";
import { verifyPassword, withHashedPassword, stripCredentials } from "./utils.js"; import { verifyPassword, withHashedPassword, stripCredentials } from "./utils.js";
import { storage, isCloudBackend } from "./storage/adapter.js"; import { storage, isCloudBackend, isServerMode } from "./storage/adapter.js";
import { applyMigrations } from "./storage/migrations.js"; import { applyMigrations } from "./storage/migrations.js";
import Login from "./views/Login.jsx"; import Login from "./views/Login.jsx";
import Setup from "./views/Setup.jsx"; import Setup from "./views/Setup.jsx";
@@ -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;
@@ -297,8 +303,8 @@ export default function App() {
const [modal, setModal] = useState(null); const [modal, setModal] = useState(null);
const [printContent, setPrintContent] = useState(null); const [printContent, setPrintContent] = useState(null);
const [darkMode, setDarkMode] = useState(() => localStorage.getItem("rapport_dark") === "1"); const [darkMode, setDarkMode] = useState(() => localStorage.getItem("rapport_dark") === "1");
const [showChangelog, setShowChangelog] = useState(() => localStorage.getItem("rapport_changelog_seen") !== "0.8.2"); const [showChangelog, setShowChangelog] = useState(() => localStorage.getItem("rapport_changelog_seen") !== "0.8.3");
const [changelogVersion, setChangelogVersion] = useState("0.8.2"); const [changelogVersion, setChangelogVersion] = useState("0.8.3");
const [showAbout, setShowAbout] = useState(false); const [showAbout, setShowAbout] = useState(false);
const [navOpen, setNavOpen] = useState(false); const [navOpen, setNavOpen] = useState(false);
const [expandedNav, setExpandedNav] = useState(new Set(["buchhaltung"])); const [expandedNav, setExpandedNav] = useState(new Set(["buchhaltung"]));
@@ -458,8 +464,10 @@ export default function App() {
// lokalen Daten gibt. Sobald er gewählt hat, übernimmt der jeweilige Wizard. // lokalen Daten gibt. Sobald er gewählt hat, übernimmt der jeweilige Wizard.
// UpdateNotifier wird in allen Pre-Login-Screens mitgerendert, damit ein // UpdateNotifier wird in allen Pre-Login-Screens mitgerendert, damit ein
// hängender Setup-Wizard sich via Auto-Update selbst befreien kann. // hängender Setup-Wizard sich via Auto-Update selbst befreien kann.
// Server-Modus (gehostete Web-GUI): nie die Lokal/Server-Wahl zeigen — die
// App ist fest an diesen Server gebunden. Nur die lokale DMG zeigt die Wahl.
const hasChosenBackend = localStorage.getItem("rapport_backend_chosen") === "1"; const hasChosenBackend = localStorage.getItem("rapport_backend_chosen") === "1";
if (!hasChosenBackend && isNewInstall && !data.settings.setupCompleted && !currentUser) { if (!isServerMode && !hasChosenBackend && isNewInstall && !data.settings.setupCompleted && !currentUser) {
return <><BackendChoice /><UpdateNotifier /></>; return <><BackendChoice /><UpdateNotifier /></>;
} }
@@ -499,7 +507,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 +518,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.3" cloudUnreachable={cloudUnreachable} />
<UpdateNotifier /> <UpdateNotifier />
</>; </>;
} }
@@ -782,8 +793,8 @@ export default function App() {
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}> <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<button onClick={() => setShowAbout(true)} style={{ background: "none", border: "none", padding: 0, color: "#555", fontSize: 10, letterSpacing: "0.08em", cursor: "pointer", fontFamily: "inherit", textAlign: "left" }} <button onClick={() => setShowAbout(true)} style={{ background: "none", border: "none", padding: 0, color: "#555", fontSize: 10, letterSpacing: "0.08em", cursor: "pointer", fontFamily: "inherit", textAlign: "left" }}
onMouseEnter={e => e.currentTarget.style.color = "#aaa"} onMouseLeave={e => e.currentTarget.style.color = "#555"}>ÜBER RAPPORT</button> onMouseEnter={e => e.currentTarget.style.color = "#aaa"} onMouseLeave={e => e.currentTarget.style.color = "#555"}>ÜBER RAPPORT</button>
<button onClick={() => { setChangelogVersion("0.8.2"); setShowChangelog(true); }} style={{ background: "none", border: "none", padding: 0, color: "#aaa", fontSize: 10, letterSpacing: "0.08em", cursor: "pointer", fontFamily: "inherit" }} <button onClick={() => { setChangelogVersion("0.8.3"); setShowChangelog(true); }} style={{ background: "none", border: "none", padding: 0, color: "#aaa", fontSize: 10, letterSpacing: "0.08em", cursor: "pointer", fontFamily: "inherit" }}
onMouseEnter={e => e.currentTarget.style.color = "#f0ede8"} onMouseLeave={e => e.currentTarget.style.color = "#aaa"}>0.8.2</button> onMouseEnter={e => e.currentTarget.style.color = "#f0ede8"} onMouseLeave={e => e.currentTarget.style.color = "#aaa"}>0.8.3</button>
</div> </div>
</div>} </div>}
@@ -848,6 +859,13 @@ export default function App() {
{showChangelog && (() => { {showChangelog && (() => {
const CHANGELOGS = { const CHANGELOGS = {
"0.8.3": {
items: [
["Desktop-App-Verhalten", "Kein Text mehr markierbar, kein Rechtsklick-Menü — die App verhält sich jetzt wie eine native Desktop-Applikation. Eingabefelder sind weiterhin voll benutzbar."],
["PWA-Support", "Die Web-Version lässt sich auf dem Homebildschirm von iPhone/iPad und Android hinzufügen. Nach dem Hinzufügen öffnet sie sich vollbild ohne Browser-Chrome — mit eigenem App-Icon und Name."],
["Testmodus klar gekennzeichnet", "Wer Rapport ohne Server ausprobiert, sieht jetzt explizit, dass es sich um einen Testmodus mit Einschränkungen handelt (5 MB Limit, kein Backup, kein Mehrbenutzer)."],
],
},
"0.8.2": { "0.8.2": {
items: [ items: [
["Selbstheilung für hängende 0.8.0-Installationen", "Wer von 0.7 auf 0.8 geupdated hat und in den Cloud-Setup-Wizard geschoben wurde, kommt mit 0.8.2 automatisch zurück in seinen Lokal-Modus. Der Auto-Recovery-Code erkennt: Cloud-Modus gesetzt + lokale Daten vorhanden + keine Cloud-Anmeldung → Cloud-Konfiguration wird zurückgenommen, alle Daten bleiben erhalten."], ["Selbstheilung für hängende 0.8.0-Installationen", "Wer von 0.7 auf 0.8 geupdated hat und in den Cloud-Setup-Wizard geschoben wurde, kommt mit 0.8.2 automatisch zurück in seinen Lokal-Modus. Der Auto-Recovery-Code erkennt: Cloud-Modus gesetzt + lokale Daten vorhanden + keine Cloud-Anmeldung → Cloud-Konfiguration wird zurückgenommen, alle Daten bleiben erhalten."],
@@ -937,7 +955,7 @@ export default function App() {
}, },
}; };
const versions = Object.keys(CHANGELOGS); const versions = Object.keys(CHANGELOGS);
const current = CHANGELOGS[changelogVersion] || CHANGELOGS["0.8.2"]; const current = CHANGELOGS[changelogVersion] || CHANGELOGS["0.8.3"];
return ( return (
<div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.55)", zIndex: 200, display: "flex", alignItems: "center", justifyContent: "center", padding: 24 }}> <div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.55)", zIndex: 200, display: "flex", alignItems: "center", justifyContent: "center", padding: 24 }}>
<div style={{ background: "#fff", borderRadius: 10, width: "100%", maxWidth: 480, boxShadow: "0 8px 40px rgba(0,0,0,0.18)", overflow: "hidden" }}> <div style={{ background: "#fff", borderRadius: 10, width: "100%", maxWidth: 480, boxShadow: "0 8px 40px rgba(0,0,0,0.18)", overflow: "hidden" }}>
@@ -966,7 +984,7 @@ export default function App() {
))} ))}
</div> </div>
<div style={{ padding: "12px 32px 24px" }}> <div style={{ padding: "12px 32px 24px" }}>
<button className="btn btn-primary" style={{ width: "100%", fontSize: 13 }} onClick={() => { setShowChangelog(false); localStorage.setItem("rapport_changelog_seen", "0.8.2"); }}> <button className="btn btn-primary" style={{ width: "100%", fontSize: 13 }} onClick={() => { setShowChangelog(false); localStorage.setItem("rapport_changelog_seen", "0.8.3"); }}>
Schliessen Schliessen
</button> </button>
</div> </div>
@@ -981,7 +999,7 @@ export default function App() {
<div style={{ background: "#1a1a18", padding: "28px 32px 24px" }}> <div style={{ background: "#1a1a18", padding: "28px 32px 24px" }}>
<div style={{ fontSize: 10, letterSpacing: "0.18em", color: "#b07848", marginBottom: 8, fontWeight: 600 }}>ÜBER RAPPORT</div> <div style={{ fontSize: 10, letterSpacing: "0.18em", color: "#b07848", marginBottom: 8, fontWeight: 600 }}>ÜBER RAPPORT</div>
<div style={{ fontFamily: "'Playfair Display', serif", fontSize: 28, color: "#f0ede8", fontWeight: 400, lineHeight: 1.1 }}>Rapport</div> <div style={{ fontFamily: "'Playfair Display', serif", fontSize: 28, color: "#f0ede8", fontWeight: 400, lineHeight: 1.1 }}>Rapport</div>
<div style={{ fontSize: 11, color: "#888", marginTop: 6, letterSpacing: "0.04em" }}>Alpha 0.8.2 · Studio-Management für Architekturbüros</div> <div style={{ fontSize: 11, color: "#888", marginTop: 6, letterSpacing: "0.04em" }}>Alpha 0.8.3 · Studio-Management für Architekturbüros</div>
</div> </div>
<div style={{ padding: "20px 32px 8px" }}> <div style={{ padding: "20px 32px 8px" }}>
<div style={{ fontSize: 11, fontWeight: 600, color: "#888", letterSpacing: "0.1em", marginBottom: 12 }}>LIZENZ</div> <div style={{ fontSize: 11, fontWeight: 600, color: "#888", letterSpacing: "0.1em", marginBottom: 12 }}>LIZENZ</div>
+4
View File
@@ -1,3 +1,7 @@
*, *::before, *::after { box-sizing: border-box; } *, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: 100%; } html, body { margin: 0; padding: 0; height: 100%; }
#root { height: 100%; } #root { height: 100%; }
/* Desktop-App-Verhalten: kein Textmarkieren, kein Drag */
* { user-select: none; -webkit-user-drag: none; }
input, textarea, [contenteditable] { user-select: text; }
+2
View File
@@ -3,6 +3,8 @@ import { createRoot } from 'react-dom/client'
import './index.css' import './index.css'
import App from './App.jsx' import App from './App.jsx'
document.addEventListener('contextmenu', e => e.preventDefault())
createRoot(document.getElementById('root')).render( createRoot(document.getElementById('root')).render(
<StrictMode> <StrictMode>
<App /> <App />
+26 -1
View File
@@ -45,8 +45,17 @@ export class LocalStorageAdapter {
} }
} }
// SERVER-MODUS: dieser Build ist die gehostete Web-GUI eines bestimmten
// Servers (gesetzt via VITE_SERVER_MODE=1 im Dockerfile.app). Dann ist die App
// FEST an diesen einen Server gebunden — keine Lokal/Server-Wahl, kein Wechsel
// der Server-Adresse, kein Verbinden auf andere Instanzen. Nur der Login auf
// genau diesem Server. Die lokale DMG (Tauri) setzt diese Flag NIE und behält
// die volle Wahl (Lokal / beliebige Server-IP).
const _isTauri = typeof window !== "undefined" && !!window.__TAURI_INTERNALS__;
export const isServerMode = import.meta.env.VITE_SERVER_MODE === "1" && !_isTauri;
function createAdapter() { function createAdapter() {
const isTauri = typeof window !== "undefined" && !!window.__TAURI_INTERNALS__; const isTauri = _isTauri;
// Build-time-URL nur für Web-Deploy gültig. Tauri-Builds ignorieren den // Build-time-URL nur für Web-Deploy gültig. Tauri-Builds ignorieren den
// eingebrannten Wert — Desktop-User geben die Server-URL aktiv ein. // eingebrannten Wert — Desktop-User geben die Server-URL aktiv ein.
// Der Anon-Key bleibt aus dem Build, weil er pro Cloud-Instanz konstant ist // Der Anon-Key bleibt aus dem Build, weil er pro Cloud-Instanz konstant ist
@@ -54,6 +63,22 @@ function createAdapter() {
const envUrl = isTauri ? null : import.meta.env.VITE_SUPABASE_URL; const envUrl = isTauri ? null : import.meta.env.VITE_SUPABASE_URL;
const envKey = import.meta.env.VITE_SUPABASE_ANON_KEY; const envKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
// ── Server-Modus: fest auf diesen Server, ohne Wahlmöglichkeit ───────────
if (isServerMode) {
if (!envUrl || !envKey) {
console.error("VITE_SERVER_MODE=1, aber VITE_SUPABASE_URL/ANON_KEY fehlen — Fehlkonfiguration des Web-Builds.");
return new LocalStorageAdapter();
}
// localStorage konsistent halten (Login liest cloud_url für listStudios),
// aber der User kann nichts davon ändern — die Werte kommen aus dem Build.
if (typeof localStorage !== "undefined") {
localStorage.setItem("rapport_backend", "cloud");
localStorage.setItem("rapport_backend_chosen", "1");
localStorage.setItem("rapport_cloud_url", envUrl.replace(/\/+$/, ""));
}
return new SupabaseAdapter(envUrl, envKey);
}
if (typeof localStorage !== "undefined") { if (typeof localStorage !== "undefined") {
// ── Auto-Recovery für 0.8.0-Upgrade-Bug ────────────────────────────── // ── Auto-Recovery für 0.8.0-Upgrade-Bug ──────────────────────────────
// 0.8.0 hat Cloud-Modus ungewollt gesetzt bei Lokal-Usern (siehe 0.8.1 // 0.8.0 hat Cloud-Modus ungewollt gesetzt bei Lokal-Usern (siehe 0.8.1
+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 || [];
} }
+6 -3
View File
@@ -82,10 +82,13 @@ export default function BackendChoice() {
</p> </p>
<button className="bc-option" onClick={() => pick("local")}> <button className="bc-option" onClick={() => pick("local")}>
<div className="bc-title">Lokal auf diesem Gerät</div> <div className="bc-title" style={{ display: "flex", alignItems: "center", gap: 8 }}>
Testmodus (lokal)
<span style={{ fontSize: 10, fontWeight: 600, letterSpacing: "0.08em", color: "#9a7858", background: "rgba(154,120,88,0.10)", borderRadius: 4, padding: "2px 6px" }}>TEST</span>
</div>
<div className="bc-desc"> <div className="bc-desc">
Daten liegen ausschliesslich in diesem Browser / dieser App. Kein Server nötig. Daten liegen nur in diesem Browser. Kein Backup, kein Mehrbenutzer, begrenzt auf ~5 MB.
Ideal zum Ausprobieren oder als Solo-Setup. Nur zum Ausprobieren für den produktiven Einsatz einen Server verbinden.
</div> </div>
</button> </button>
+17 -4
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { storage, isCloudBackend } from "../storage/adapter.js"; import { storage, isCloudBackend, isServerMode } from "../storage/adapter.js";
const isValidEmail = (s) => /.+@.+\..+/.test(s); const isValidEmail = (s) => /.+@.+\..+/.test(s);
@@ -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");
@@ -121,8 +121,10 @@ export default function Login({ verifyLogin, settings, version }) {
const userPlaceholder = isCloud ? "name@studio.ch" : "admin"; const userPlaceholder = isCloud ? "name@studio.ch" : "admin";
const userInputType = isCloud ? "email" : "text"; const userInputType = isCloud ? "email" : "text";
const showUrlField = isCloud && editUrl; // Im Server-Modus (gehostete Web-GUI) gibt es keine Server-Adress-Eingabe —
const showUrlBadge = isCloud && !editUrl && cloudUrl; // die App ist fest an diesen Server gebunden.
const showUrlField = isCloud && editUrl && !isServerMode;
const showUrlBadge = isCloud && !editUrl && cloudUrl && !isServerMode;
// Hostname zur Anzeige (ohne Protokoll, ohne Port falls Standard) // Hostname zur Anzeige (ohne Protokoll, ohne Port falls Standard)
let urlDisplay = cloudUrl; let urlDisplay = cloudUrl;
@@ -236,6 +238,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 }}>
@@ -375,6 +383,10 @@ export default function Login({ verifyLogin, settings, version }) {
)} )}
{/* Verbindung-Switch + Server-Anzeige (dezent darunter) */} {/* Verbindung-Switch + Server-Anzeige (dezent darunter) */}
{/* Verbindungs-Switch (Lokal/Server + Server-Adresse) nur in der lokalen
DMG. Die gehostete Web-GUI (Server-Modus) ist fest an diesen Server
gebunden — keine Wahl, kein Wechsel. */}
{!isServerMode && (
<div style={{ <div style={{
marginTop: 22, paddingTop: 16, marginTop: 22, paddingTop: 16,
borderTop: "1px solid #ebe7e1", borderTop: "1px solid #ebe7e1",
@@ -407,6 +419,7 @@ export default function Login({ verifyLogin, settings, version }) {
</div> </div>
)} )}
</div> </div>
)}
<div style={{ marginTop: 16, textAlign: "center", fontSize: 9, color: "#c8c4be", letterSpacing: "0.08em" }}> <div style={{ marginTop: 16, textAlign: "center", fontSize: 9, color: "#c8c4be", letterSpacing: "0.08em" }}>
{version ? `V${version}` : ""} {version ? `V${version}` : ""}
@@ -0,0 +1,65 @@
-- ============================================================================
-- RAPPORT — Studio für einen bestimmten User anlegen (Server-/Hosting-Pfad)
-- ============================================================================
-- `create_studio_with_admin` nutzt auth.uid() und läuft nur im Kontext eines
-- eingeloggten Users (Frontend). RAPPORT-HOST provisioniert aber serverseitig
-- mit service_role und kennt keinen auth.uid() — es übergibt die Ziel-User-ID
-- explizit.
--
-- `create_studio_for_user` ist die service_role-Variante: identische Wirkung
-- (Studio + Admin-Membership + Settings), aber die User-ID ist ein Parameter.
-- Bewusst NICHT an `authenticated` gegrantet — nur service_role darf das, sonst
-- könnte ein User sich selbst zum Admin beliebiger Studios machen.
-- ============================================================================
create or replace function create_studio_for_user(
p_user_id uuid,
p_name text,
p_slug text,
p_username text default null,
p_display_name text default null
)
returns uuid
language plpgsql
security definer
as $$
declare
v_studio_id uuid;
v_email text;
v_username text;
v_display text;
begin
if p_user_id is null then
raise exception 'p_user_id required';
end if;
select email into v_email from auth.users where id = p_user_id;
if v_email is null then
raise exception 'user % does not exist', p_user_id;
end if;
-- Profil sicherstellen (profiles.username/display_name sind NOT NULL; das
-- Frontend braucht sie beim ersten Login in die Instanz). Aus E-Mail
-- abgeleitet, falls nicht explizit übergeben.
v_username := coalesce(nullif(p_username, ''), split_part(v_email, '@', 1));
v_display := coalesce(nullif(p_display_name, ''), v_username);
insert into profiles (id, username, display_name)
values (p_user_id, v_username, v_display)
on conflict (id) do nothing;
insert into studios (name, slug) values (p_name, p_slug) returning id into v_studio_id;
insert into studio_members (studio_id, user_id, app_role_id)
values (v_studio_id, p_user_id, 'r-admin');
-- Studio-Name + setup_completed in die settings übernehmen (Seed-Trigger hat
-- die Zeile mit Defaults bereits angelegt) — analog create_studio_with_admin.
update studio_settings
set name = p_name, setup_completed = true
where studio_id = v_studio_id;
return v_studio_id;
end;
$$;
-- Nur service_role (RAPPORT-HOST). KEIN Grant an anon/authenticated.
revoke all on function create_studio_for_user(uuid, text, text, text, text) from public;
grant execute on function create_studio_for_user(uuid, text, text, text, text) to service_role;