27b1057cd4
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>
418 lines
17 KiB
React
Executable File
418 lines
17 KiB
React
Executable File
import React, { useEffect, useRef, useState } from "react";
|
|
import { storage, isCloudBackend } from "../storage/adapter.js";
|
|
|
|
const isValidEmail = (s) => /.+@.+\..+/.test(s);
|
|
|
|
// Simple per-tab rate limit: after MAX_ATTEMPTS failed tries, lock for LOCK_MS.
|
|
const MAX_ATTEMPTS = 5;
|
|
const LOCK_MS = 60_000;
|
|
const ATTEMPT_KEY = "rapport_login_attempts";
|
|
|
|
function readAttempts() {
|
|
try { return JSON.parse(sessionStorage.getItem(ATTEMPT_KEY)) || { count: 0, lockedUntil: 0 }; }
|
|
catch { return { count: 0, lockedUntil: 0 }; }
|
|
}
|
|
function writeAttempts(state) {
|
|
try { sessionStorage.setItem(ATTEMPT_KEY, JSON.stringify(state)); } catch {}
|
|
}
|
|
|
|
export default function Login({ verifyLogin, settings, version }) {
|
|
// Backend-Modus aus localStorage (per-Device, ähnlich Dark Mode).
|
|
// Beim Wechsel wird die App neu geladen, damit der Storage-Adapter neu initialisiert.
|
|
const [backend, setBackend] = useState(() => localStorage.getItem("rapport_backend") || "local");
|
|
const [cloudUrl, setCloudUrl] = useState(() => localStorage.getItem("rapport_cloud_url") || "");
|
|
const [editUrl, setEditUrl] = useState(() => {
|
|
const stored = localStorage.getItem("rapport_cloud_url") || "";
|
|
return (localStorage.getItem("rapport_backend") === "cloud") && !stored;
|
|
});
|
|
|
|
const [username, setUsername] = useState("");
|
|
const [password, setPassword] = useState("");
|
|
const [error, setError] = useState(false);
|
|
const [errorMsg, setErrorMsg] = useState("");
|
|
const [shake, setShake] = useState(false);
|
|
const [lockedUntil, setLockedUntil] = useState(() => readAttempts().lockedUntil);
|
|
const [now, setNow] = useState(Date.now());
|
|
const submittingRef = useRef(false);
|
|
|
|
// Cloud: Studios der Instanz für Multi-Studio-Dropdown
|
|
const [studios, setStudios] = useState([]);
|
|
const [selectedStudioId, setSelectedStudioId] = useState("");
|
|
// Passwort-Vergessen-Inline-Modus
|
|
const [forgotOpen, setForgotOpen] = useState(false);
|
|
const [forgotSent, setForgotSent] = useState(false);
|
|
const [forgotErr, setForgotErr] = useState("");
|
|
|
|
const isCloud = backend === "cloud";
|
|
|
|
useEffect(() => {
|
|
if (!isCloudBackend || !cloudUrl) return;
|
|
let cancelled = false;
|
|
(async () => {
|
|
const list = await storage.listStudios?.();
|
|
if (cancelled) return;
|
|
setStudios(list || []);
|
|
if (list?.length === 1) setSelectedStudioId(list[0].id);
|
|
})();
|
|
return () => { cancelled = true; };
|
|
}, [cloudUrl]);
|
|
|
|
// Tick once a second while locked, so the countdown updates and unlocks automatically
|
|
useEffect(() => {
|
|
if (!lockedUntil || lockedUntil <= now) return;
|
|
const id = setInterval(() => setNow(Date.now()), 1000);
|
|
return () => clearInterval(id);
|
|
}, [lockedUntil, now]);
|
|
|
|
const isLocked = lockedUntil > now;
|
|
const remainingSec = isLocked ? Math.ceil((lockedUntil - now) / 1000) : 0;
|
|
|
|
const switchBackend = (next) => {
|
|
if (next === backend) return;
|
|
localStorage.setItem("rapport_backend", next);
|
|
window.location.reload();
|
|
};
|
|
|
|
const handleSubmit = async (e) => {
|
|
e.preventDefault();
|
|
if (isLocked || submittingRef.current) return;
|
|
submittingRef.current = true;
|
|
try {
|
|
// Cloud: URL muss gesetzt sein, sonst kann der Adapter nicht initialisiert worden sein
|
|
if (isCloud) {
|
|
const trimmed = (cloudUrl || "").trim().replace(/\/+$/, "");
|
|
if (!trimmed) {
|
|
setError(true);
|
|
setErrorMsg("Server-Adresse eingeben.");
|
|
return;
|
|
}
|
|
const currentUrl = localStorage.getItem("rapport_cloud_url") || "";
|
|
if (trimmed !== currentUrl) {
|
|
localStorage.setItem("rapport_cloud_url", trimmed);
|
|
window.location.reload();
|
|
return;
|
|
}
|
|
}
|
|
|
|
const user = await Promise.resolve(verifyLogin(username, password, { studioId: selectedStudioId || null }));
|
|
if (user) {
|
|
writeAttempts({ count: 0, lockedUntil: 0 });
|
|
} else {
|
|
const state = readAttempts();
|
|
const count = state.count + 1;
|
|
const next = count >= MAX_ATTEMPTS
|
|
? { count: 0, lockedUntil: Date.now() + LOCK_MS }
|
|
: { count, lockedUntil: 0 };
|
|
writeAttempts(next);
|
|
setLockedUntil(next.lockedUntil);
|
|
setNow(Date.now());
|
|
setError(true);
|
|
setErrorMsg(isCloud ? "Anmeldung fehlgeschlagen." : "Falscher Benutzername oder Passwort");
|
|
setShake(true);
|
|
setTimeout(() => setShake(false), 500);
|
|
}
|
|
} finally {
|
|
submittingRef.current = false;
|
|
}
|
|
};
|
|
|
|
const studioHeader = settings?.name || "Studio";
|
|
const userLabel = isCloud ? "EMAIL" : "BENUTZER";
|
|
const userPlaceholder = isCloud ? "name@studio.ch" : "admin";
|
|
const userInputType = isCloud ? "email" : "text";
|
|
|
|
const showUrlField = isCloud && editUrl;
|
|
const showUrlBadge = isCloud && !editUrl && cloudUrl;
|
|
|
|
// Hostname zur Anzeige (ohne Protokoll, ohne Port falls Standard)
|
|
let urlDisplay = cloudUrl;
|
|
try {
|
|
const u = new URL(cloudUrl);
|
|
urlDisplay = u.host;
|
|
} catch {}
|
|
|
|
return (
|
|
<div style={{
|
|
minHeight: "100vh", minWidth: "100vw",
|
|
background: "#ebe7e1",
|
|
display: "flex", alignItems: "center", justifyContent: "center",
|
|
fontFamily: "'DM Mono', 'Courier New', monospace",
|
|
position: "fixed", inset: 0, zIndex: 9999,
|
|
}}>
|
|
<style>{`
|
|
@keyframes login-shake {
|
|
0%,100% { transform: translateX(0); }
|
|
20%,60% { transform: translateX(-8px); }
|
|
40%,80% { transform: translateX(8px); }
|
|
}
|
|
@keyframes login-fade-in {
|
|
from { opacity: 0; transform: translateY(16px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
.login-card {
|
|
animation: login-fade-in 0.5s cubic-bezier(0.22,1,0.36,1) both;
|
|
}
|
|
.login-card.shake {
|
|
animation: login-shake 0.45s ease;
|
|
}
|
|
.login-input {
|
|
width: 100%; box-sizing: border-box;
|
|
background: #f7f4f0; border: 1.5px solid #ddd8d0;
|
|
border-radius: 10px; padding: 11px 14px;
|
|
font-family: 'DM Mono', monospace; font-size: 13px;
|
|
color: #1a1a18; outline: none;
|
|
transition: border-color 0.18s, box-shadow 0.18s;
|
|
}
|
|
.login-input:focus {
|
|
border-color: #9a7858;
|
|
box-shadow: 0 0 0 3px rgba(154,120,88,0.14);
|
|
}
|
|
.login-input.error {
|
|
border-color: #b5621e;
|
|
box-shadow: 0 0 0 3px rgba(181,98,30,0.12);
|
|
}
|
|
.login-input:disabled {
|
|
opacity: 0.6;
|
|
}
|
|
.login-btn {
|
|
width: 100%; padding: 13px;
|
|
background: #1a1a18; color: #f0ede8;
|
|
border: none; border-radius: 10px;
|
|
font-family: 'DM Mono', monospace; font-size: 13px;
|
|
font-weight: 500; letter-spacing: 0.04em;
|
|
cursor: pointer; transition: background 0.18s, box-shadow 0.18s;
|
|
}
|
|
.login-btn:hover:not(:disabled) {
|
|
background: #2e2e28;
|
|
box-shadow: 0 4px 16px rgba(0,0,0,0.28);
|
|
}
|
|
.login-btn:active:not(:disabled) { background: #0e0e0c; }
|
|
.login-btn:disabled {
|
|
background: #c4bbb0;
|
|
cursor: not-allowed;
|
|
}
|
|
.backend-select {
|
|
background: transparent;
|
|
border: none;
|
|
color: #6a6660;
|
|
font-family: 'DM Mono', monospace;
|
|
font-size: 10px;
|
|
letter-spacing: 0.1em;
|
|
text-transform: uppercase;
|
|
padding: 2px 18px 2px 4px;
|
|
margin: 0;
|
|
cursor: pointer;
|
|
outline: none;
|
|
appearance: none;
|
|
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5'><path d='M1 1l3 3 3-3' stroke='%236a6660' stroke-width='1.2' fill='none' stroke-linecap='round'/></svg>");
|
|
background-repeat: no-repeat;
|
|
background-position: right 4px center;
|
|
}
|
|
.backend-select:hover { color: #1a1a18; }
|
|
.url-edit-link {
|
|
background: none; border: none; padding: 0;
|
|
color: #9a7858; font-family: inherit; font-size: 10px;
|
|
letter-spacing: 0.08em; cursor: pointer; text-decoration: underline;
|
|
}
|
|
.url-edit-link:hover { color: #1a1a18; }
|
|
`}</style>
|
|
|
|
<div className={`login-card${shake ? " shake" : ""}`} style={{
|
|
background: "#fdfcfa",
|
|
borderRadius: 20,
|
|
padding: "48px 44px 28px",
|
|
width: "100%", maxWidth: 380,
|
|
boxShadow: "0 8px 40px rgba(0,0,0,0.10), 0 2px 8px rgba(0,0,0,0.07)",
|
|
border: "1px solid #ddd8d0",
|
|
margin: "0 20px",
|
|
}}>
|
|
<div style={{ textAlign: "center", marginBottom: 36 }}>
|
|
<div style={{ fontFamily: "Krungthep, 'Archivo Black', sans-serif", fontSize: 36, color: "#1a1a18", letterSpacing: "-0.02em", lineHeight: 1 }}>
|
|
RAPPORT
|
|
</div>
|
|
<div style={{ fontSize: 9, color: "#b0aca4", letterSpacing: "0.2em", marginTop: 8, fontWeight: 500 }}>
|
|
{studioHeader.toUpperCase()}
|
|
</div>
|
|
<div style={{ width: 32, height: 1.5, background: "#ddd8d0", margin: "16px auto 0" }} />
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit}>
|
|
{isCloud && studios.length > 1 && (
|
|
<div style={{ marginBottom: 14 }}>
|
|
<label style={{ display: "block", fontSize: 9, letterSpacing: "0.15em", color: "#8c8880", marginBottom: 7, fontWeight: 500 }}>
|
|
STUDIO
|
|
</label>
|
|
<select
|
|
className={`login-input${error ? " error" : ""}`}
|
|
disabled={isLocked}
|
|
value={selectedStudioId}
|
|
onChange={e => { setSelectedStudioId(e.target.value); setError(false); }}
|
|
style={{ appearance: "none", paddingRight: 32, backgroundImage: "url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path d='M1 1l4 4 4-4' stroke='%23999' stroke-width='1.5' fill='none' stroke-linecap='round'/></svg>\")", backgroundRepeat: "no-repeat", backgroundPosition: "right 12px center" }}
|
|
>
|
|
<option value="">Studio auswählen…</option>
|
|
{studios.map(s => (
|
|
<option key={s.id} value={s.id}>{s.name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ marginBottom: 14 }}>
|
|
<label style={{ display: "block", fontSize: 9, letterSpacing: "0.15em", color: "#8c8880", marginBottom: 7, fontWeight: 500 }}>
|
|
{userLabel}
|
|
</label>
|
|
<input
|
|
className={`login-input${error ? " error" : ""}`}
|
|
type={userInputType}
|
|
autoComplete={isCloud ? "email" : "username"}
|
|
autoFocus
|
|
disabled={isLocked}
|
|
value={username}
|
|
onChange={e => { setUsername(e.target.value); setError(false); }}
|
|
placeholder={userPlaceholder}
|
|
/>
|
|
</div>
|
|
|
|
<div style={{ marginBottom: showUrlField ? 14 : 28 }}>
|
|
<label style={{ display: "block", fontSize: 9, letterSpacing: "0.15em", color: "#8c8880", marginBottom: 7, fontWeight: 500 }}>
|
|
PASSWORT
|
|
</label>
|
|
<input
|
|
className={`login-input${error ? " error" : ""}`}
|
|
type="password"
|
|
autoComplete="current-password"
|
|
disabled={isLocked}
|
|
value={password}
|
|
onChange={e => { setPassword(e.target.value); setError(false); }}
|
|
placeholder="••••••"
|
|
/>
|
|
</div>
|
|
|
|
{showUrlField && (
|
|
<div style={{ marginBottom: 28 }}>
|
|
<label style={{ display: "block", fontSize: 9, letterSpacing: "0.15em", color: "#8c8880", marginBottom: 7, fontWeight: 500 }}>
|
|
SERVER-ADRESSE
|
|
</label>
|
|
<input
|
|
className="login-input"
|
|
type="url"
|
|
disabled={isLocked}
|
|
value={cloudUrl}
|
|
onChange={e => { setCloudUrl(e.target.value); setError(false); }}
|
|
placeholder="http://mac-mini.local:54321"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{isLocked ? (
|
|
<div style={{ marginBottom: 18, padding: "9px 14px", background: "#fdf2f2", borderRadius: 8, border: "1px solid #e0b0b0", fontSize: 11, color: "#8a1a1a", textAlign: "center" }}>
|
|
Zu viele Fehlversuche. Bitte {remainingSec}s warten.
|
|
</div>
|
|
) : error && (
|
|
<div style={{ marginBottom: 18, padding: "9px 14px", background: "#fff5f0", borderRadius: 8, border: "1px solid #f5c9b0", fontSize: 11, color: "#b5621e", textAlign: "center" }}>
|
|
{errorMsg || (isCloud ? "Anmeldung fehlgeschlagen." : "Falscher Benutzername oder Passwort")}
|
|
</div>
|
|
)}
|
|
|
|
<button className="login-btn" type="submit" disabled={isLocked}>
|
|
Anmelden
|
|
</button>
|
|
</form>
|
|
|
|
{/* Passwort vergessen — nur Cloud */}
|
|
{isCloud && !isLocked && !forgotOpen && !forgotSent && (
|
|
<div style={{ marginTop: 14, textAlign: "center", fontSize: 10 }}>
|
|
<button type="button" className="url-edit-link" onClick={() => { setForgotOpen(true); setForgotErr(""); }}>
|
|
Passwort vergessen?
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{isCloud && forgotOpen && !forgotSent && (
|
|
<div style={{ marginTop: 18, padding: "14px 14px 12px", background: "#f7f4f0", border: "1px solid #ddd8d0", borderRadius: 10 }}>
|
|
<div style={{ fontSize: 11, color: "#666", marginBottom: 10, lineHeight: 1.5 }}>
|
|
Wir senden Ihnen eine Email mit einem Link zum Zurücksetzen des Passworts.
|
|
</div>
|
|
<input
|
|
className="login-input"
|
|
type="email"
|
|
placeholder="name@studio.ch"
|
|
value={username}
|
|
onChange={e => setUsername(e.target.value)}
|
|
style={{ marginBottom: 8 }}
|
|
/>
|
|
{forgotErr && <div style={{ fontSize: 11, color: "#b5621e", marginBottom: 8 }}>{forgotErr}</div>}
|
|
<div style={{ display: "flex", gap: 8 }}>
|
|
<button
|
|
type="button"
|
|
className="login-btn"
|
|
style={{ flex: 1, padding: "9px", fontSize: 12 }}
|
|
onClick={async () => {
|
|
if (!isValidEmail(username)) { setForgotErr("Bitte gültige Email eingeben."); return; }
|
|
const res = await storage.requestPasswordReset?.(username.trim());
|
|
if (res?.ok) { setForgotSent(true); setForgotErr(""); }
|
|
else setForgotErr(res?.error || "Fehler beim Versand.");
|
|
}}
|
|
>
|
|
Link senden
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="login-btn"
|
|
style={{ flex: 1, padding: "9px", fontSize: 12, background: "transparent", color: "#888", border: "1px solid #ddd8d0" }}
|
|
onClick={() => { setForgotOpen(false); setForgotErr(""); }}
|
|
>
|
|
Abbrechen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{isCloud && forgotSent && (
|
|
<div style={{ marginTop: 14, padding: "10px 14px", background: "#e8f5ee", border: "1px solid #b8dbc4", borderRadius: 8, fontSize: 11, color: "#2d6a4f", textAlign: "center", lineHeight: 1.5 }}>
|
|
Email gesendet — bitte Ihren Posteingang prüfen.
|
|
</div>
|
|
)}
|
|
|
|
{/* Verbindung-Switch + Server-Anzeige (dezent darunter) */}
|
|
<div style={{
|
|
marginTop: 22, paddingTop: 16,
|
|
borderTop: "1px solid #ebe7e1",
|
|
display: "flex", alignItems: "center", justifyContent: "space-between",
|
|
gap: 8, flexWrap: "wrap",
|
|
}}>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
|
<span style={{ fontSize: 9, color: "#b0aca4", letterSpacing: "0.12em", textTransform: "uppercase" }}>
|
|
Verbindung
|
|
</span>
|
|
<select
|
|
className="backend-select"
|
|
value={backend}
|
|
onChange={e => switchBackend(e.target.value)}
|
|
>
|
|
<option value="local">Lokal</option>
|
|
<option value="cloud">Cloud</option>
|
|
</select>
|
|
</div>
|
|
{showUrlBadge && (
|
|
<div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 10, color: "#8c8880", letterSpacing: "0.05em" }}>
|
|
<span title={cloudUrl}>{urlDisplay}</span>
|
|
<button
|
|
type="button"
|
|
className="url-edit-link"
|
|
onClick={() => setEditUrl(true)}
|
|
>
|
|
ändern
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div style={{ marginTop: 16, textAlign: "center", fontSize: 9, color: "#c8c4be", letterSpacing: "0.08em" }}>
|
|
{version ? `V${version}` : ""}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|