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
+197
@@ -0,0 +1,197 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
|
||||
// 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 }) {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState(false);
|
||||
const [shake, setShake] = useState(false);
|
||||
const [lockedUntil, setLockedUntil] = useState(() => readAttempts().lockedUntil);
|
||||
const [now, setNow] = useState(Date.now());
|
||||
const submittingRef = useRef(false);
|
||||
|
||||
// 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 handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (isLocked || submittingRef.current) return;
|
||||
submittingRef.current = true;
|
||||
try {
|
||||
const user = await Promise.resolve(verifyLogin(username, password));
|
||||
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);
|
||||
setShake(true);
|
||||
setTimeout(() => setShake(false), 500);
|
||||
}
|
||||
} finally {
|
||||
submittingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const studioName = settings?.name || "Studio";
|
||||
|
||||
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;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div className={`login-card${shake ? " shake" : ""}`} style={{
|
||||
background: "#fdfcfa",
|
||||
borderRadius: 20,
|
||||
padding: "48px 44px 40px",
|
||||
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 }}>
|
||||
{studioName.toUpperCase()}
|
||||
</div>
|
||||
<div style={{ width: 32, height: 1.5, background: "#ddd8d0", margin: "16px auto 0" }} />
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={{ display: "block", fontSize: 9, letterSpacing: "0.15em", color: "#8c8880", marginBottom: 7, fontWeight: 500 }}>
|
||||
BENUTZER
|
||||
</label>
|
||||
<input
|
||||
className={`login-input${error ? " error" : ""}`}
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
disabled={isLocked}
|
||||
value={username}
|
||||
onChange={e => { setUsername(e.target.value); setError(false); }}
|
||||
placeholder="admin"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 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>
|
||||
|
||||
{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" }}>
|
||||
Falscher Benutzername oder Passwort
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button className="login-btn" type="submit" disabled={isLocked}>
|
||||
Anmelden
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div style={{ marginTop: 24, textAlign: "center", fontSize: 9, color: "#c8c4be", letterSpacing: "0.08em" }}>
|
||||
{version ? `V${version}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user