From f0961e0cca332513aef1cba4d39fa1f78623a7c0 Mon Sep 17 00:00:00 2001 From: Rajpreet0 Date: Wed, 26 Aug 2026 18:32:02 +0200 Subject: [PATCH] see comments --- .../courses/book/[courseId]/success/page.tsx | 20 ++++-- src/modules/settings/lib/contrast.ts | 50 ++++++++++----- src/modules/settings/lib/logo-url.ts | 28 +++++++++ .../schemas/business-branding-schema.ts | 10 ++- .../settings/ui/components/LogoCropDialog.tsx | 61 ++++++++++++++----- .../ui/views/settings-branding-page-view.tsx | 39 +++++++++--- .../ui/components/invoice-pdf-document.tsx | 15 ++--- 7 files changed, 168 insertions(+), 55 deletions(-) create mode 100644 src/modules/settings/lib/logo-url.ts diff --git a/src/app/courses/book/[courseId]/success/page.tsx b/src/app/courses/book/[courseId]/success/page.tsx index bd68e4f..ca70cb7 100644 --- a/src/app/courses/book/[courseId]/success/page.tsx +++ b/src/app/courses/book/[courseId]/success/page.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { Button } from "@/src/components/ui/button"; import { getCourseSummaryForConfirmation } from "@/src/modules/courses/actions/booking-actions"; import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding"; +import { getContrastTextColor } from "@/src/modules/settings/lib/contrast"; import { CheckCircle2, Calendar, Clock, MapPin } from "lucide-react"; interface SuccessPageProps { @@ -85,12 +86,19 @@ export default async function CourseBookingSuccessPage({ params }: SuccessPagePr
Bezahlt - - {formattedPrice} - + {branding ? ( + + {formattedPrice} + + ) : ( + {formattedPrice} + )}
)} diff --git a/src/modules/settings/lib/contrast.ts b/src/modules/settings/lib/contrast.ts index ee13448..1493910 100644 --- a/src/modules/settings/lib/contrast.ts +++ b/src/modules/settings/lib/contrast.ts @@ -1,15 +1,6 @@ -/** - * Leitet eine gut lesbare Textfarbe (schwarz oder weiß) für einen gegebenen - * Hex-Hintergrund ab, statt sie als eigenes Formularfeld zu erheben - so - * bleibt der Farbwähler bei zwei Farben (Primär/Akzent), ohne dass eine falsch - * gewählte Akzentfarbe zu unlesbarem Button-/Badge-Text führt. - * - * WCAG-relative Luminanz (gammakorrigiert) mit Schwellwert 0.5: unterhalb - * gilt der Hintergrund als dunkel genug für weißen Text, sonst schwarz. - */ -export function getContrastTextColor(hex: string): string { +function getRelativeLuminance(hex: string): number { const match = /^#([0-9a-fA-F]{6})$/.exec(hex); - if (!match) return "#1E293B"; + if (!match) return 0; const r = parseInt(match[1].slice(0, 2), 16) / 255; const g = parseInt(match[1].slice(2, 4), 16) / 255; @@ -18,7 +9,38 @@ export function getContrastTextColor(hex: string): string { const toLinear = (channel: number) => channel <= 0.03928 ? channel / 12.92 : Math.pow((channel + 0.055) / 1.055, 2.4); - const luminance = 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b); - - return luminance > 0.5 ? "#1E293B" : "#FFFFFF"; + return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b); +} + +/** WCAG-Kontrastverhältnis zwischen zwei relativen Luminanzen (1 bis 21). */ +function contrastRatio(luminanceA: number, luminanceB: number): number { + const lighter = Math.max(luminanceA, luminanceB); + const darker = Math.min(luminanceA, luminanceB); + return (lighter + 0.05) / (darker + 0.05); +} + +const DARK_TEXT = "#1E293B"; +const LIGHT_TEXT = "#FFFFFF"; + +/** + * Leitet eine gut lesbare Textfarbe für einen gegebenen Hex-Hintergrund ab, + * statt sie als eigenes Formularfeld zu erheben - so bleibt der Farbwähler + * bei zwei Farben (Primär/Akzent), ohne dass eine falsch gewählte + * Akzentfarbe zu unlesbarem Button-/Badge-Text führt. + * + * Wählt zwischen DARK_TEXT und LIGHT_TEXT die Variante mit dem höheren + * tatsächlichen WCAG-Kontrastverhältnis gegen den Hintergrund - ein reiner + * Luminanz-Schwellwert (>0.5 → dunkel, sonst hell) würde bei mittleren + * Grautönen (z.B. #808080, #969696) fälschlich eine Farbe wählen, die WCAG AA + * (4.5:1) klar verfehlt, weil beide Kandidaten dort ähnlich schlecht sind. + */ +export function getContrastTextColor(hex: string): string { + const match = /^#[0-9a-fA-F]{6}$/.test(hex); + if (!match) return DARK_TEXT; + + const backgroundLuminance = getRelativeLuminance(hex); + const darkContrast = contrastRatio(backgroundLuminance, getRelativeLuminance(DARK_TEXT)); + const lightContrast = contrastRatio(backgroundLuminance, getRelativeLuminance(LIGHT_TEXT)); + + return lightContrast >= darkContrast ? LIGHT_TEXT : DARK_TEXT; } diff --git a/src/modules/settings/lib/logo-url.ts b/src/modules/settings/lib/logo-url.ts new file mode 100644 index 0000000..e166aba --- /dev/null +++ b/src/modules/settings/lib/logo-url.ts @@ -0,0 +1,28 @@ +/** + * Prüft, ob eine Logo-URL aus der eigenen Supabase-Storage-Origin stammt. + * + * Logos werden ausschließlich über /api/upload/business-logo erzeugt + * (Supabase Storage, "avatars"-Bucket, Pfad-Präfix "business-logos/") - jede + * andere URL ist entweder Unsinn oder ein SSRF-Versuch (die URL wird sowohl + * beim Speichern validiert als auch später beim PDF-Rendering serverseitig + * nachgeladen, z.B. gegen Loopback/private Netze/Cloud-Metadata-Endpunkte). + * Eine gemeinsame Funktion für beide Stellen (Zod-Schema beim Speichern, + * invoice-pdf-document.tsx beim Rendern) verhindert, dass die beiden Checks + * auseinanderdriften. + */ +export function isAllowedLogoUrl(url: string): boolean { + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + if (!supabaseUrl) return false; + + try { + const parsed = new URL(url); + const allowedOrigin = new URL(supabaseUrl); + return ( + parsed.protocol === "https:" && + parsed.host === allowedOrigin.host && + parsed.pathname.startsWith("/storage/v1/object/public/avatars/") + ); + } catch { + return false; + } +} diff --git a/src/modules/settings/schemas/business-branding-schema.ts b/src/modules/settings/schemas/business-branding-schema.ts index d5b7891..208b49d 100644 --- a/src/modules/settings/schemas/business-branding-schema.ts +++ b/src/modules/settings/schemas/business-branding-schema.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { KUMASY_BRANDING } from "@/src/lib/mail/templates/tokens"; +import { isAllowedLogoUrl } from "../lib/logo-url"; // Nur 6-stelliges Hex statt der breiteren Farbnotation, die sanitizeColor in // den Mail-Templates akzeptiert (dort defense-in-depth für beliebige Quellen) - @@ -7,9 +8,16 @@ import { KUMASY_BRANDING } from "@/src/lib/mail/templates/tokens"; // immer #rrggbb liefert. const hexColorSchema = z.string().regex(/^#[0-9a-fA-F]{6}$/, "Ungültige Farbe"); +const logoUrlSchema = z + .string() + .url() + .refine(isAllowedLogoUrl, "Logo-URL muss aus dem eigenen Storage stammen") + .nullable() + .default(null); + export const businessBrandingSchema = z.object({ companyName: z.string().trim().min(1).max(100), - logoUrl: z.string().url().nullable().default(null), + logoUrl: logoUrlSchema, primaryColor: hexColorSchema.default(KUMASY_BRANDING.primaryColor), accentColor: hexColorSchema.default(KUMASY_BRANDING.accentColor), }); diff --git a/src/modules/settings/ui/components/LogoCropDialog.tsx b/src/modules/settings/ui/components/LogoCropDialog.tsx index 2001c13..550f16d 100644 --- a/src/modules/settings/ui/components/LogoCropDialog.tsx +++ b/src/modules/settings/ui/components/LogoCropDialog.tsx @@ -21,6 +21,32 @@ interface LogoCropDialogProps { onConfirm: (croppedFile: File) => void; } +/** Größe, in der das Bild bei gegebenem Zoom gezeichnet wird (füllt CANVAS_SIZE mindestens aus). */ +function getScaledSize(image: HTMLImageElement, zoom: number): { width: number; height: number } { + const baseScale = Math.max(CANVAS_SIZE / image.width, CANVAS_SIZE / image.height); + const scale = baseScale * zoom; + return { width: image.width * scale, height: image.height * scale }; +} + +/** + * Begrenzt den Offset auf den Bereich, in dem das skalierte Bild den + * quadratischen Canvas noch vollständig ausfüllt - ohne diese Klemmung könnte + * ein Drag oder ein Zoom-Wechsel das Bild aus dem sichtbaren Bereich schieben + * (grauer Freiraum in der Vorschau, transparente Fläche statt Logo im Export). + */ +function clampOffset( + offset: { x: number; y: number }, + drawWidth: number, + drawHeight: number +): { x: number; y: number } { + const maxOffsetX = Math.max(0, (drawWidth - CANVAS_SIZE) / 2); + const maxOffsetY = Math.max(0, (drawHeight - CANVAS_SIZE) / 2); + return { + x: Math.min(maxOffsetX, Math.max(-maxOffsetX, offset.x)), + y: Math.min(maxOffsetY, Math.max(-maxOffsetY, offset.y)), + }; +} + /** * Einfacher Zoom/Pan-Crop direkt per Canvas, ohne zusätzliche Dependency - * analog zum bestehenden Canvas-Ansatz in downsizeImage. Schneidet immer @@ -53,12 +79,10 @@ const LogoCropDialog = ({ file, onCancel, onConfirm }: LogoCropDialogProps) => { ctx.fillStyle = "#f5f5f5"; ctx.fillRect(0, 0, CANVAS_SIZE, CANVAS_SIZE); - const baseScale = Math.max(CANVAS_SIZE / image.width, CANVAS_SIZE / image.height); - const scale = baseScale * currentZoom; - const drawWidth = image.width * scale; - const drawHeight = image.height * scale; - const x = (CANVAS_SIZE - drawWidth) / 2 + currentOffset.x; - const y = (CANVAS_SIZE - drawHeight) / 2 + currentOffset.y; + const { width: drawWidth, height: drawHeight } = getScaledSize(image, currentZoom); + const clampedOffset = clampOffset(currentOffset, drawWidth, drawHeight); + const x = (CANVAS_SIZE - drawWidth) / 2 + clampedOffset.x; + const y = (CANVAS_SIZE - drawHeight) / 2 + clampedOffset.y; ctx.drawImage(image, x, y, drawWidth, drawHeight); }, @@ -80,8 +104,13 @@ const LogoCropDialog = ({ file, onCancel, onConfirm }: LogoCropDialogProps) => { }, [file, draw]); const handleZoomChange = ([value]: number[]) => { + const image = imageRef.current; + if (!image) return; + const { width, height } = getScaledSize(image, value); + const clamped = clampOffset(offset, width, height); setZoom(value); - draw(value, offset); + setOffset(clamped); + draw(value, clamped); }; const handlePointerDown = (e: React.PointerEvent) => { @@ -91,9 +120,13 @@ const LogoCropDialog = ({ file, onCancel, onConfirm }: LogoCropDialogProps) => { const handlePointerMove = (e: React.PointerEvent) => { if (!dragState.current) return; + const image = imageRef.current; + if (!image) return; const dx = e.clientX - dragState.current.startX; const dy = e.clientY - dragState.current.startY; - const next = { x: dragState.current.origin.x + dx, y: dragState.current.origin.y + dy }; + const raw = { x: dragState.current.origin.x + dx, y: dragState.current.origin.y + dy }; + const { width, height } = getScaledSize(image, zoom); + const next = clampOffset(raw, width, height); setOffset(next); draw(zoom, next); }; @@ -113,14 +146,12 @@ const LogoCropDialog = ({ file, onCancel, onConfirm }: LogoCropDialogProps) => { if (!ctx) return; const outputScale = OUTPUT_SIZE / CANVAS_SIZE; - const baseScale = Math.max(CANVAS_SIZE / image.width, CANVAS_SIZE / image.height); - const scale = baseScale * zoom * outputScale; - const drawWidth = image.width * scale; - const drawHeight = image.height * scale; - const x = (OUTPUT_SIZE - drawWidth) / 2 + offset.x * outputScale; - const y = (OUTPUT_SIZE - drawHeight) / 2 + offset.y * outputScale; + const { width: drawWidth, height: drawHeight } = getScaledSize(image, zoom); + const clampedOffset = clampOffset(offset, drawWidth, drawHeight); + const x = (OUTPUT_SIZE - drawWidth * outputScale) / 2 + clampedOffset.x * outputScale; + const y = (OUTPUT_SIZE - drawHeight * outputScale) / 2 + clampedOffset.y * outputScale; - ctx.drawImage(image, x, y, drawWidth, drawHeight); + ctx.drawImage(image, x, y, drawWidth * outputScale, drawHeight * outputScale); outputCanvas.toBlob((blob) => { if (!blob) return; diff --git a/src/modules/settings/ui/views/settings-branding-page-view.tsx b/src/modules/settings/ui/views/settings-branding-page-view.tsx index 18d7b26..0121f09 100644 --- a/src/modules/settings/ui/views/settings-branding-page-view.tsx +++ b/src/modules/settings/ui/views/settings-branding-page-view.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useTransition } from "react"; +import { useRef, useState, useTransition } from "react"; import { Megaphone, AlertTriangle } from "lucide-react"; import { toast } from "sonner"; import { @@ -23,7 +23,15 @@ const SettingsBrandingPageView = ({ businesses, initialBranding, }: SettingsBrandingPageViewProps) => { - const [selectedBusinessId, setSelectedBusinessId] = useState(businesses[0]?.id ?? ""); + // "committed" = das Business, dessen Branding tatsächlich geladen ist und + // bearbeitet/gespeichert werden darf. Wird erst nach erfolgreicher Antwort + // umgestellt - der Editor (key={committedBusinessId}) bleibt dadurch bis + // dahin auf dem alten, tatsächlich geladenen Business stehen, statt sofort + // unter der neuen Business-ID mit noch altem Branding speicherbar zu sein. + const [committedBusinessId, setCommittedBusinessId] = useState(businesses[0]?.id ?? ""); + // "pending" = was der Select gerade anzeigt, kann während des Ladens von + // committedBusinessId abweichen. + const [pendingBusinessId, setPendingBusinessId] = useState(businesses[0]?.id ?? ""); const [branding, setBranding] = useState(initialBranding); // Getrennt von "kein Branding gespeichert" - eine fehlgeschlagene Anfrage // darf niemals als leeres/Default-Branding dargestellt werden, sonst würde @@ -32,20 +40,27 @@ const SettingsBrandingPageView = ({ const [loadError, setLoadError] = useState(null); const [loadingBranding, startLoadingBranding] = useTransition(); const [isSaving, startSaving] = useTransition(); + // Schutz gegen veraltete Antworten: wenn der Manager zweimal hintereinander + // das Business wechselt, bevor die erste Anfrage zurückkommt, darf deren + // (spätere) Antwort nicht mehr die inzwischen aktuelleren Daten überschreiben. + const latestRequestId = useRef(0); const handleBusinessChange = (businessId: string) => { - const previousBusinessId = selectedBusinessId; - setSelectedBusinessId(businessId); + setPendingBusinessId(businessId); setLoadError(null); + const requestId = ++latestRequestId.current; startLoadingBranding(async () => { const result = await getBusinessBranding(businessId); + if (requestId !== latestRequestId.current) return; // veraltet, verworfen + if (result.success) { setBranding(result.branding); + setCommittedBusinessId(businessId); } else { // Auswahl zurücksetzen, damit die UI nicht unter dem neuen // Business-Namen die Werte des vorherigen Business anzeigt und ein // Speichern sie fälschlich der neuen Business-ID zuordnet. - setSelectedBusinessId(previousBusinessId); + setPendingBusinessId(committedBusinessId); setLoadError(result.error); toast.error(result.error); } @@ -56,7 +71,7 @@ const SettingsBrandingPageView = ({ return new Promise((resolve) => { startSaving(async () => { try { - const result = await saveBusinessBranding(selectedBusinessId, newBranding); + const result = await saveBusinessBranding(committedBusinessId, newBranding); if (result.success) { toast.success("Branding gespeichert."); resolve(true); @@ -90,7 +105,11 @@ const SettingsBrandingPageView = ({ ) : (
{businesses.length > 1 && ( - @@ -112,10 +131,12 @@ const SettingsBrandingPageView = ({ ) : (