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:
Executable
+423
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user