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:
+276
-99
@@ -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 <ViewFallback />;
|
||||
|
||||
// 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 <BackendChoice />;
|
||||
}
|
||||
|
||||
// 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 <Setup onComplete={handleSetupComplete} />;
|
||||
}
|
||||
|
||||
if (!localStorage.getItem("rapport_v0_5_migrated")) {
|
||||
if (!isCloudBackend && !localStorage.getItem("rapport_v0_5_migrated")) {
|
||||
return <MigrationScreen data={data} onComplete={handleSetupComplete} />;
|
||||
}
|
||||
|
||||
// Passwort-Reset hat höchste Priorität — User kommt von Mail-Link
|
||||
if (passwordRecovery) {
|
||||
return <ResetPassword
|
||||
onComplete={async (newPw) => {
|
||||
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 <Login verifyLogin={verifyLogin} settings={data.settings} version="0.7" />;
|
||||
if (isCloudBackend && cloudStudios !== null && cloudStudios.length === 0) {
|
||||
const cloudUrl = localStorage.getItem("rapport_cloud_url") || "";
|
||||
return <CloudSetup cloudInit={cloudInit} cloudUrl={cloudUrl} />;
|
||||
}
|
||||
return <Login verifyLogin={verifyLogin} settings={data.settings} version="0.8" />;
|
||||
}
|
||||
|
||||
if (printContent) {
|
||||
@@ -607,8 +771,8 @@ export default function App() {
|
||||
<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" }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = "#aaa"} onMouseLeave={e => e.currentTarget.style.color = "#555"}>ÜBER RAPPORT</button>
|
||||
<button onClick={() => { setChangelogVersion("0.7"); 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.7</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" }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = "#f0ede8"} onMouseLeave={e => e.currentTarget.style.color = "#aaa"}>0.8</button>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
@@ -652,7 +816,7 @@ export default function App() {
|
||||
{view === "projects" && !selectedProjectId && <Projects data={data} update={update} saveAll={save} modal={modal} setModal={setModal} onSelect={setSelectedProjectId} setPrintContent={setPrintContent} currentUser={currentUser} />}
|
||||
{view === "projects" && selectedProjectId && <ProjectDetail data={data} update={update} saveAll={save} projectId={selectedProjectId} onBack={() => setSelectedProjectId(null)} setPrintContent={setPrintContent} modal={modal} setModal={setModal} currentUser={currentUser} />}
|
||||
{view === "time" && <Time data={data} update={update} currentUser={currentUser} setPrintContent={setPrintContent} />}
|
||||
{view === "quotes" && <Quotes data={data} update={update} setData={setData} saveAll={save} modal={modal} setModal={setModal} setPrintContent={setPrintContent} setView={navigate} onSelectProject={setSelectedProjectId} />}
|
||||
{view === "quotes" && <Quotes data={data} update={update} saveAll={save} modal={modal} setModal={setModal} setPrintContent={setPrintContent} setView={navigate} onSelectProject={setSelectedProjectId} />}
|
||||
{view === "dokumente" && <Documents data={data} setView={navigate} />}
|
||||
{view === "lieferscheine" && <DeliveryNotes data={data} update={update} saveAll={save} setPrintContent={setPrintContent} />}
|
||||
{view === "protokolle" && <Protocols data={data} update={update} saveAll={save} setPrintContent={setPrintContent} />}
|
||||
@@ -673,6 +837,19 @@ export default function App() {
|
||||
|
||||
{showChangelog && (() => {
|
||||
const CHANGELOGS = {
|
||||
"0.8": {
|
||||
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."],
|
||||
["Erst-Einrichtung als Wizard", "Cloud-Einrichtung als 3-Schritt-Assistent: Studio-Stammdaten, Admin-Account, optionale Buchhaltungsdaten (IBAN, MwSt, Stundensatz). Adresse und Bankverbindung sind optional und können später ergänzt werden."],
|
||||
["Multi-Studio", "Ein Account kann mehrere Studios verwalten. Beim Anlegen eines neuen Studios lassen sich Personen (Kunden & Partner) aus bestehenden Studios übernehmen — Änderungen sind dann für alle verlinkten Studios sichtbar."],
|
||||
["Live-Sync zwischen Browsern", "Änderungen in einem Browser (z.B. neue Pinnwand-Notiz) erscheinen in allen anderen offenen Rapport-Tabs ohne Reload. Funktioniert über Postgres-Realtime."],
|
||||
["Mitarbeiter einladen", "Admins können Mitarbeiter direkt in den Einstellungen einladen: Email + Anzeigename + App-Rolle + temporäres Passwort. Eingeladene erhalten Zugangsdaten zum sofortigen Login."],
|
||||
["Passwort-Reset", "Im Cloud-Modus gibt es einen «Passwort vergessen?»-Link auf der Anmeldeseite. Mit der hinterlegten Email wird ein Reset-Link per Mail verschickt."],
|
||||
["Web-Version", "Wer keine Desktop-App installieren möchte, kann Rapport im Browser unter der Studio-Adresse nutzen (z.B. app.rapport.kgva.ch). Identische UI, gleiches Backend, kein Tauri nötig."],
|
||||
["Sichere Datenhaltung pro Studio", "Daten verschiedener Studios sind auf Datenbank-Ebene strikt getrennt (Row-Level-Security). Kein Studio sieht je die Daten eines anderen."],
|
||||
["Persönliche Zugangsdaten via Email", "Cloud-Anmeldung mit Email + Passwort (statt Benutzername). Lokal-Modus weiter wie gehabt mit Benutzername + Passwort."],
|
||||
],
|
||||
},
|
||||
"0.7": {
|
||||
items: [
|
||||
["Automatische Updates", "Rapport prüft beim Start, ob eine neue Version unter git.kgva.ch verfügbar ist, und installiert sie auf Knopfdruck — kein manuelles DMG-Download mehr nötig. Updates lassen sich überspringen oder verschieben; Pakete werden vor der Installation per Signaturprüfung verifiziert."],
|
||||
@@ -737,7 +914,7 @@ export default function App() {
|
||||
},
|
||||
};
|
||||
const versions = Object.keys(CHANGELOGS);
|
||||
const current = CHANGELOGS[changelogVersion] || CHANGELOGS["0.7"];
|
||||
const current = CHANGELOGS[changelogVersion] || CHANGELOGS["0.8"];
|
||||
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={{ background: "#fff", borderRadius: 10, width: "100%", maxWidth: 480, boxShadow: "0 8px 40px rgba(0,0,0,0.18)", overflow: "hidden" }}>
|
||||
@@ -766,7 +943,7 @@ export default function App() {
|
||||
))}
|
||||
</div>
|
||||
<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.7"); }}>
|
||||
<button className="btn btn-primary" style={{ width: "100%", fontSize: 13 }} onClick={() => { setShowChangelog(false); localStorage.setItem("rapport_changelog_seen", "0.8"); }}>
|
||||
Schliessen
|
||||
</button>
|
||||
</div>
|
||||
@@ -781,7 +958,7 @@ export default function App() {
|
||||
<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={{ 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.7 · Studio-Management für Architekturbüros</div>
|
||||
<div style={{ fontSize: 11, color: "#888", marginTop: 6, letterSpacing: "0.04em" }}>Alpha 0.8 · Studio-Management für Architekturbüros</div>
|
||||
</div>
|
||||
<div style={{ padding: "20px 32px 8px" }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: "#888", letterSpacing: "0.1em", marginBottom: 12 }}>LIZENZ</div>
|
||||
|
||||
Reference in New Issue
Block a user