// ─── UTILS ────────────────────────────────────────────────── import { DEFAULT_ABSENZ_TYPES } from "./constants.js"; // SIA-Honorar-Formel: p = Z1 + Z2 / ∛B export function calcSIAHours(baukosten, schwierigkeit, phasen) { if (!baukosten || baukosten <= 0) return { p: 0, total: 0, phases: [] }; const cbrtB = Math.cbrt(baukosten); const p = 0.062 + 10.58 / cbrtB; const results = phasen.map(ph => { const items = ph.items.map(it => { if (it.enabled === false) return { ...it, hours: 0 }; const r = it.r ?? 1; const hours = baukosten * (p / 100) * schwierigkeit * (it.pct / 100) * r; return { ...it, hours: Math.round(hours * 10) / 10 }; }); const phaseHours = items.reduce((s, it) => s + it.hours, 0); return { ...ph, items, hours: phaseHours }; }); const total = results.reduce((s, ph) => s + ph.hours, 0); return { p: Math.round(p * 1000) / 1000, cbrtB, total, phases: results }; } // Manuelle Aufwandschätzung: Stunden pro Rolle × Stundensatz export function calcManualHours(phases, roles) { const results = phases.filter(ph => ph.enabled).map(ph => { const roleDetails = (roles || []).map(r => ({ ...r, hours: ph.hoursByRole?.[r.id] || 0, })); const totalHours = roleDetails.reduce((s, r) => s + r.hours, 0); const totalAmount = roleDetails.reduce((s, r) => s + r.hours * r.rate, 0); return { ...ph, roleDetails, totalHours, totalAmount }; }); return { phases: results, totalHours: results.reduce((s, p) => s + p.totalHours, 0), totalAmount: results.reduce((s, p) => s + p.totalAmount, 0), }; } // Berechnet Budget aus linkedQuotes-Array (Migration von sourceQuoteId) export function migrateLinkedQuotes(project) { if (project.linkedQuotes) return project.linkedQuotes; if (project.sourceQuoteId) return [{ quoteId: project.sourceQuoteId, role: "Hauptofferte" }]; return []; } export function deriveQuoteBudget(linkedQuotes, allQuotes, settingsRoles) { const quotes = (linkedQuotes || []).map(lq => allQuotes.find(q => q.id === lq.quoteId)).filter(Boolean); let totalHours = 0; let totalAmount = 0; const phaseMap = {}; // phaseId -> hours (summed) const enabledSet = new Set(); // phases enabled in quotes even with 0 hours quotes.forEach(q => { const roles = q.quoteRoles || settingsRoles || []; if (q.mode === "sia") { const calc = calcSIAHours(q.sia?.baukosten, q.sia?.schwierigkeit, q.sia?.phases || []); totalHours += calc.total || 0; totalAmount += (calc.total || 0) * (q.sia?.stundenansatz || 0); (calc.phases || []).forEach(ph => { if (ph.hours > 0) phaseMap[ph.id] = (phaseMap[ph.id] || 0) + ph.hours; }); } else if (q.mode === "manual") { const calc = calcManualHours(q.manualPhases || [], roles); totalHours += calc.totalHours || 0; totalAmount += calc.totalAmount || 0; (q.manualPhases || []).filter(ph => ph.enabled).forEach(ph => { enabledSet.add(ph.id); const h = roles.reduce((s, r) => s + (ph.hoursByRole?.[r.id] || 0), 0); if (h > 0) phaseMap[ph.id] = (phaseMap[ph.id] || 0) + h; }); } else if (q.mode === "free") { totalAmount += (q.freeItems || []).reduce((s, it) => s + (it.qty * it.price), 0); } }); const phasesBudget = Object.entries(phaseMap).map(([id, hours]) => ({ id, hours: Math.round(hours * 10) / 10 })); const enabledPhases = [...new Set([...phasesBudget.map(p => p.id), ...enabledSet])]; return { budgetHours: Math.round(totalHours * 10) / 10, budgetAmount: Math.round(totalAmount), phasesBudget, enabledPhases, hasFreeQuotes: quotes.some(q => q.mode === "free"), hasHourQuotes: quotes.some(q => q.mode === "sia" || q.mode === "manual"), }; } export function generateId() { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return crypto.randomUUID(); } // Fallback for very old environments — uses crypto.getRandomValues if available if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") { const buf = new Uint8Array(8); crypto.getRandomValues(buf); return Date.now().toString(36) + "-" + Array.from(buf).map(b => b.toString(16).padStart(2, "0")).join(""); } return Date.now().toString(36) + Math.random().toString(36).slice(2, 10); } // ─── PASSWORT-HASHING (PBKDF2 via Web Crypto) ────────────────────────────── // Stored shape on user: { passwordHash, passwordSalt } (both hex strings) // Legacy users with plaintext { password } are accepted once and upgraded // transparently after a successful login. const PBKDF2_ITERATIONS = 100_000; const PBKDF2_HASH = "SHA-256"; const PBKDF2_SALT_BYTES = 16; const PBKDF2_KEY_BITS = 256; function bytesToHex(bytes) { let s = ""; for (const b of bytes) s += b.toString(16).padStart(2, "0"); return s; } function hexToBytes(hex) { const out = new Uint8Array(hex.length / 2); for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); return out; } export async function hashPassword(password, saltHex) { const salt = saltHex ? hexToBytes(saltHex) : crypto.getRandomValues(new Uint8Array(PBKDF2_SALT_BYTES)); const baseKey = await crypto.subtle.importKey( "raw", new TextEncoder().encode(password ?? ""), "PBKDF2", false, ["deriveBits"] ); const bits = await crypto.subtle.deriveBits( { name: "PBKDF2", salt, iterations: PBKDF2_ITERATIONS, hash: PBKDF2_HASH }, baseKey, PBKDF2_KEY_BITS ); return { hash: bytesToHex(new Uint8Array(bits)), salt: bytesToHex(salt) }; } // Constant-time string compare to avoid timing leaks function constantTimeEq(a, b) { if (typeof a !== "string" || typeof b !== "string" || a.length !== b.length) return false; let r = 0; for (let i = 0; i < a.length; i++) r |= a.charCodeAt(i) ^ b.charCodeAt(i); return r === 0; } export async function verifyPassword(password, user) { if (!user) return false; if (user.passwordHash && user.passwordSalt) { const { hash } = await hashPassword(password, user.passwordSalt); return constantTimeEq(hash, user.passwordHash); } // Legacy plaintext fallback (transitional only) if (typeof user.password === "string") { return constantTimeEq(password ?? "", user.password); } return false; } // Returns a copy of user with hashed credentials (and the legacy plaintext field stripped). export async function withHashedPassword(user, password) { const { hash, salt } = await hashPassword(password); // eslint-disable-next-line no-unused-vars const { password: _legacy, ...rest } = user; return { ...rest, passwordHash: hash, passwordSalt: salt }; } // Strips all credential fields from a user-shaped object (for sessionStorage / props) export function stripCredentials(user) { if (!user) return user; // eslint-disable-next-line no-unused-vars const { password, passwordHash, passwordSalt, ...safe } = user; return safe; } // HTML sanitizer for rich-text content (letter bodies, etc.). // Allowlist of tags + attributes; strips script, event handlers, javascript: URLs. const SAFE_TAGS = new Set([ "P", "BR", "DIV", "SPAN", "B", "STRONG", "I", "EM", "U", "S", "STRIKE", "H1", "H2", "H3", "H4", "H5", "H6", "UL", "OL", "LI", "BLOCKQUOTE", "FONT", "A", "HR", "SUB", "SUP", ]); const SAFE_ATTRS = new Set([ "style", "color", "size", "face", "align", "href", "target", "rel", ]); function isSafeUrl(url) { if (!url) return false; const v = String(url).trim().toLowerCase(); // block javascript:, data:text/html, vbscript:, etc. — allow http(s), mailto, tel, relative return !/^(javascript|data|vbscript|file):/i.test(v); } export function sanitizeHtml(html) { if (typeof html !== "string" || html === "") return ""; if (typeof DOMParser === "undefined") return ""; // SSR/safe fallback const doc = new DOMParser().parseFromString(`
${html}
`, "text/html"); const root = doc.body.firstChild; if (!root) return ""; const walk = (node) => { // Iterate over a snapshot — children may be removed during walk const kids = Array.from(node.childNodes); for (const child of kids) { if (child.nodeType === 1) { const tag = child.tagName; if (!SAFE_TAGS.has(tag)) { // Replace disallowed tag with its sanitized children (drops