From 3107a0258884bf15bc0d1cc227f598c01c111852 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Wed, 30 Sep 2026 21:20:37 +0200 Subject: [PATCH] feat(konto): settings page for security and privacy Adds /konto/einstellungen, which gathers two-factor authentication, "sign out on all devices", the Erntepost consent, the data export (Art. 15 GDPR) and account deletion (Art. 17 GDPR) in one place. The account overview now links there through a "Settings" card instead of listing these blocks inline. Server actions are unchanged apart from redirecting failures and revalidating the new page. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/konto/actions/account.ts | 6 +- src/app/konto/actions/newsletter.ts | 2 +- src/app/konto/einstellungen/page.tsx | 141 +++++++++++++++++++++++++++ src/app/konto/layout.tsx | 2 +- src/app/konto/page.tsx | 87 ++--------------- src/app/konto/zwei-faktor/page.tsx | 2 +- src/components/account-ui.tsx | 10 ++ src/i18n/dictionaries/de.ts | 19 +++- src/i18n/dictionaries/en.ts | 19 +++- src/i18n/dictionary-type.ts | 20 +++- 10 files changed, 215 insertions(+), 93 deletions(-) create mode 100644 src/app/konto/einstellungen/page.tsx diff --git a/src/app/konto/actions/account.ts b/src/app/konto/actions/account.ts index a1ad07b..7d223b5 100644 --- a/src/app/konto/actions/account.ts +++ b/src/app/konto/actions/account.ts @@ -22,7 +22,7 @@ export async function deleteOwnAccountAction(formData: FormData): Promise if (!customer) return fail("/konto", "Bitte melde dich an."); if (customer.role === "ADMIN") { - return fail("/konto", "Admin-Konten können nicht selbst gelöscht werden. Bitte kontaktiere einen anderen Admin."); + return fail("/konto/einstellungen", "Admin-Konten können nicht selbst gelöscht werden. Bitte kontaktiere einen anderen Admin."); } const ip = await getClientIp(); @@ -33,13 +33,13 @@ export async function deleteOwnAccountAction(formData: FormData): Promise ACCOUNT_PASSWORD_CONFIRM_WINDOW_MS ) ) { - return fail("/konto", "Zu viele Versuche. Bitte versuche es später erneut."); + return fail("/konto/einstellungen", "Zu viele Versuche. Bitte versuche es später erneut."); } const password = String(formData.get("password") ?? ""); const passwordValid = await verifyPassword(password, customer.passwordHash); if (!passwordValid) { - return fail("/konto", "Passwort ist falsch."); + return fail("/konto/einstellungen", "Passwort ist falsch."); } const { subject, html, text } = await securityAlertEmail(await getLocale(), "accountDeleted"); diff --git a/src/app/konto/actions/newsletter.ts b/src/app/konto/actions/newsletter.ts index e4083c5..6b0febd 100644 --- a/src/app/konto/actions/newsletter.ts +++ b/src/app/konto/actions/newsletter.ts @@ -29,6 +29,6 @@ export async function toggleNewsletterAction(): Promise { }); } - revalidatePath("/konto"); + revalidatePath("/konto/einstellungen"); await setFlash("success", "Deine Erntepost-Einstellung wurde gespeichert."); } diff --git a/src/app/konto/einstellungen/page.tsx b/src/app/konto/einstellungen/page.tsx new file mode 100644 index 0000000..d8922d8 --- /dev/null +++ b/src/app/konto/einstellungen/page.tsx @@ -0,0 +1,141 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; +import { Container, SectionHeading, Button, LinkButton } from "@/components/ui"; +import { BackLink } from "@/components/back-link"; +import { ShieldIcon } from "@/components/account-ui"; +import { ConfirmForm } from "@/components/confirm-form"; +import { getLocale } from "@/i18n/locale"; +import { getDictionary } from "@/i18n/get-dictionary"; +import { getCurrentCustomer } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { toggleNewsletterAction, logoutAllDevicesAction, deleteOwnAccountAction } from "../actions"; + +export const metadata: Metadata = { title: "Einstellungen" }; +export const dynamic = "force-dynamic"; + +/** + * Buendelt alles, was das Konto selbst betrifft statt einer Bestellung: + * Sicherheit (Zwei-Faktor, Sitzungen) und Datenschutz (Einwilligung in die + * Erntepost, Auskunft nach Art. 15, Loeschung nach Art. 17 DSGVO). Die + * Aktionen selbst leben unveraendert in ../actions — diese Seite ist nur + * ihr gemeinsamer Ort. + */ +export default async function EinstellungenPage() { + const t = getDictionary(await getLocale()); + + const customer = await getCurrentCustomer(); + if (!customer) redirect("/konto"); + + const newsletter = await prisma.newsletterSubscriber.findUnique({ + where: { customerId: customer.id }, + }); + const isSubscribed = Boolean(newsletter && newsletter.unsubscribedAt === null); + + return ( +
+ + + + +
+

+ {t.account.settings.securityHeading} +

+ +
+
+

+ + {t.account.settings.twoFactorHeading} +

+

+ {customer.twoFactorEnabled + ? t.account.settings.twoFactorActiveDescription + : t.account.settings.twoFactorInactiveDescription} +

+
+ + {customer.twoFactorEnabled ? t.account.settings.twoFactorManage : t.account.settings.twoFactorSetup} + +
+ +
+
+

{t.account.logoutAllDevicesHeading}

+

{t.account.logoutAllDevicesDescription}

+
+
+ +
+
+
+ +
+

+ {t.account.privacy.heading} +

+ +
+
+

{t.account.newsletterHeading}

+

+ {isSubscribed ? t.account.newsletterSubscribed : t.account.newsletterNotSubscribed} +

+
+
+ +
+
+ +
+
+

{t.account.privacy.downloadDataHeading}

+

{t.account.privacy.downloadDataDescription}

+
+ + {t.account.privacy.downloadDataButton} + +
+ +
+

{t.account.privacy.deleteAccountHeading}

+ {customer.role === "ADMIN" ? ( +

{t.account.privacy.deleteAccountAdminBlocked}

+ ) : ( + <> +

{t.account.privacy.deleteAccountDescription}

+ + + + + + )} +
+
+
+
+ ); +} diff --git a/src/app/konto/layout.tsx b/src/app/konto/layout.tsx index ec88976..c2c2597 100644 --- a/src/app/konto/layout.tsx +++ b/src/app/konto/layout.tsx @@ -2,7 +2,7 @@ import type { Metadata } from "next"; import { noIndexPrivate } from "@/lib/seo"; // Der Kontobereich enthaelt ausschliesslich personenbezogene Seiten -// (Bestellungen, Adressen, Wunschliste, Zwei-Faktor-Einrichtung) sowie die +// (Bestellungen, Adressen, Wunschliste, Einstellungen, Zwei-Faktor-Einrichtung) sowie die // Anmeldung selbst. Nichts davon gehoert in den Suchindex. // // Gesetzt einmal hier im Layout statt in jeder Unterseite: /konto hat aktuell diff --git a/src/app/konto/page.tsx b/src/app/konto/page.tsx index e134ee6..23d5e61 100644 --- a/src/app/konto/page.tsx +++ b/src/app/konto/page.tsx @@ -1,23 +1,18 @@ import type { Metadata } from "next"; import Link from "next/link"; -import { Container, SectionHeading, Button, LinkButton } from "@/components/ui"; -import { AccountNavCard, OrderIcon, AddressIcon, HeartIcon, ShieldIcon } from "@/components/account-ui"; +import { Container, SectionHeading, Button } from "@/components/ui"; +import { AccountNavCard, OrderIcon, AddressIcon, HeartIcon, ShieldIcon, SettingsIcon } from "@/components/account-ui"; import { TurnstileField } from "@/components/turnstile-widget"; import { PasswordField } from "@/components/password-field"; -import { ConfirmForm } from "@/components/confirm-form"; import { getLocale } from "@/i18n/locale"; import { getDictionary } from "@/i18n/get-dictionary"; import { getCurrentCustomer } from "@/lib/auth"; import { isEntraConfigured } from "@/lib/entra"; -import { prisma } from "@/lib/prisma"; import { registerAction, loginAction, logoutAction, - toggleNewsletterAction, - logoutAllDevicesAction, resendVerificationEmailAction, - deleteOwnAccountAction, } from "./actions"; export const metadata: Metadata = { title: "Mein Konto" }; @@ -111,11 +106,6 @@ export default async function KontoPage() { ); } - const newsletter = await prisma.newsletterSubscriber.findUnique({ - where: { customerId: customer.id }, - }); - const isSubscribed = Boolean(newsletter && newsletter.unsubscribedAt === null); - return (
@@ -155,10 +145,10 @@ export default async function KontoPage() { description={t.account.navWishlist.description} /> } - title={t.account.navSecurity.title} - description={`${t.account.navSecurity.twoFactorLabel}: ${customer.twoFactorEnabled ? t.account.navSecurity.twoFactorActive : t.account.navSecurity.twoFactorInactive}`} + href="/konto/einstellungen" + icon={} + title={t.account.navSettings.title} + description={`${t.account.navSettings.description} · ${t.account.navSettings.twoFactorLabel}: ${customer.twoFactorEnabled ? t.account.navSettings.twoFactorActive : t.account.navSettings.twoFactorInactive}`} /> {customer.role === "ADMIN" ? ( -
-
-

{t.account.newsletterHeading}

-

- {isSubscribed ? t.account.newsletterSubscribed : t.account.newsletterNotSubscribed} -

-
-
- -
-
- -
-
-

{t.account.logoutAllDevicesHeading}

-

- {t.account.logoutAllDevicesDescription} -

-
-
- -
-
- -
+
- -

{t.account.privacy.heading}

-
-
-

{t.account.privacy.downloadDataHeading}

-

{t.account.privacy.downloadDataDescription}

-
- - {t.account.privacy.downloadDataButton} - -
- -
-

{t.account.privacy.deleteAccountHeading}

-

{t.account.privacy.deleteAccountDescription}

- - - - -
); diff --git a/src/app/konto/zwei-faktor/page.tsx b/src/app/konto/zwei-faktor/page.tsx index 83d5e0d..f6f91e1 100644 --- a/src/app/konto/zwei-faktor/page.tsx +++ b/src/app/konto/zwei-faktor/page.tsx @@ -27,7 +27,7 @@ export default async function ZweiFaktorPage() {
- + + + + + + ); +} + export function ShieldIcon() { return ( diff --git a/src/i18n/dictionaries/de.ts b/src/i18n/dictionaries/de.ts index 72a2a45..d0f40a7 100644 --- a/src/i18n/dictionaries/de.ts +++ b/src/i18n/dictionaries/de.ts @@ -732,7 +732,7 @@ export const de: Dictionary = { twoFactorPage: { title: "Zwei-Faktor-Authentifizierung", subtitle: "Schützt dein Konto zusätzlich zum Passwort mit einem Code aus einer Authenticator-App.", - backLabel: "Zurück zur Übersicht", + backLabel: "Zurück zu den Einstellungen", activeStatus: (count) => `Zwei-Faktor-Authentifizierung ist aktiv. Noch ${count} Backup-Code${count === 1 ? "" : "s"} verfügbar.`, disableHeading: "Deaktivieren", passwordConfirmLabel: "Passwort zur Bestätigung", @@ -1631,8 +1631,9 @@ export const de: Dictionary = { navOrders: { title: "Bestellungen", description: "Bestellhistorie, Versandstatus und Rechnungen" }, navAddresses: { title: "Adressen", description: "Lieferadressen verwalten" }, navWishlist: { title: "Wunschliste", description: "Gemerkte Produkte ansehen" }, - navSecurity: { - title: "Sicherheit", + navSettings: { + title: "Einstellungen", + description: "Sicherheit und Datenschutz", twoFactorLabel: "Zwei-Faktor-Authentifizierung", twoFactorActive: "aktiv", twoFactorInactive: "nicht aktiv", @@ -1646,6 +1647,18 @@ export const de: Dictionary = { logoutAllDevicesHeading: "Alle Geräte abmelden", logoutAllDevicesDescription: "Beendet alle aktiven Sitzungen, auch dieses Gerät. Du musst dich danach neu anmelden.", logout: "Abmelden", + settings: { + title: "Einstellungen", + subtitle: "Sicherheit deines Kontos und deine Daten an einem Ort.", + backLabel: "Zurück zu meinem Konto", + securityHeading: "Sicherheit", + twoFactorHeading: "Zwei-Faktor-Authentifizierung", + twoFactorActiveDescription: "Aktiv. Bei der Anmeldung fragen wir zusätzlich einen Code aus deiner Authenticator-App ab.", + twoFactorInactiveDescription: "Nicht aktiv. Wir empfehlen, dein Konto zusätzlich mit einem Code aus einer Authenticator-App zu schützen.", + twoFactorManage: "Verwalten", + twoFactorSetup: "Einrichten", + logoutAllDevicesButton: "Überall abmelden", + }, privacy: { heading: "Datenschutz", downloadDataHeading: "Meine Daten herunterladen", diff --git a/src/i18n/dictionaries/en.ts b/src/i18n/dictionaries/en.ts index bc4c4c5..e897376 100644 --- a/src/i18n/dictionaries/en.ts +++ b/src/i18n/dictionaries/en.ts @@ -708,7 +708,7 @@ export const en: Dictionary = { twoFactorPage: { title: "Two-Factor Authentication", subtitle: "Protects your account beyond just a password, with a code from an authenticator app.", - backLabel: "Back to overview", + backLabel: "Back to settings", activeStatus: (count) => `Two-factor authentication is active. ${count} backup code${count === 1 ? "" : "s"} remaining.`, disableHeading: "Disable", passwordConfirmLabel: "Password to confirm", @@ -1605,8 +1605,9 @@ export const en: Dictionary = { navOrders: { title: "Orders", description: "Order history, shipping status, and invoices" }, navAddresses: { title: "Addresses", description: "Manage delivery addresses" }, navWishlist: { title: "Wishlist", description: "View saved products" }, - navSecurity: { - title: "Security", + navSettings: { + title: "Settings", + description: "Security and privacy", twoFactorLabel: "Two-factor authentication", twoFactorActive: "active", twoFactorInactive: "not active", @@ -1620,6 +1621,18 @@ export const en: Dictionary = { logoutAllDevicesHeading: "Log out all devices", logoutAllDevicesDescription: "Ends all active sessions, including this device. You'll need to sign in again.", logout: "Log out", + settings: { + title: "Settings", + subtitle: "Your account security and your data in one place.", + backLabel: "Back to my account", + securityHeading: "Security", + twoFactorHeading: "Two-factor authentication", + twoFactorActiveDescription: "Active. When you sign in, we also ask for a code from your authenticator app.", + twoFactorInactiveDescription: "Not active. We recommend protecting your account with an additional code from an authenticator app.", + twoFactorManage: "Manage", + twoFactorSetup: "Set up", + logoutAllDevicesButton: "Sign out everywhere", + }, privacy: { heading: "Privacy", downloadDataHeading: "Download my data", diff --git a/src/i18n/dictionary-type.ts b/src/i18n/dictionary-type.ts index f1cb020..62a4fae 100644 --- a/src/i18n/dictionary-type.ts +++ b/src/i18n/dictionary-type.ts @@ -1167,7 +1167,13 @@ export interface Dictionary { navOrders: { title: string; description: string }; navAddresses: { title: string; description: string }; navWishlist: { title: string; description: string }; - navSecurity: { title: string; twoFactorLabel: string; twoFactorActive: string; twoFactorInactive: string }; + navSettings: { + title: string; + description: string; + twoFactorLabel: string; + twoFactorActive: string; + twoFactorInactive: string; + }; navAdmin: { title: string; description: string }; newsletterHeading: string; newsletterSubscribed: string; @@ -1177,6 +1183,18 @@ export interface Dictionary { logoutAllDevicesHeading: string; logoutAllDevicesDescription: string; logout: string; + settings: { + title: string; + subtitle: string; + backLabel: string; + securityHeading: string; + twoFactorHeading: string; + twoFactorActiveDescription: string; + twoFactorInactiveDescription: string; + twoFactorManage: string; + twoFactorSetup: string; + logoutAllDevicesButton: string; + }; privacy: { heading: string; downloadDataHeading: string;