mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 19:51:17 +00:00
see comments
This commit is contained in:
parent
04e294bc10
commit
f0961e0cca
7 changed files with 168 additions and 55 deletions
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
28
src/modules/settings/lib/logo-url.ts
Normal file
28
src/modules/settings/lib/logo-url.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue