Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f88825ebe0 | |||
| afc6163b2d | |||
| 44ddc5ee12 | |||
| 266e7d61d4 | |||
| 5a34d0a60f | |||
| 1846a00d07 | |||
| 4c04f1cb56 | |||
| df69a2dc6b | |||
| bb69cc0657 | |||
| edee7b9f28 | |||
| 2bd516a9ab | |||
| 40a28d5ff5 |
+5
-5
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"version": "0.8.0",
|
"version": "0.8.2",
|
||||||
"notes": "Rapport 0.8.0",
|
"notes": "Rapport 0.8.2",
|
||||||
"pub_date": "2026-05-23T17:12:56Z",
|
"pub_date": "2026-05-23T17:54:59Z",
|
||||||
"platforms": {
|
"platforms": {
|
||||||
"darwin-aarch64": {
|
"darwin-aarch64": {
|
||||||
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVSUjV0czVoWDR4NGs2aHVOMzc4NUhtN092d1ZLa2Jobk1CY2prUkt6WlVFclRLQzF4Y3pDZk13djFiWkVUTm1QZU1LSTE4MmJoQjhtZ0ZBT2tQalBUaDdCMnRIM2ZzNmdnPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzc5NTU2Mzc2CWZpbGU6UkFQUE9SVCBQUkUtUkVMRUFTRS5hcHAudGFyLmd6CkNFaEtaQTF5SUJFMG84MVBNeEdaRXBleEtRZ1oyeUNjaU9ydWtzUkIxU21BU1RRY0I2QjREcEhEZlBEQitCRUQrNFowbFJ3V01aYjFhL2dwRGZDVkNBPT0K",
|
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVSUjV0czVoWDR4NHFSRmtHVUhpSkFlMDFwVS9XRGZjOGh1MnoybDNVcWh4ZkpIcGFQTFhVTHg5TVJ1SFA5L2kxbVQ5NUYvYmRsdlVmTHExcWZ4NThqeUZvQmk4WCtXTkEwPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzc5NTU4ODk5CWZpbGU6UkFQUE9SVCBQUkUtUkVMRUFTRS5hcHAudGFyLmd6Cjdvc2VDeW0rbEQvbnJDeDJzbTRIS1VQT1l0VU5pRmNNT1g0Wkc4NXB3UENGVjFPUFdRM0gveE1jcENKZjFMUTRCQ2tMMHNTUDBORkNiYVpOODZZdUJRPT0K",
|
||||||
"url": "https://git.kgva.ch/karim/RAPPORT/releases/download/0.8.0/RAPPORT%20PRE-RELEASE.app.tar.gz"
|
"url": "https://git.kgva.ch/karim/RAPPORT/releases/download/0.8.2/RAPPORT%20PRE-RELEASE.app.tar.gz"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "rapport",
|
"name": "rapport",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.8.0",
|
"version": "0.8.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
Generated
+1
-1
@@ -2880,7 +2880,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rapport"
|
name = "rapport"
|
||||||
version = "0.7.0"
|
version = "0.8.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rapport"
|
name = "rapport"
|
||||||
version = "0.8.0"
|
version = "0.8.2"
|
||||||
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,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.0",
|
"version": "0.8.2",
|
||||||
"identifier": "com.karimgabrielevarano.rapport",
|
"identifier": "com.karimgabrielevarano.rapport",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
|
|||||||
+52
-18
@@ -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");
|
const [showChangelog, setShowChangelog] = useState(() => localStorage.getItem("rapport_changelog_seen") !== "0.8.2");
|
||||||
const [changelogVersion, setChangelogVersion] = useState("0.8");
|
const [changelogVersion, setChangelogVersion] = useState("0.8.2");
|
||||||
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"]));
|
||||||
@@ -456,19 +462,23 @@ export default function App() {
|
|||||||
// Erst-Screen einer frischen Installation: «Lokal oder Cloud?».
|
// Erst-Screen einer frischen Installation: «Lokal oder Cloud?».
|
||||||
// Sichtbar solange der User die Wahl noch nicht getroffen hat UND es keine
|
// 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.
|
// lokalen Daten gibt. Sobald er gewählt hat, übernimmt der jeweilige Wizard.
|
||||||
|
// UpdateNotifier wird in allen Pre-Login-Screens mitgerendert, damit ein
|
||||||
|
// 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 />;
|
return <><BackendChoice /><UpdateNotifier /></>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup- und Migrations-Screens sind LocalStorage-Spezifika. Im Cloud-Modus
|
// Setup- und Migrations-Screens sind LocalStorage-Spezifika. Im Cloud-Modus
|
||||||
// erfolgt Erst-Einrichtung über den Init-Dialog im Login.
|
// erfolgt Erst-Einrichtung über den Init-Dialog im Login.
|
||||||
if (!isCloudBackend && isNewInstall && !data.settings.setupCompleted) {
|
if (!isCloudBackend && isNewInstall && !data.settings.setupCompleted) {
|
||||||
return <Setup onComplete={handleSetupComplete} />;
|
return <><Setup onComplete={handleSetupComplete} /><UpdateNotifier /></>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isCloudBackend && !localStorage.getItem("rapport_v0_5_migrated")) {
|
if (!isCloudBackend && !localStorage.getItem("rapport_v0_5_migrated")) {
|
||||||
return <MigrationScreen data={data} onComplete={handleSetupComplete} />;
|
return <><MigrationScreen data={data} onComplete={handleSetupComplete} /><UpdateNotifier /></>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Passwort-Reset hat höchste Priorität — User kommt von Mail-Link
|
// Passwort-Reset hat höchste Priorität — User kommt von Mail-Link
|
||||||
@@ -493,12 +503,24 @@ export default function App() {
|
|||||||
|
|
||||||
// Cloud + Instanz ist leer (0 Studios) → mehrseitiger Setup-Wizard.
|
// Cloud + Instanz ist leer (0 Studios) → mehrseitiger Setup-Wizard.
|
||||||
// Cloud + Studios vorhanden → klassischer Login.
|
// Cloud + Studios vorhanden → klassischer Login.
|
||||||
|
// UpdateNotifier wird hier auch gerendert, damit der Auto-Update-Check auch
|
||||||
|
// ohne Login läuft (sonst kommt man bei einem fehlerhaften Setup-Screen nie
|
||||||
|
// 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 <CloudSetup cloudInit={cloudInit} cloudUrl={cloudUrl} />;
|
return <>
|
||||||
|
<CloudSetup cloudInit={cloudInit} cloudUrl={cloudUrl} />
|
||||||
|
<UpdateNotifier />
|
||||||
|
</>;
|
||||||
}
|
}
|
||||||
return <Login verifyLogin={verifyLogin} settings={data.settings} version="0.8" />;
|
return <>
|
||||||
|
<Login verifyLogin={verifyLogin} settings={data.settings} version="0.8.2" cloudUnreachable={cloudUnreachable} />
|
||||||
|
<UpdateNotifier />
|
||||||
|
</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (printContent) {
|
if (printContent) {
|
||||||
@@ -771,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"); 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.2"); 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</button>
|
onMouseEnter={e => e.currentTarget.style.color = "#f0ede8"} onMouseLeave={e => e.currentTarget.style.color = "#aaa"}>0.8.2</button>
|
||||||
</div>
|
</div>
|
||||||
</div>}
|
</div>}
|
||||||
|
|
||||||
@@ -837,6 +859,18 @@ export default function App() {
|
|||||||
|
|
||||||
{showChangelog && (() => {
|
{showChangelog && (() => {
|
||||||
const CHANGELOGS = {
|
const CHANGELOGS = {
|
||||||
|
"0.8.2": {
|
||||||
|
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."],
|
||||||
|
["Auto-Update auch ohne Login", "Bisher prüfte Rapport erst nach dem Login auf Updates — wer in einem fehlerhaften Setup-Bildschirm hing, kam nicht an den Bugfix. Jetzt läuft der Update-Check auch im «Lokal oder Cloud»-Wizard, im Login-Screen und im Cloud-Setup."],
|
||||||
|
["Tauri ohne fest eingebaute Server-IP", "Die Desktop-App enthält keine vorkonfigurierte Cloud-Adresse mehr. Wer Cloud nutzen will, gibt die Server-Adresse beim Login aktiv ein — kein automatisches Vorausfüllen mit irrelevanten IPs."],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"0.8.1": {
|
||||||
|
items: [
|
||||||
|
["Update-Fix", "Behebt einen Fehler beim Upgrade von 0.7 auf 0.8: Lokal-Installationen wurden ungewollt in den Cloud-Modus geschoben und der Cloud-Setup-Wizard angezeigt, obwohl bereits lokale Daten vorhanden waren. Die App prüft jetzt vor einem automatischen Modus-Wechsel, ob lokale Daten existieren — und in Tauri-Installationen wird der Modus nie implizit gesetzt."],
|
||||||
|
],
|
||||||
|
},
|
||||||
"0.8": {
|
"0.8": {
|
||||||
items: [
|
items: [
|
||||||
["Cloud-Variante", "Rapport kann jetzt nicht nur lokal, sondern auch auf einem eigenen Server (Supabase) laufen. Beim ersten Öffnen wählt man «Lokal» oder «Cloud»; beide Modi haben dieselben Funktionen, Cloud zusätzlich Multi-User und Live-Sync zwischen Geräten."],
|
["Cloud-Variante", "Rapport kann jetzt nicht nur lokal, sondern auch auf einem eigenen Server (Supabase) laufen. Beim ersten Öffnen wählt man «Lokal» oder «Cloud»; beide Modi haben dieselben Funktionen, Cloud zusätzlich Multi-User und Live-Sync zwischen Geräten."],
|
||||||
@@ -914,7 +948,7 @@ export default function App() {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
const versions = Object.keys(CHANGELOGS);
|
const versions = Object.keys(CHANGELOGS);
|
||||||
const current = CHANGELOGS[changelogVersion] || CHANGELOGS["0.8"];
|
const current = CHANGELOGS[changelogVersion] || CHANGELOGS["0.8.2"];
|
||||||
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" }}>
|
||||||
@@ -943,7 +977,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"); }}>
|
<button className="btn btn-primary" style={{ width: "100%", fontSize: 13 }} onClick={() => { setShowChangelog(false); localStorage.setItem("rapport_changelog_seen", "0.8.2"); }}>
|
||||||
Schliessen
|
Schliessen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -958,7 +992,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 · Studio-Management für Architekturbüros</div>
|
<div style={{ fontSize: 11, color: "#888", marginTop: 6, letterSpacing: "0.04em" }}>Alpha 0.8.2 · 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>
|
||||||
|
|||||||
+62
-14
@@ -45,16 +45,63 @@ 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() {
|
||||||
// Build-time-Default für Web-Deploy: wenn eine Production-Build VITE_SUPABASE_URL
|
const isTauri = _isTauri;
|
||||||
// gesetzt hat (z.B. via .env.production) und der User noch nichts gewählt hat,
|
// Build-time-URL nur für Web-Deploy gültig. Tauri-Builds ignorieren den
|
||||||
// wird Cloud automatisch der Modus. Damit landet ein Browser, der app.rapport.kgva.ch
|
// eingebrannten Wert — Desktop-User geben die Server-URL aktiv ein.
|
||||||
// öffnet, direkt im Cloud-Login (bzw. Init-Dialog wenn Server leer).
|
// Der Anon-Key bleibt aus dem Build, weil er pro Cloud-Instanz konstant ist
|
||||||
// Dev (`npm run dev`) ist ausgenommen — dort sieht der User weiterhin den
|
// (kein User-Geheimnis, sondern Public-Konfig).
|
||||||
// BackendChoice-Screen, weil .env.local oft auf localhost zeigt.
|
const envUrl = isTauri ? null : import.meta.env.VITE_SUPABASE_URL;
|
||||||
if (typeof localStorage !== "undefined" && import.meta.env.PROD) {
|
const envKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
||||||
const envUrl = import.meta.env.VITE_SUPABASE_URL;
|
|
||||||
if (envUrl && !localStorage.getItem("rapport_backend_chosen")) {
|
// ── 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") {
|
||||||
|
// ── Auto-Recovery für 0.8.0-Upgrade-Bug ──────────────────────────────
|
||||||
|
// 0.8.0 hat Cloud-Modus ungewollt gesetzt bei Lokal-Usern (siehe 0.8.1
|
||||||
|
// CHANGELOG). 0.8.1 verhindert das nur prospektiv, behebt aber den
|
||||||
|
// bestehenden Cloud-State nicht. Hier räumen wir nachträglich auf:
|
||||||
|
// Cloud-Modus gesetzt + lokale Daten vorhanden + keine Cloud-Session
|
||||||
|
// → Cloud-State löschen, User landet im BackendChoice oder Lokal-Modus.
|
||||||
|
const RECOVERY_MARKER = "rapport_080_recovery";
|
||||||
|
const backend = localStorage.getItem("rapport_backend");
|
||||||
|
const hasLocalData = !!localStorage.getItem(STORAGE_KEY);
|
||||||
|
const hasCloudSession = !!localStorage.getItem("rapport_supabase_session");
|
||||||
|
if (backend === "cloud" && hasLocalData && !hasCloudSession && !localStorage.getItem(RECOVERY_MARKER)) {
|
||||||
|
console.warn("Auto-Recovery: Cloud-Modus gesetzt, aber Lokal-Daten vorhanden und kein Cloud-Login — räume Cloud-State auf.");
|
||||||
|
localStorage.removeItem("rapport_backend");
|
||||||
|
localStorage.removeItem("rapport_backend_chosen");
|
||||||
|
localStorage.removeItem("rapport_cloud_url");
|
||||||
|
localStorage.setItem(RECOVERY_MARKER, "1");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Auto-Cloud-Default für Web-Deploy ────────────────────────────────
|
||||||
|
// Nur wenn keine lokalen Daten existieren und der Browser auf einer
|
||||||
|
// konfigurierten Web-Instanz landet (envUrl aus build).
|
||||||
|
if (import.meta.env.PROD && envUrl && !localStorage.getItem("rapport_backend_chosen") && !hasLocalData) {
|
||||||
localStorage.setItem("rapport_backend_chosen", "1");
|
localStorage.setItem("rapport_backend_chosen", "1");
|
||||||
localStorage.setItem("rapport_backend", "cloud");
|
localStorage.setItem("rapport_backend", "cloud");
|
||||||
if (!localStorage.getItem("rapport_cloud_url")) {
|
if (!localStorage.getItem("rapport_cloud_url")) {
|
||||||
@@ -66,14 +113,15 @@ function createAdapter() {
|
|||||||
const backend = (typeof localStorage !== "undefined"
|
const backend = (typeof localStorage !== "undefined"
|
||||||
&& localStorage.getItem("rapport_backend")) || "local";
|
&& localStorage.getItem("rapport_backend")) || "local";
|
||||||
if (backend === "cloud") {
|
if (backend === "cloud") {
|
||||||
const url = import.meta.env.VITE_SUPABASE_URL;
|
// URL kommt bevorzugt aus localStorage (vom User eingegeben). Tauri hat
|
||||||
const key = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
// gar keine env-URL; Web-Build hat sie ggf. als Fallback.
|
||||||
if (!url || !key) {
|
const url = (typeof localStorage !== "undefined" && localStorage.getItem("rapport_cloud_url")) || envUrl;
|
||||||
console.warn("rapport_backend=cloud, aber VITE_SUPABASE_URL/ANON_KEY fehlen — Fallback auf LocalStorage.");
|
if (!url || !envKey) {
|
||||||
|
console.warn("rapport_backend=cloud, aber URL oder ANON_KEY fehlen — Fallback auf LocalStorage.");
|
||||||
return new LocalStorageAdapter();
|
return new LocalStorageAdapter();
|
||||||
}
|
}
|
||||||
console.info("Storage-Adapter: SupabaseAdapter aktiv (URL:", url + ")");
|
console.info("Storage-Adapter: SupabaseAdapter aktiv (URL:", url + ")");
|
||||||
return new SupabaseAdapter(url, key);
|
return new SupabaseAdapter(url, envKey);
|
||||||
}
|
}
|
||||||
return new LocalStorageAdapter();
|
return new LocalStorageAdapter();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,7 +7,10 @@ import React from "react";
|
|||||||
// Lokal → bestehender Setup.jsx
|
// Lokal → bestehender Setup.jsx
|
||||||
// Cloud → Login mit Init-Modus oder Login-Modus (je nach Studio-Vorhandensein)
|
// Cloud → Login mit Init-Modus oder Login-Modus (je nach Studio-Vorhandensein)
|
||||||
|
|
||||||
const envCloudUrl = import.meta.env.VITE_SUPABASE_URL || "";
|
// Tauri-User geben die Server-URL immer aktiv ein. Build-time-URL ist nur
|
||||||
|
// für Web-Deploy gedacht (z.B. app.rapport.kgva.ch → 127.0.0.1:54321 lokal).
|
||||||
|
const isTauri = typeof window !== "undefined" && !!window.__TAURI_INTERNALS__;
|
||||||
|
const envCloudUrl = isTauri ? "" : (import.meta.env.VITE_SUPABASE_URL || "");
|
||||||
|
|
||||||
export default function BackendChoice() {
|
export default function BackendChoice() {
|
||||||
const pick = (backend, cloudUrl = "") => {
|
const pick = (backend, cloudUrl = "") => {
|
||||||
|
|||||||
+17
-4
@@ -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}` : ""}
|
||||||
|
|||||||
@@ -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 )
|
||||||
);
|
);
|
||||||
|
|
||||||
-- ────────────────────────────────────────────────────────────────────────────
|
-- ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user