Rapport 0.6 — Initial Public Release

Sicherheits-Hardening
- Passwort-Hashing mit PBKDF2 (SHA-256, 100k Iterationen) inkl. transparenter
  Migration bestehender Klartext-Passwörter beim ersten Login
- Login Brute-Force-Schutz (5 Fehlversuche → 60s Lockout), Constant-Time-Compare,
  Mindestpasswortlänge 8 Zeichen
- HTML-Sanitizer für Brieftexte (Allowlist, entfernt javascript:/data:/vbscript:-URLs,
  Event-Handler, Script-Tags; rel=noopener für target=_blank)
- Datenexport entfernt Legacy-Klartextpasswörter (Hashes bleiben)
- Kryptografische IDs via crypto.randomUUID statt Math.random
- sessionStorage speichert keine Credentials mehr

GUI & Performance
- Code-Splitting pro View via React.lazy + Suspense (Initial-Bundle 86 KB gzipped)
- swissqrbill als lokale Dependency — QR-Rechnungen offline-fähig
- Spesenbelege (Bild/PDF) direkt in der Tageserfassung mit Bildkomprimierung
- Avatar-Upload: 256px-Skalierung + JPEG-Kompression, Typprüfung
- Über-Rapport-Modal, einheitliche Bearbeiten-Icons, Pinnwand-Kategorien als Pills

Bug-Fixes
- Auto-überfällig-Routine läuft nur noch einmal pro Tag (verhindert Re-Render-Loop)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-13 01:16:26 +02:00
commit 00f07d76f6
65 changed files with 28010 additions and 0 deletions
+423
View File
@@ -0,0 +1,423 @@
import React, { useState, useRef } from "react";
import { defaultData } from "../constants.js";
import { generateId, hashPassword } from "../utils.js";
// ─── Palette — matches the app's light theme exactly ─────────────────────────
const C = {
bg: "#ebe7e1",
surface: "#fdfcfa",
surface2: "#f7f4f0",
border: "#ddd8d0",
border2: "#e6e1da",
text: "#1a1a18",
text3: "#6a6660",
text4: "#8c8880",
danger: "#8a1a1a",
dangerBg: "#fdf2f2",
dangerBorder: "#e0b0b0",
};
const S = {
wrap: {
background: C.bg,
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontFamily: "'DM Mono','Courier New',monospace",
color: C.text,
padding: "32px 16px",
},
card: {
width: "100%",
maxWidth: 460,
background: C.surface,
borderRadius: 14,
padding: "44px 40px",
boxShadow: "0 2px 24px rgba(60,50,40,0.10)",
border: `1px solid ${C.border}`,
},
logo: {
fontFamily: "Krungthep,'Archivo Black',sans-serif",
fontSize: 34,
color: C.text,
letterSpacing: "-0.02em",
textAlign: "center",
marginBottom: 4,
},
sub: {
textAlign: "center",
fontSize: 10,
color: C.text4,
letterSpacing: "0.14em",
marginBottom: 32,
},
progress: {
display: "flex",
gap: 6,
justifyContent: "center",
marginBottom: 32,
},
dot: (active, done) => ({
width: active ? 22 : 8,
height: 8,
borderRadius: 4,
background: done ? C.text : active ? C.text : C.border,
opacity: done ? 1 : active ? 1 : 0.4,
transition: "all 0.3s",
}),
label: {
fontSize: 9,
letterSpacing: "0.14em",
color: C.text4,
display: "block",
marginBottom: 5,
marginTop: 16,
},
input: {
width: "100%",
boxSizing: "border-box",
background: C.surface,
border: `1px solid ${C.border}`,
borderRadius: 6,
padding: "9px 12px",
color: C.text,
fontFamily: "'DM Mono',monospace",
fontSize: 13,
outline: "none",
transition: "border-color 0.15s",
},
inputErr: { border: `1px solid ${C.dangerBorder}` },
textarea: {
width: "100%",
boxSizing: "border-box",
background: C.surface,
border: `1px solid ${C.border}`,
borderRadius: 6,
padding: "9px 12px",
color: C.text,
fontFamily: "'DM Mono',monospace",
fontSize: 13,
outline: "none",
resize: "vertical",
minHeight: 64,
},
err: { fontSize: 11, color: C.danger, marginTop: 4 },
hint: { fontSize: 11, color: C.text4, marginTop: 5, lineHeight: 1.5 },
btnPrimary: {
width: "100%",
background: C.text,
border: "none",
borderRadius: 8,
padding: "12px 0",
color: "#fff",
fontFamily: "'DM Mono',monospace",
fontSize: 13,
cursor: "pointer",
marginTop: 28,
letterSpacing: "0.04em",
transition: "opacity 0.15s",
},
btnGhost: {
width: "100%",
background: "transparent",
border: `1px solid ${C.border}`,
borderRadius: 8,
padding: "10px 0",
color: C.text4,
fontFamily: "'DM Mono',monospace",
fontSize: 12,
cursor: "pointer",
marginTop: 10,
},
};
// ─── Prompt for users with existing data ─────────────────────────────────────
export function SetupPrompt({ onSetup, onSkip }) {
return (
<div style={S.wrap}>
<div style={S.card}>
<div style={S.logo}>RAPPORT</div>
<div style={S.sub}>NEUE VERSION</div>
<div style={{ fontFamily: "'Playfair Display',serif", fontSize: 20, color: C.text, marginBottom: 10 }}>
Neuer Einrichtungsassistent.
</div>
<div style={{ fontSize: 12, color: C.text3, lineHeight: 1.7, marginBottom: 24 }}>
Rapport verfügt über einen neuen Setup-Assistenten für Studio, Admin-Account und Mitarbeiterprofil.
Möchtest du ihn durchlaufen?
</div>
<div style={{ background: C.dangerBg, border: `1px solid ${C.dangerBorder}`, borderRadius: 8, padding: "10px 14px", fontSize: 11, color: C.danger, marginBottom: 4 }}>
Neu einrichten löscht alle bestehenden Daten unwiderruflich.
</div>
<button onClick={onSetup} style={{ ...S.btnPrimary, marginTop: 16 }}>Neu einrichten</button>
<button onClick={onSkip} style={S.btnGhost}>Vorhandene Daten behalten </button>
</div>
</div>
);
}
// ─── Main setup wizard ────────────────────────────────────────────────────────
export default function Setup({ onComplete }) {
const TOTAL = 3;
const [step, setStep] = useState(1);
const [studio, setStudio] = useState({ name: "", address: "", email: "", phone: "" });
const [account, setAccount] = useState({ displayName: "", username: "admin", password: "", confirm: "" });
const [errors, setErrors] = useState({});
const [showPw, setShowPw] = useState(false);
const [importErr, setImportErr] = useState("");
const importRef = useRef(null);
const handleImport = (e) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
try {
const parsed = JSON.parse(ev.target.result);
if (!parsed.settings || !Array.isArray(parsed.projects)) {
setImportErr("Ungültiges Backup-Format.");
return;
}
onComplete({
...defaultData,
...parsed,
settings: { ...defaultData.settings, ...parsed.settings, setupCompleted: true },
appRoles: parsed.appRoles || defaultData.appRoles,
users: parsed.users || defaultData.users,
});
} catch {
setImportErr("Datei konnte nicht gelesen werden.");
}
};
reader.readAsText(file);
e.target.value = "";
};
const setS = (k, v) => setStudio(s => ({ ...s, [k]: v }));
const setA = (k, v) => setAccount(a => ({ ...a, [k]: v }));
const clearErr = k => setErrors(e => { const n = { ...e }; delete n[k]; return n; });
const validate = () => {
const errs = {};
if (step === 1 && !studio.name.trim()) errs.name = "Pflichtfeld";
if (step === 2) {
if (!account.displayName.trim()) errs.displayName = "Pflichtfeld";
if (!account.username.trim()) errs.username = "Pflichtfeld";
if (account.password.length < 8) errs.password = "Mindestens 8 Zeichen";
if (account.password !== account.confirm) errs.confirm = "Passwörter stimmen nicht überein";
}
setErrors(errs);
return Object.keys(errs).length === 0;
};
const next = () => { if (validate()) setStep(s => s + 1); };
const back = () => setStep(s => s - 1);
const [busy, setBusy] = useState(false);
const finish = async () => {
if (busy) return;
setBusy(true);
try {
const empId = generateId();
const { hash, salt } = await hashPassword(account.password);
onComplete({
...defaultData,
settings: {
...defaultData.settings,
setupCompleted: true,
name: studio.name.trim(),
address: studio.address.trim() || defaultData.settings.address,
email: studio.email.trim() || defaultData.settings.email,
phone: studio.phone.trim() || defaultData.settings.phone,
},
users: [{
id: "admin",
username: account.username.trim(),
passwordHash: hash,
passwordSalt: salt,
role: "admin",
displayName: account.displayName.trim(),
appRoleId: "r-admin",
employeeId: empId,
}],
employees: [{
id: empId,
name: account.displayName.trim(),
email: studio.email.trim() || "",
wochenstunden: defaultData.settings.defaultWochenstunden || 35,
ferienWochen: defaultData.settings.defaultFerienWochen || 5,
eintrittsdatum: new Date().toISOString().slice(0, 10),
aktiv: true,
appUserId: "admin",
}],
});
} finally {
setBusy(false);
}
};
const progressEl = (
<div style={S.progress}>
{Array.from({ length: TOTAL }, (_, i) => (
<div key={i} style={S.dot(i + 1 === step, i + 1 < step)} />
))}
</div>
);
const renderHeader = (n, title, lead) => (
<>
<div style={S.logo}>RAPPORT</div>
<div style={S.sub}>SCHRITT {n} VON {TOTAL}</div>
{progressEl}
<div style={{ fontFamily: "'Playfair Display',serif", fontSize: 20, color: C.text, marginBottom: 6 }}>{title}</div>
{lead && <div style={{ fontSize: 12, color: C.text3, lineHeight: 1.65, marginBottom: 20 }}>{lead}</div>}
</>
);
// ── Step 1: Studio ──────────────────────────────────────────────────────────
if (step === 1) return (
<div style={S.wrap}>
<div style={S.card}>
{renderHeader(1, "Willkommen.", "Lass uns Rapport für dein Studio einrichten. Das dauert weniger als zwei Minuten.")}
<label style={S.label}>STUDIO / UNTERNEHMEN *</label>
<input
style={{ ...S.input, ...(errors.name ? S.inputErr : {}) }}
placeholder="Muster Architektur GmbH"
autoFocus
value={studio.name}
onChange={e => { setS("name", e.target.value); clearErr("name"); }}
onKeyDown={e => e.key === "Enter" && next()}
/>
{errors.name && <div style={S.err}>{errors.name}</div>}
<label style={S.label}>ADRESSE</label>
<textarea
style={S.textarea}
placeholder={"Musterstrasse 1\n8001 Zürich"}
value={studio.address}
onChange={e => setS("address", e.target.value)}
/>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
<div>
<label style={S.label}>E-MAIL</label>
<input style={S.input} type="email" placeholder="mail@studio.ch"
value={studio.email} onChange={e => setS("email", e.target.value)} />
</div>
<div>
<label style={S.label}>TELEFON</label>
<input style={S.input} placeholder="+41 44 000 00 00"
value={studio.phone} onChange={e => setS("phone", e.target.value)} />
</div>
</div>
<button style={S.btnPrimary} onClick={next}>Weiter </button>
<div style={{ display: "flex", alignItems: "center", gap: 10, margin: "18px 0 4px" }}>
<div style={{ flex: 1, height: 1, background: C.border }} />
<span style={{ fontSize: 10, color: C.text4, letterSpacing: "0.1em" }}>ODER</span>
<div style={{ flex: 1, height: 1, background: C.border }} />
</div>
<input ref={importRef} type="file" accept=".json" style={{ display: "none" }} onChange={handleImport} />
<button style={S.btnGhost} onClick={() => { setImportErr(""); importRef.current?.click(); }}>
Backup importieren
</button>
{importErr && <div style={{ ...S.err, textAlign: "center", marginTop: 8 }}>{importErr}</div>}
</div>
</div>
);
// ── Step 2: Admin-Account ───────────────────────────────────────────────────
if (step === 2) return (
<div style={S.wrap}>
<div style={S.card}>
{renderHeader(2, "Dein Account.", "Dieser Account hat vollen Systemzugriff. Weitere Mitarbeitende können später mit eingeschränkten Berechtigungen hinzugefügt werden.")}
<label style={S.label}>DEIN NAME *</label>
<input
style={{ ...S.input, ...(errors.displayName ? S.inputErr : {}) }}
placeholder="Anna Muster"
autoFocus
value={account.displayName}
onChange={e => { setA("displayName", e.target.value); clearErr("displayName"); }}
/>
{errors.displayName && <div style={S.err}>{errors.displayName}</div>}
<div style={S.hint}>Wird als Mitarbeiterprofil angelegt und im System angezeigt.</div>
<label style={S.label}>BENUTZERNAME *</label>
<input
style={{ ...S.input, ...(errors.username ? S.inputErr : {}) }}
placeholder="admin"
value={account.username}
onChange={e => { setA("username", e.target.value.toLowerCase().replace(/\s/g, "")); clearErr("username"); }}
/>
{errors.username && <div style={S.err}>{errors.username}</div>}
<label style={S.label}>PASSWORT *</label>
<div style={{ position: "relative" }}>
<input
style={{ ...S.input, ...(errors.password ? S.inputErr : {}), paddingRight: 80 }}
type={showPw ? "text" : "password"}
placeholder="Mindestens 8 Zeichen"
value={account.password}
onChange={e => { setA("password", e.target.value); clearErr("password"); }}
/>
<button onClick={() => setShowPw(v => !v)}
style={{ position: "absolute", right: 10, top: "50%", transform: "translateY(-50%)", background: "none", border: "none", color: C.text4, cursor: "pointer", fontSize: 11, fontFamily: "inherit" }}>
{showPw ? "verbergen" : "anzeigen"}
</button>
</div>
{errors.password && <div style={S.err}>{errors.password}</div>}
<label style={S.label}>PASSWORT BESTÄTIGEN *</label>
<input
style={{ ...S.input, ...(errors.confirm ? S.inputErr : {}) }}
type={showPw ? "text" : "password"}
placeholder="Nochmals eingeben"
value={account.confirm}
onChange={e => { setA("confirm", e.target.value); clearErr("confirm"); }}
onKeyDown={e => e.key === "Enter" && next()}
/>
{errors.confirm && <div style={S.err}>{errors.confirm}</div>}
<button style={S.btnPrimary} onClick={next}>Weiter </button>
<button style={S.btnGhost} onClick={back}> Zurück</button>
</div>
</div>
);
// ── Step 3: Done ────────────────────────────────────────────────────────────
return (
<div style={S.wrap}>
<div style={S.card}>
{renderHeader(3, "Alles bereit.")}
{[
{ label: "STUDIO", value: studio.name },
{ label: "MITARBEITER", value: account.displayName },
{ label: "BENUTZERNAME", value: account.username },
{ label: "ADRESSE", value: studio.address || "—" },
].map(({ label, value }) => (
<div key={label} style={{ padding: "10px 0", borderBottom: `1px solid ${C.border2}`, display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 16 }}>
<div style={{ fontSize: 9, color: C.text4, letterSpacing: "0.12em", flexShrink: 0 }}>{label}</div>
<div style={{ fontSize: 12, color: C.text, textAlign: "right" }}>{value}</div>
</div>
))}
<div style={{ marginTop: 20, padding: "12px 14px", background: C.surface2, borderRadius: 8, border: `1px solid ${C.border}`, fontSize: 11, color: C.text3, lineHeight: 1.65 }}>
Projekte, Mitarbeitende, Einstellungen und Berechtigungen können jederzeit in der App angepasst werden.
</div>
<button style={S.btnPrimary} onClick={finish} disabled={busy}>{busy ? "…" : "Rapport starten →"}</button>
<button style={S.btnGhost} onClick={back}> Zurück</button>
</div>
</div>
);
}