see comments

This commit is contained in:
Rajpreet0 2026-08-26 18:32:02 +02:00
parent 04e294bc10
commit f0961e0cca
7 changed files with 168 additions and 55 deletions

View file

@ -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
<div className="border-t border-gray-200 mt-4 pt-3 flex items-center justify-between">
<span className="text-sm text-gray-500">Bezahlt</span>
<span
className="text-lg font-bold"
style={{ color: branding?.accentColor ?? "#111827" }}
>
{formattedPrice}
</span>
{branding ? (
<span
className="text-lg font-bold px-2.5 py-0.5 rounded-md"
style={{
backgroundColor: branding.accentColor,
color: getContrastTextColor(branding.accentColor),
}}
>
{formattedPrice}
</span>
) : (
<span className="text-lg font-bold text-gray-900">{formattedPrice}</span>
)}
</div>
</div>
)}

View file

@ -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;
}

View file

@ -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;
}
}

View file

@ -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),
});

View file

@ -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<HTMLCanvasElement>) => {
@ -91,9 +120,13 @@ const LogoCropDialog = ({ file, onCancel, onConfirm }: LogoCropDialogProps) => {
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
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;

View file

@ -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<string | null>(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 = ({
) : (
<div className="space-y-4">
{businesses.length > 1 && (
<Select value={selectedBusinessId} onValueChange={handleBusinessChange}>
<Select
value={pendingBusinessId}
onValueChange={handleBusinessChange}
disabled={loadingBranding}
>
<SelectTrigger className="bg-white/60 backdrop-blur-xl border-white/40 w-full sm:w-64">
<SelectValue placeholder="Business" />
</SelectTrigger>
@ -112,10 +131,12 @@ const SettingsBrandingPageView = ({
) : (
<div
className={loadingBranding ? "opacity-60 transition-opacity" : "transition-opacity"}
aria-busy={loadingBranding}
inert={loadingBranding || undefined}
>
<BusinessBrandingEditor
key={selectedBusinessId}
businessId={selectedBusinessId}
key={committedBusinessId}
businessId={committedBusinessId}
initialBranding={branding}
onSave={handleSave}
isSaving={isSaving}

View file

@ -1,20 +1,15 @@
import { Document, Page, Text, View, Image, StyleSheet } from "@react-pdf/renderer";
import type { InvoiceData } from "../../actions/invoice-actions";
import { isAllowedLogoUrl } from "@/src/modules/settings/lib/logo-url";
/**
* @react-pdf/renderer lädt Bilder zur Renderzeit serverseitig über die
* angegebene URL nach - eine unerreichbare/kaputte Logo-URL darf den
* gesamten Rechnungs-Download nicht zum Absturz bringen. Nur http(s)
* zulassen (kein data:/file:/beliebiges Protokoll).
* angegebene URL nach - Defense in Depth zusätzlich zur Prüfung in
* businessBrandingSchema beim Speichern (z.B. für Datensätze aus der Zeit vor
* dieser Prüfung), da ein beliebiges http(s)-Ziel hier ein SSRF-Vektor wäre.
*/
function isSafeImageUrl(url: string | null): url is string {
if (!url) return false;
try {
const parsed = new URL(url);
return parsed.protocol === "https:" || parsed.protocol === "http:";
} catch {
return false;
}
return !!url && isAllowedLogoUrl(url);
}
const styles = StyleSheet.create({