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) <[email protected]>
This commit is contained in:
1 parent
3bc0693e4d
commit
3107a02588
10 files changed
+215
-93
No files matched your search
@@ -22,7 +22,7 @@ export async function deleteOwnAccountAction(formData: FormData): Promise<void>
|
||||
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<void>
|
||||
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");
|
||||
|
||||
@@ -29,6 +29,6 @@ export async function toggleNewsletterAction(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
revalidatePath("/konto");
|
||||
revalidatePath("/konto/einstellungen");
|
||||
await setFlash("success", "Deine Erntepost-Einstellung wurde gespeichert.");
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="py-20">
|
||||
<Container className="max-w-2xl">
|
||||
<BackLink href="/konto" label={t.account.settings.backLabel} />
|
||||
<SectionHeading level="h1" title={t.account.settings.title} subtitle={t.account.settings.subtitle} />
|
||||
|
||||
<section aria-labelledby="einstellungen-sicherheit" className="mt-12">
|
||||
<h2 id="einstellungen-sicherheit" className="text-2xl text-heading mb-4">
|
||||
{t.account.settings.securityHeading}
|
||||
</h2>
|
||||
|
||||
<div className="border border-ink/10 p-6 flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
|
||||
<div>
|
||||
<h3 className="flex items-center gap-2 text-lg text-heading">
|
||||
<ShieldIcon />
|
||||
{t.account.settings.twoFactorHeading}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-ink/70">
|
||||
{customer.twoFactorEnabled
|
||||
? t.account.settings.twoFactorActiveDescription
|
||||
: t.account.settings.twoFactorInactiveDescription}
|
||||
</p>
|
||||
</div>
|
||||
<LinkButton
|
||||
href="/konto/zwei-faktor"
|
||||
variant={customer.twoFactorEnabled ? "secondary" : "primary"}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
{customer.twoFactorEnabled ? t.account.settings.twoFactorManage : t.account.settings.twoFactorSetup}
|
||||
</LinkButton>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 border border-ink/10 p-6 flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
|
||||
<div>
|
||||
<h3 className="text-lg text-heading">{t.account.logoutAllDevicesHeading}</h3>
|
||||
<p className="mt-1 text-sm text-ink/70">{t.account.logoutAllDevicesDescription}</p>
|
||||
</div>
|
||||
<form action={logoutAllDevicesAction} className="flex-shrink-0">
|
||||
<Button type="submit" variant="secondary">
|
||||
{t.account.settings.logoutAllDevicesButton}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="einstellungen-datenschutz" className="mt-12">
|
||||
<h2 id="einstellungen-datenschutz" className="text-2xl text-heading mb-4">
|
||||
{t.account.privacy.heading}
|
||||
</h2>
|
||||
|
||||
<div className="border border-ink/10 p-6 flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
|
||||
<div>
|
||||
<h3 className="text-lg text-heading">{t.account.newsletterHeading}</h3>
|
||||
<p className="mt-1 text-sm text-ink/70">
|
||||
{isSubscribed ? t.account.newsletterSubscribed : t.account.newsletterNotSubscribed}
|
||||
</p>
|
||||
</div>
|
||||
<form action={toggleNewsletterAction} className="flex-shrink-0">
|
||||
<Button type="submit" variant="secondary">
|
||||
{isSubscribed ? t.account.unsubscribe : t.account.subscribe}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 border border-ink/10 p-6 flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
|
||||
<div>
|
||||
<h3 className="text-lg text-heading">{t.account.privacy.downloadDataHeading}</h3>
|
||||
<p className="mt-1 text-sm text-ink/70">{t.account.privacy.downloadDataDescription}</p>
|
||||
</div>
|
||||
<LinkButton href="/api/konto/datenexport" variant="secondary" className="flex-shrink-0">
|
||||
{t.account.privacy.downloadDataButton}
|
||||
</LinkButton>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 border border-red-200 bg-red-50/40 p-6">
|
||||
<h3 className="text-lg text-red-700">{t.account.privacy.deleteAccountHeading}</h3>
|
||||
{customer.role === "ADMIN" ? (
|
||||
<p className="mt-1 text-sm text-ink/70">{t.account.privacy.deleteAccountAdminBlocked}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="mt-1 text-sm text-ink/70">{t.account.privacy.deleteAccountDescription}</p>
|
||||
<ConfirmForm
|
||||
action={deleteOwnAccountAction}
|
||||
confirmMessage={t.account.privacy.deleteAccountConfirm}
|
||||
className="mt-4 flex flex-wrap items-end gap-3"
|
||||
>
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.account.privacy.deleteAccountPasswordLabel}</span>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-red-400"
|
||||
/>
|
||||
</label>
|
||||
<Button type="submit" variant="secondary" className="border-red-300 text-red-700 hover:bg-red-100">
|
||||
{t.account.privacy.deleteAccountButton}
|
||||
</Button>
|
||||
</ConfirmForm>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+7
-80
@@ -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 (
|
||||
<div className="py-20">
|
||||
<Container className="max-w-2xl">
|
||||
@@ -155,10 +145,10 @@ export default async function KontoPage() {
|
||||
description={t.account.navWishlist.description}
|
||||
/>
|
||||
<AccountNavCard
|
||||
href="/konto/zwei-faktor"
|
||||
icon={<ShieldIcon />}
|
||||
title={t.account.navSecurity.title}
|
||||
description={`${t.account.navSecurity.twoFactorLabel}: ${customer.twoFactorEnabled ? t.account.navSecurity.twoFactorActive : t.account.navSecurity.twoFactorInactive}`}
|
||||
href="/konto/einstellungen"
|
||||
icon={<SettingsIcon />}
|
||||
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" ? (
|
||||
<AccountNavCard
|
||||
@@ -172,74 +162,11 @@ export default async function KontoPage() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-10 border border-ink/10 p-6 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl text-heading">{t.account.newsletterHeading}</h2>
|
||||
<p className="mt-1 text-sm text-ink/70">
|
||||
{isSubscribed ? t.account.newsletterSubscribed : t.account.newsletterNotSubscribed}
|
||||
</p>
|
||||
</div>
|
||||
<form action={toggleNewsletterAction}>
|
||||
<Button type="submit" variant="secondary">
|
||||
{isSubscribed ? t.account.unsubscribe : t.account.subscribe}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 border border-ink/10 p-6 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl text-heading">{t.account.logoutAllDevicesHeading}</h2>
|
||||
<p className="mt-1 text-sm text-ink/70">
|
||||
{t.account.logoutAllDevicesDescription}
|
||||
</p>
|
||||
</div>
|
||||
<form action={logoutAllDevicesAction}>
|
||||
<Button type="submit" variant="secondary">
|
||||
{t.account.logout}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<form action={logoutAction} className="mt-6 mb-10">
|
||||
<form action={logoutAction} className="mt-10">
|
||||
<Button type="submit" variant="ghost">
|
||||
{t.account.logout}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<h2 className="text-2xl text-heading mb-4">{t.account.privacy.heading}</h2>
|
||||
<div className="border border-ink/10 p-6 flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
|
||||
<div>
|
||||
<h3 className="text-lg text-heading">{t.account.privacy.downloadDataHeading}</h3>
|
||||
<p className="mt-1 text-sm text-ink/70">{t.account.privacy.downloadDataDescription}</p>
|
||||
</div>
|
||||
<LinkButton href="/api/konto/datenexport" variant="secondary" className="flex-shrink-0">
|
||||
{t.account.privacy.downloadDataButton}
|
||||
</LinkButton>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 border border-red-200 bg-red-50/40 p-6">
|
||||
<h3 className="text-lg text-red-700">{t.account.privacy.deleteAccountHeading}</h3>
|
||||
<p className="mt-1 text-sm text-ink/70">{t.account.privacy.deleteAccountDescription}</p>
|
||||
<ConfirmForm
|
||||
action={deleteOwnAccountAction}
|
||||
confirmMessage={t.account.privacy.deleteAccountConfirm}
|
||||
className="mt-4 flex flex-wrap items-end gap-3"
|
||||
>
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.account.privacy.deleteAccountPasswordLabel}</span>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-red-400"
|
||||
/>
|
||||
</label>
|
||||
<Button type="submit" variant="secondary" className="border-red-300 text-red-700 hover:bg-red-100">
|
||||
{t.account.privacy.deleteAccountButton}
|
||||
</Button>
|
||||
</ConfirmForm>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -27,7 +27,7 @@ export default async function ZweiFaktorPage() {
|
||||
<div className="py-20">
|
||||
<Container className="max-w-lg">
|
||||
<FormPanel>
|
||||
<BackLink href="/konto" label={t.twoFactorPage.backLabel} />
|
||||
<BackLink href="/konto/einstellungen" label={t.twoFactorPage.backLabel} />
|
||||
<SectionHeading
|
||||
title={t.twoFactorPage.title}
|
||||
subtitle={t.twoFactorPage.subtitle}
|
||||
|
||||
Reference in new issue
Block a user