From e42a8109bfe12eaa1ace579dc23023be9727e971 Mon Sep 17 00:00:00 2001 From: Sebastian Mayer Date: Fri, 29 May 2026 10:07:09 +0200 Subject: [PATCH] Feature: Settings-Tabs, Login-Redesign und Security-Tests - Einstellungen-Seite in fokussierte Komponenten aufgeteilt (settings-tabs, status-banner, section-card) - Login-Seite ueberarbeitet - Neue security.test.ts fuer 2FA-Verlust-/Recovery-Logik - health.test.ts erweitert - next-env.d.ts auf Next.js dev-types-Pfad aktualisiert Co-Authored-By: Claude Opus 4.8 (1M context) --- .../_components/section-card.tsx | 24 + .../_components/settings-tabs.tsx | 83 ++ .../_components/status-banner.tsx | 29 + .../admin/app/(authed)/einstellungen/page.tsx | 1085 +++++++++-------- apps/admin/app/login/page.tsx | 187 +-- apps/admin/next-env.d.ts | 2 +- packages/api/src/trpc/health.test.ts | 82 ++ packages/api/src/trpc/security.test.ts | 229 ++++ 8 files changed, 1165 insertions(+), 556 deletions(-) create mode 100644 apps/admin/app/(authed)/einstellungen/_components/section-card.tsx create mode 100644 apps/admin/app/(authed)/einstellungen/_components/settings-tabs.tsx create mode 100644 apps/admin/app/(authed)/einstellungen/_components/status-banner.tsx create mode 100644 packages/api/src/trpc/security.test.ts diff --git a/apps/admin/app/(authed)/einstellungen/_components/section-card.tsx b/apps/admin/app/(authed)/einstellungen/_components/section-card.tsx new file mode 100644 index 0000000..52d2c05 --- /dev/null +++ b/apps/admin/app/(authed)/einstellungen/_components/section-card.tsx @@ -0,0 +1,24 @@ +import type { ReactNode } from "react"; + +export function SectionCard({ + title, + description, + children, + footer, +}: { + title: string; + description?: string; + children: ReactNode; + footer?: ReactNode; +}) { + return ( +
+
+

{title}

+ {description ?

{description}

: null} +
+
{children}
+ {footer ?
{footer}
: null} +
+ ); +} diff --git a/apps/admin/app/(authed)/einstellungen/_components/settings-tabs.tsx b/apps/admin/app/(authed)/einstellungen/_components/settings-tabs.tsx new file mode 100644 index 0000000..4d4d08f --- /dev/null +++ b/apps/admin/app/(authed)/einstellungen/_components/settings-tabs.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { useRouter, useSearchParams } from "next/navigation"; +import { useCallback } from "react"; + +export type SettingsTabId = + | "profil" + | "ernaehrung" + | "ziele" + | "sicherheit" + | "anwendung"; + +const tabs: { id: SettingsTabId; label: string; description: string }[] = [ + { id: "profil", label: "Profil", description: "Persoenliche Daten und Kontakt" }, + { id: "ernaehrung", label: "Ernaehrung", description: "Rezept- und Mahlzeiten-Praeferenzen" }, + { id: "ziele", label: "Ziele", description: "Kalorien, Makros, Gewicht, Fasten" }, + { id: "sicherheit", label: "Sicherheit", description: "2FA, Passkeys, Passwort" }, + { id: "anwendung", label: "Anwendung", description: "Erscheinungsbild und Erinnerungen" }, +]; + +export function useSettingsTab(): { + active: SettingsTabId; + setActive: (id: SettingsTabId) => void; +} { + const router = useRouter(); + const search = useSearchParams(); + const tabParam = search.get("tab"); + const active: SettingsTabId = + tabs.find((tab) => tab.id === tabParam)?.id ?? "profil"; + + const setActive = useCallback( + (id: SettingsTabId) => { + const params = new URLSearchParams(search.toString()); + params.set("tab", id); + router.replace(`/einstellungen?${params.toString()}`, { scroll: false }); + }, + [router, search], + ); + + return { active, setActive }; +} + +export function SettingsTabs({ + active, + onChange, +}: { + active: SettingsTabId; + onChange: (id: SettingsTabId) => void; +}) { + const activeMeta = tabs.find((tab) => tab.id === active); + return ( +
+
+ {tabs.map((tab) => { + const isActive = tab.id === active; + return ( + + ); + })} +
+ {activeMeta?.description ? ( +

{activeMeta.description}

+ ) : null} +
+ ); +} diff --git a/apps/admin/app/(authed)/einstellungen/_components/status-banner.tsx b/apps/admin/app/(authed)/einstellungen/_components/status-banner.tsx new file mode 100644 index 0000000..80b8ca3 --- /dev/null +++ b/apps/admin/app/(authed)/einstellungen/_components/status-banner.tsx @@ -0,0 +1,29 @@ +"use client"; + +export function StatusBanner({ + status, + error, +}: { + status: string | null; + error: string | null; +}) { + if (!status && !error) return null; + if (error) { + return ( +
+ {error} +
+ ); + } + return ( +
+ {status} +
+ ); +} diff --git a/apps/admin/app/(authed)/einstellungen/page.tsx b/apps/admin/app/(authed)/einstellungen/page.tsx index 4cfa48f..aab774e 100644 --- a/apps/admin/app/(authed)/einstellungen/page.tsx +++ b/apps/admin/app/(authed)/einstellungen/page.tsx @@ -1,4 +1,4 @@ -"use client"; +"use client"; import { BeakerIcon, @@ -8,8 +8,10 @@ import { EyeIcon, EyeSlashIcon, FireIcon, + KeyIcon, MoonIcon, ScaleIcon, + ShieldCheckIcon, SunIcon, } from "@heroicons/react/24/outline"; import Link from "next/link"; @@ -19,6 +21,9 @@ import { useTheme } from "@/app/_components/theme-provider"; import { authClient } from "@/lib/auth-client"; import { HellthGoals, loadHellthData } from "@/lib/hellth-data"; import { trpc } from "@/lib/trpc"; +import { SectionCard } from "./_components/section-card"; +import { SettingsTabs, useSettingsTab } from "./_components/settings-tabs"; +import { StatusBanner } from "./_components/status-banner"; const PASSWORD_CHARS = "ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%&*"; @@ -38,9 +43,7 @@ function todayKey() { function generatePassword(length = 16) { const bytes = crypto.getRandomValues(new Uint8Array(length)); - return Array.from(bytes, (byte) => PASSWORD_CHARS[byte % PASSWORD_CHARS.length]!).join( - "", - ); + return Array.from(bytes, (byte) => PASSWORD_CHARS[byte % PASSWORD_CHARS.length]!).join(""); } async function toOptimizedAvatarDataUrl(file: File) { @@ -54,7 +57,7 @@ async function toOptimizedAvatarDataUrl(file: File) { canvas.width = width; canvas.height = height; const ctx = canvas.getContext("2d"); - if (!ctx) throw new Error("Canvas nicht verfügbar"); + if (!ctx) throw new Error("Canvas nicht verfuegbar"); ctx.drawImage(bitmap, 0, 0, width, height); bitmap.close(); @@ -64,13 +67,22 @@ async function toOptimizedAvatarDataUrl(file: File) { throw new Error("Profilbild konnte nicht verarbeitet werden."); } if (dataUrl.length > 120_000) { - throw new Error("Profilbild ist nach Komprimierung noch zu groß."); + throw new Error("Profilbild ist nach Komprimierung noch zu gross."); } return dataUrl; } +const inputClass = + "w-full rounded-lg border border-gray-200 bg-white px-3 py-2.5 text-sm text-gray-900 focus:border-[#0F766E] focus:outline-none focus:ring-2 focus:ring-[#0F766E]/20"; +const labelClass = "mb-1.5 block text-sm font-medium text-gray-700"; +const buttonPrimary = + "inline-flex items-center justify-center rounded-lg bg-[#0F766E] px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-[#115E59] disabled:cursor-not-allowed disabled:opacity-50"; +const buttonSecondary = + "inline-flex items-center justify-center rounded-lg border border-gray-200 bg-white px-4 py-2.5 text-sm font-semibold text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"; + export default function EinstellungenPage() { const { isDark, toggleDark } = useTheme(); + const { active, setActive } = useSettingsTab(); const utils = trpc.useUtils(); const profileQuery = trpc.users.myProfile.useQuery(); const securityQuery = trpc.security.status.useQuery(); @@ -89,13 +101,21 @@ export default function EinstellungenPage() { const [postalCode, setPostalCode] = useState(""); const [city, setCity] = useState(""); const [image, setImage] = useState(null); - const [dietPreferences, setDietPreferences] = useState(["vegan", "vegetarian", "meat", "fish"]); + const [dietPreferences, setDietPreferences] = useState([ + "vegan", + "vegetarian", + "meat", + "fish", + ]); const [mealsPerDay, setMealsPerDay] = useState("3"); const [cooksPerDay, setCooksPerDay] = useState("1"); const [preferredMealPrepDays, setPreferredMealPrepDays] = useState("0"); - const [meatFishFrequency, setMeatFishFrequency] = useState<"none" | "once_per_day" | "flexible">("flexible"); + const [meatFishFrequency, setMeatFishFrequency] = useState< + "none" | "once_per_day" | "flexible" + >("flexible"); const isPlatformAdmin = profileQuery.data?.role === "admin"; + const [currentPassword, setCurrentPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); const [confirmNewPassword, setConfirmNewPassword] = useState(""); @@ -124,7 +144,10 @@ export default function EinstellungenPage() { const updateMyProfile = trpc.users.updateMyProfile.useMutation({ onSuccess: async () => { - await Promise.all([utils.users.myProfile.invalidate(), utils.security.status.invalidate()]); + await Promise.all([ + utils.users.myProfile.invalidate(), + utils.security.status.invalidate(), + ]); setStatusMessage("Profil gespeichert."); setErrorMessage(null); }, @@ -152,7 +175,7 @@ export default function EinstellungenPage() { onSuccess: async () => { await healthOverviewQuery.refetch(); await utils.health.recipeRecommendations.invalidate(); - setStatusMessage("Essenspräferenzen gespeichert."); + setStatusMessage("Essenspraeferenzen gespeichert."); setErrorMessage(null); }, onError: (error) => { @@ -164,16 +187,24 @@ export default function EinstellungenPage() { useEffect(() => { const profile = healthOverviewQuery.data?.profile; if (!profile) return; - setDietPreferences((profile.dietPreferences?.length ? profile.dietPreferences : ["vegan", "vegetarian", "meat", "fish"]) as DietPreference[]); + setDietPreferences( + (profile.dietPreferences?.length + ? profile.dietPreferences + : ["vegan", "vegetarian", "meat", "fish"]) as DietPreference[], + ); setMealsPerDay(String(profile.mealsPerDay ?? 3)); setCooksPerDay(String(profile.cooksPerDay ?? 1)); setPreferredMealPrepDays(String(profile.preferredMealPrepDays ?? 0)); - setMeatFishFrequency((profile.meatFishFrequency as "none" | "once_per_day" | "flexible") ?? "flexible"); + setMeatFishFrequency( + (profile.meatFishFrequency as "none" | "once_per_day" | "flexible") ?? "flexible", + ); }, [healthOverviewQuery.data?.profile]); function toggleDietPreference(preference: DietPreference) { setDietPreferences((current) => { - const next = current.includes(preference) ? current.filter((item) => item !== preference) : [...current, preference]; + const next = current.includes(preference) + ? current.filter((item) => item !== preference) + : [...current, preference]; return next.length ? next : current; }); } @@ -184,7 +215,8 @@ export default function EinstellungenPage() { goal: profile?.goal ?? "healthier", age: profile?.age ?? null, heightCm: profile?.heightCm == null ? null : Number(profile.heightCm), - currentWeightKg: profile?.currentWeightKg == null ? null : Number(profile.currentWeightKg), + currentWeightKg: + profile?.currentWeightKg == null ? null : Number(profile.currentWeightKg), targetWeightKg: profile?.targetWeightKg == null ? null : Number(profile.targetWeightKg), activityLevel: profile?.activityLevel ?? "moderate", dietStyle: profile?.dietStyle ?? "flexible", @@ -193,7 +225,8 @@ export default function EinstellungenPage() { cooksPerDay: Number(cooksPerDay) || 1, preferredMealPrepDays: Number(preferredMealPrepDays) || 0, meatFishFrequency, - cookingTime: (profile?.cookingTime as "low" | "medium" | "high" | undefined) ?? "medium", + cookingTime: + (profile?.cookingTime as "low" | "medium" | "high" | undefined) ?? "medium", budgetFocus: profile?.budgetFocus ?? false, excludedFoods: profile?.excludedFoods ?? null, biggestChallenge: profile?.biggestChallenge ?? null, @@ -209,7 +242,8 @@ export default function EinstellungenPage() { () => !!firstName.trim() && firstName.trim().length >= 2 && - (firstName.trim() !== (profileQuery.data?.firstName ?? profileQuery.data?.name ?? "") || + (firstName.trim() !== + (profileQuery.data?.firstName ?? profileQuery.data?.name ?? "") || (lastName.trim() || null) !== (profileQuery.data?.lastName ?? null) || (phone.trim() || null) !== (profileQuery.data?.phone ?? null) || (mobile.trim() || null) !== (profileQuery.data?.mobile ?? null) || @@ -245,24 +279,23 @@ export default function EinstellungenPage() { name: newPasskeyName.trim() || undefined, }); if (result.error) { - setErrorMessage(result.error.message ?? "Passkey konnte nicht hinzugefügt werden."); + setErrorMessage(result.error.message ?? "Passkey konnte nicht hinzugefuegt werden."); return; } - setStatusMessage("Passkey erfolgreich hinzugefügt."); + setStatusMessage("Passkey erfolgreich hinzugefuegt."); setNewPasskeyName(""); await passkeysQuery.refetch(); } async function handleImageUpload(file: File) { if (!file.type.startsWith("image/")) { - setErrorMessage("Bitte eine Bilddatei auswählen."); + setErrorMessage("Bitte eine Bilddatei auswaehlen."); return; } if (file.size > 5 * 1024 * 1024) { - setErrorMessage("Profilbild ist zu groß. Maximal 5 MB."); + setErrorMessage("Profilbild ist zu gross. Maximal 5 MB."); return; } - try { const dataUrl = await toOptimizedAvatarDataUrl(file); setImage(dataUrl); @@ -276,162 +309,74 @@ export default function EinstellungenPage() { } } + function handleSaveProfile() { + setStatusMessage(null); + setErrorMessage(null); + updateMyProfile.mutate({ + firstName: firstName.trim(), + lastName: lastName.trim() || null, + phone: phone.trim() || null, + mobile: mobile.trim() || null, + street: street.trim() || null, + postalCode: postalCode.trim() || null, + city: city.trim() || null, + image, + }); + } + + function handleChangePassword() { + setStatusMessage(null); + setErrorMessage(null); + changeMyPassword.mutate({ currentPassword, newPassword }); + } + return ( - -
-
-

Ziele

-
- {[ - { icon: FireIcon, label: "Kalorien", value: `${healthGoals?.calories ?? 2000} kcal`, color: "text-orange-500" }, - { icon: ScaleIcon, label: "Makros", value: `${healthGoals?.carbs ?? 200}/${healthGoals?.fat ?? 67}/${healthGoals?.protein ?? 150} g`, color: "text-blue-500" }, - { icon: ClockIcon, label: "Fasten", value: `${healthGoals?.fastingHours ?? 12} h`, color: "text-rose-500" }, - { icon: BeakerIcon, label: "Wasser", value: `${healthGoals?.drinkMl ?? 2500} ml`, color: "text-blue-500" }, - { icon: ScaleIcon, label: "Gewicht", value: `${healthGoals?.weightKg ?? 120} kg`, color: "text-orange-500" }, - ].map((item) => ( - - - {item.label} - {item.value} - - - ))} -
-
+ +
+ -
-

Rezept-Präferenzen

-
-
-

Ernährung

-
- {dietPreferenceOptions.map((option) => ( - - ))} + + + {profileQuery.isLoading ? ( +
+ Profil wird geladen... +
+ ) : null} + + {active === "profil" ? ( + +
-
-
- - - -
- - -
-
- -
-

Anwendung

-
-
- -
-

Tägliche Erinnerung

-

Tracking und Planung

-
- - - -
-
- {isDark ? : } -
-

Dunkles Design

-

{isDark ? "aktiv" : "inaktiv"}

-
- -
-
-
-
- -
-
-
-

Mein Profil

-

- Du kannst dein eigenes Profil bearbeiten. Höhere Admins können dich zusätzlich in - der Benutzerverwaltung bearbeiten. -

-
- -
-
- - setFirstName(event.target.value)} - placeholder="Vorname" - /> -
-
- - setLastName(event.target.value)} - placeholder="Nachname" - /> -
-
- -
-
+ } + > +
+
+
{image ? ( // eslint-disable-next-line @next/next/no-img-element - Profilbild + Profilbild ) : ( -
- - +
+ {(firstName || profileQuery.data?.email || "?").slice(0, 1).toUpperCase()}
)}
-
-
+ +
+
+ + setFirstName(event.target.value)} + placeholder="Vorname" + /> +
+
+ + setLastName(event.target.value)} + placeholder="Nachname" + /> +
+
+ + +
+
+ + setPhone(event.target.value)} + placeholder="Telefon" + /> +
+
+ + setMobile(event.target.value)} + placeholder="Mobil" + /> +
+
+ + setStreet(event.target.value)} + placeholder="Strasse und Hausnummer" + /> +
+
+ + setPostalCode(event.target.value)} + placeholder="PLZ" + /> +
+
+ + setCity(event.target.value)} + placeholder="Ort" + />
-
- - -
-
- - setPhone(event.target.value)} - placeholder="Telefon" - /> -
-
- - setMobile(event.target.value)} - placeholder="Mobil" - /> -
-
- - setStreet(event.target.value)} - placeholder="Straße und Hausnummer" - /> -
-
- - setPostalCode(event.target.value)} - placeholder="PLZ" - /> -
-
- - setCity(event.target.value)} - placeholder="Ort" - /> -
-
+ + ) : null} -
- -
-
- -
-

Sicherheit

-

- 2FA Status:{" "} - {securityQuery.data?.twoFactorEnabled ? "aktiv" : "nicht aktiv"} -

- {securityQuery.data?.twoFactorRecoveryRequired && ( -

- 2FA Recovery aktiv: Bitte OTP neu einrichten. -

- )} - -
- - 2FA verwalten - -
- -
-

Passkeys

-

- Passkeys erlauben die Anmeldung per Gerät (z. B. Windows Hello, Touch ID oder - Passwortmanager). -

-
- setNewPasskeyName(event.target.value)} - placeholder="Name (optional), z. B. Laptop" - className="flex-1 border border-gray-200 rounded-lg px-3 py-2.5 text-sm" - /> - -
-
- {passkeysQuery.isPending && ( -

Passkeys werden geladen...

- )} - {passkeysQuery.error && ( -

- {passkeysQuery.error.message ?? "Passkeys konnten nicht geladen werden."} -

- )} - {!passkeysQuery.isPending && - !passkeysQuery.error && - (passkeysQuery.data?.length ?? 0) === 0 && ( -

Noch keine Passkeys hinterlegt.

- )} - {(passkeysQuery.data ?? []).map((item) => ( -
+
+ Praeferenzen speichern + +
+ } + > +
+

Ernaehrung

+
+ {dietPreferenceOptions.map((option) => { + const selected = dietPreferences.includes(option.id); + return ( + + ); + })} +
+
+
+ + + +
+ + + ) : null} + + {active === "ziele" ? ( + +
+ {[ + { + icon: FireIcon, + label: "Kalorien", + value: `${healthGoals?.calories ?? 2000} kcal`, + color: "text-orange-500", + }, + { + icon: ScaleIcon, + label: "Makros (KH / Fett / Eiweiss)", + value: `${healthGoals?.carbs ?? 200} / ${healthGoals?.fat ?? 67} / ${ + healthGoals?.protein ?? 150 + } g`, + color: "text-blue-500", + }, + { + icon: ClockIcon, + label: "Fasten", + value: `${healthGoals?.fastingHours ?? 12} h`, + color: "text-rose-500", + }, + { + icon: BeakerIcon, + label: "Wasser", + value: `${healthGoals?.drinkMl ?? 2500} ml`, + color: "text-cyan-500", + }, + { + icon: ScaleIcon, + label: "Gewicht", + value: `${healthGoals?.weightKg ?? 120} kg`, + color: "text-emerald-500", + }, + ].map((item) => ( + + + + {item.label} + + {item.value} + + ))}
-
- -
-

Passwort zurücksetzen

-
- setCurrentPassword(event.target.value)} - /> - -
-
- setNewPassword(event.target.value)} - /> - -
-
- setConfirmNewPassword(event.target.value)} - /> - -
- {passwordMismatch && ( -

Die neuen Passwörter stimmen nicht überein.

- )} - - -
-
- -
-
-

Darstellung

-

Erscheinungsbild der Anwendung anpassen.

-
-
-
- {isDark ? ( - - ) : ( - - )} -
-

Dunkles Design

-

- {isDark ? "Dunkles Design ist aktiv" : "Helles Design ist aktiv"} -

+ {isPlatformAdmin ? ( +
+ Als Admin kannst du die{" "} + + Punkte-Konfiguration + {" "} + global anpassen.
-
- +
+
+ {passkeysQuery.isPending ? ( +

Passkeys werden geladen...

+ ) : null} + {passkeysQuery.error ? ( +

+ {passkeysQuery.error.message ?? "Passkeys konnten nicht geladen werden."} +

+ ) : null} + {!passkeysQuery.isPending && + !passkeysQuery.error && + (passkeysQuery.data?.length ?? 0) === 0 ? ( +

Noch keine Passkeys hinterlegt.

+ ) : null} + {(passkeysQuery.data ?? []).map((item) => ( +
+

+ {item.name || "Unbenannter Passkey"} +

+

+ Erstellt: {new Date(item.createdAt).toLocaleString("de-DE")} +

+
+ ))} +
+ + + + + +
+ } + > + setShowCurrentPassword((value) => !value)} + placeholder="Aktuelles Passwort" /> - + setShowNewPassword((value) => !value)} + placeholder="Neues Passwort (mind. 16 Zeichen)" + /> + setShowConfirmPassword((value) => !value)} + placeholder="Neues Passwort bestaetigen" + invalid={passwordMismatch} + /> + {passwordMismatch ? ( +

+ Die neuen Passwoerter stimmen nicht ueberein. +

+ ) : null} +
- + ) : null} + + {active === "anwendung" ? ( +
+ +
+
+ {isDark ? ( + + ) : ( + + )} +
+

Dunkles Design

+

+ {isDark ? "Dunkles Design ist aktiv" : "Helles Design ist aktiv"} +

+
+
+ +
+
+ + + + +
+

+ Taegliche Erinnerungen einrichten +

+

+ Push, E-Mail und Browser-Notifications. +

+
+ + +
+
+ ) : null}
- - {profileQuery.isLoading && ( -
- Profil wird geladen... -
- )} - - {(profileQuery.error || securityQuery.error) && ( -
- {profileQuery.error?.message ?? securityQuery.error?.message} -
- )} - - {statusMessage && ( -
- {statusMessage} -
- )} - - {errorMessage && ( -
- {errorMessage} -
- )}
); } + +function PasswordInput({ + value, + onChange, + show, + onToggle, + placeholder, + invalid, +}: { + value: string; + onChange: (v: string) => void; + show: boolean; + onToggle: () => void; + placeholder: string; + invalid?: boolean; +}) { + return ( +
+ onChange(event.target.value)} + /> + +
+ ); +} diff --git a/apps/admin/app/login/page.tsx b/apps/admin/app/login/page.tsx index 7352c82..7016436 100644 --- a/apps/admin/app/login/page.tsx +++ b/apps/admin/app/login/page.tsx @@ -194,79 +194,130 @@ export default function LoginPage() { } return ( -
- {!otpStepChecked ? ( -
- ) : ( -
-
-
- HH -
-

Hellth Hub

-

Health-Tracking für dein Home-Lab

-
+
+