feat: sichtbare Rueckmeldung fuer alle Aktionen (Toast statt URL-Meldungen)

Viele Aktionen gaben keine Rueckmeldung (Retoure annehmen, Archiv,
Bewertungen, Adressen, Newsletter, Kundensperre ...) oder meldeten Erfolg,
obwohl die zugehoerige E-Mail nie rausging.

- lib/flash: Meldung als kurzlebiges Cookie; Toast im Root-Layout
  (FlashToast). Ersetzt ?message=/?error= in der URL — darueber liess sich
  bisher beliebiger Text als Systemmeldung im Wabenhain-Layout anzeigen
  (Phishing-Hebel). Server Components leiten ueber /api/hinweis mit festen
  Codes und festen Zielen weiter.
- fail()/succeed() zentral und asynchron; ein Quelltext-Test stellt sicher,
  dass jeder Aufruf mit await/return erfolgt (sonst liefe eine Action nach
  einem Fehler weiter).
- sendMail meldet, ob die Mail angenommen wurde; Aktionen mit Mail zeigen
  ehrlich an, wenn der Versand scheitert (z.B. "Link erneut senden").
- Jede Server Action meldet jetzt Erfolg oder den Grund des Abbruchs.
- SubmitFeedback: geklickter Knopf zeigt sofort einen Ladezustand und ist
  bis zur Antwort gegen Doppelklick gesperrt.
- 2FA: nach dem Einrichten zurueck ins Profil statt auf die Seite mit
  "Deaktivieren".

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-25 04:36:36 +02:00
1 parent fb9c2353c6
commit 839bbff595
71 files changed
+762 -416

No files matched your search

+1 -5
View File
@@ -1,8 +1,4 @@
import { redirect } from "next/navigation";
export function fail(path: string, message: string): never {
redirect(`${path}?error=${encodeURIComponent(message)}`);
}
export { fail, succeed } from "@/lib/flash";
// Passwortbestaetigung fuer sicherheitskritische Kontoaktionen (2FA
// deaktivieren, Konto loeschen) — ohne dieses Limit koennte eine gestohlene
+8 -8
View File
@@ -1,6 +1,5 @@
"use server";
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { getCurrentCustomer, verifyPassword, destroySession } from "@/lib/auth";
import { rateLimit, getClientIp } from "@/lib/rate-limit";
@@ -8,6 +7,7 @@ import { sendMail } from "@/lib/mail";
import { securityAlertEmail } from "@/lib/email-templates";
import { getLocale } from "@/i18n/locale";
import { fail, ACCOUNT_PASSWORD_CONFIRM_ATTEMPTS_PER_WINDOW, ACCOUNT_PASSWORD_CONFIRM_WINDOW_MS } from "./_shared";
import { succeed } from "@/lib/flash";
/**
* Selbstbedienungs-Kontoloeschung (Art. 17 DSGVO). Bestellungen bleiben aus
@@ -19,10 +19,10 @@ import { fail, ACCOUNT_PASSWORD_CONFIRM_ATTEMPTS_PER_WINDOW, ACCOUNT_PASSWORD_CO
*/
export async function deleteOwnAccountAction(formData: FormData): Promise<void> {
const customer = await getCurrentCustomer();
if (!customer) fail("/konto", "Bitte melde dich an.");
if (!customer) return fail("/konto", "Bitte melde dich an.");
if (customer.role === "ADMIN") {
fail("/konto", "Admin-Konten können nicht selbst gelöscht werden. Bitte kontaktiere einen anderen Admin.");
return fail("/konto", "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
)
) {
fail("/konto", "Zu viele Versuche. Bitte versuche es später erneut.");
return fail("/konto", "Zu viele Versuche. Bitte versuche es später erneut.");
}
const password = String(formData.get("password") ?? "");
const passwordValid = await verifyPassword(password, customer.passwordHash);
if (!passwordValid) {
fail("/konto", "Passwort ist falsch.");
return fail("/konto", "Passwort ist falsch.");
}
const { subject, html, text } = await securityAlertEmail(await getLocale(), "accountDeleted");
@@ -47,7 +47,7 @@ export async function deleteOwnAccountAction(formData: FormData): Promise<void>
await prisma.customer.delete({ where: { id: customer.id } });
await destroySession();
redirect("/konto?message=" + encodeURIComponent("Dein Konto wurde endgültig gelöscht."));
return succeed("/konto", "Dein Konto wurde endgültig gelöscht.");
}
/**
@@ -59,7 +59,7 @@ export async function deleteOwnAccountAction(formData: FormData): Promise<void>
*/
export async function logoutAllDevicesAction(): Promise<void> {
const customer = await getCurrentCustomer();
if (!customer) fail("/konto", "Bitte melde dich an.");
if (!customer) return fail("/konto", "Bitte melde dich an.");
await prisma.customer.update({
where: { id: customer.id },
@@ -67,5 +67,5 @@ export async function logoutAllDevicesAction(): Promise<void> {
});
await destroySession();
redirect("/konto?message=" + encodeURIComponent("Alle Geräte wurden abgemeldet. Bitte melde dich erneut an."));
return succeed("/konto", "Alle Geräte wurden abgemeldet. Bitte melde dich erneut an.");
}
+9 -5
View File
@@ -5,10 +5,11 @@ import { prisma } from "@/lib/prisma";
import { getCurrentCustomer } from "@/lib/auth";
import { addressSchema } from "@/lib/validation";
import { fail } from "./_shared";
import { setFlash } from "@/lib/flash";
export async function addAddressAction(formData: FormData): Promise<void> {
const customer = await getCurrentCustomer();
if (!customer) fail("/konto", "Bitte melde dich an.");
if (!customer) return fail("/konto", "Bitte melde dich an.");
const parsed = addressSchema.safeParse({
label: formData.get("label"),
@@ -21,7 +22,7 @@ export async function addAddressAction(formData: FormData): Promise<void> {
isDefault: formData.get("isDefault") === "on",
});
if (!parsed.success) {
fail("/konto/adressen", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
return fail("/konto/adressen", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
}
if (parsed.data.isDefault) {
@@ -30,26 +31,29 @@ export async function addAddressAction(formData: FormData): Promise<void> {
await prisma.address.create({ data: { ...parsed.data, customerId: customer.id } });
revalidatePath("/konto/adressen");
await setFlash("success", "Adresse gespeichert.");
}
export async function deleteAddressAction(formData: FormData): Promise<void> {
const customer = await getCurrentCustomer();
if (!customer) fail("/konto", "Bitte melde dich an.");
if (!customer) return fail("/konto", "Bitte melde dich an.");
const addressId = String(formData.get("addressId") ?? "");
await prisma.address.deleteMany({ where: { id: addressId, customerId: customer.id } });
revalidatePath("/konto/adressen");
await setFlash("success", "Adresse gelöscht.");
}
export async function setDefaultAddressAction(formData: FormData): Promise<void> {
const customer = await getCurrentCustomer();
if (!customer) fail("/konto", "Bitte melde dich an.");
if (!customer) return fail("/konto", "Bitte melde dich an.");
const addressId = String(formData.get("addressId") ?? "");
const address = await prisma.address.findFirst({ where: { id: addressId, customerId: customer.id } });
if (!address) fail("/konto/adressen", "Adresse nicht gefunden.");
if (!address) return fail("/konto/adressen", "Adresse nicht gefunden.");
await prisma.address.updateMany({ where: { customerId: customer.id }, data: { isDefault: false } });
await prisma.address.update({ where: { id: address.id }, data: { isDefault: true } });
revalidatePath("/konto/adressen");
await setFlash("success", "Standardadresse geändert.");
}
+14 -13
View File
@@ -10,6 +10,7 @@ import { rateLimit, getClientIp } from "@/lib/rate-limit";
import type { Customer } from "@/generated/prisma/client";
import { fail } from "./_shared";
import { isTurnstileValid } from "@/lib/turnstile";
import { succeed } from "@/lib/flash";
const LOGIN_ATTEMPTS_PER_WINDOW = 10;
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
@@ -37,13 +38,13 @@ async function registerFailedLogin(customer: Pick<Customer, "id" | "failedLoginA
export async function loginAction(formData: FormData): Promise<void> {
const ip = await getClientIp();
if (!rateLimit(`login:ip:${ip}`, LOGIN_ATTEMPTS_PER_WINDOW, LOGIN_WINDOW_MS)) {
fail("/konto", "Zu viele Anmeldeversuche. Bitte versuche es in 15 Minuten erneut.");
return fail("/konto", "Zu viele Anmeldeversuche. Bitte versuche es in 15 Minuten erneut.");
}
// CAPTCHA vor jeder weiteren Arbeit: haelt automatisierte Versuche ab,
// bevor sie Datenbank, Mailversand oder das Ratenlimit belasten.
if (!(await isTurnstileValid(formData, ip))) {
fail("/konto", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
return fail("/konto", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
}
const parsed = loginSchema.safeParse({
@@ -51,23 +52,23 @@ export async function loginAction(formData: FormData): Promise<void> {
password: formData.get("password"),
});
if (!parsed.success) {
fail("/konto", "Bitte E-Mail und Passwort angeben.");
return fail("/konto", "Bitte E-Mail und Passwort angeben.");
}
// Zusaetzlich zum IP-Limit ein Limit pro E-Mail-Adresse, damit verteilte
// Versuche (viele IPs, ein Konto) nicht am IP-Rate-Limit vorbeikommen.
if (!rateLimit(`login:email:${parsed.data.email}`, LOGIN_ATTEMPTS_PER_EMAIL_WINDOW, LOGIN_WINDOW_MS)) {
fail("/konto", "Zu viele Anmeldeversuche für dieses Konto. Bitte versuche es später erneut.");
return fail("/konto", "Zu viele Anmeldeversuche für dieses Konto. Bitte versuche es später erneut.");
}
const customer = await prisma.customer.findUnique({ where: { email: parsed.data.email } });
if (customer?.blockedAt) {
fail("/konto", "Dieses Konto wurde gesperrt. Bitte kontaktiere uns.");
return fail("/konto", "Dieses Konto wurde gesperrt. Bitte kontaktiere uns.");
}
if (customer?.lockedUntil && customer.lockedUntil > new Date()) {
fail(
return fail(
"/konto",
"Dieses Konto ist wegen zu vieler Fehlversuche vorübergehend gesperrt. Bitte versuche es später erneut oder setze dein Passwort zurück."
);
@@ -79,7 +80,7 @@ export async function loginAction(formData: FormData): Promise<void> {
if (!customer || !passwordValid) {
if (customer) await registerFailedLogin(customer);
fail("/konto", "E-Mail-Adresse oder Passwort ist falsch.");
return fail("/konto", "E-Mail-Adresse oder Passwort ist falsch.");
}
if (customer.failedLoginAttempts > 0 || customer.lockedUntil) {
@@ -102,24 +103,24 @@ export async function loginAction(formData: FormData): Promise<void> {
export async function verifyTwoFactorAction(formData: FormData): Promise<void> {
const customerId = await getTwoFactorChallengeCustomerId();
if (!customerId) {
fail("/konto", "Die Anmeldung ist abgelaufen. Bitte melde dich erneut an.");
return fail("/konto", "Die Anmeldung ist abgelaufen. Bitte melde dich erneut an.");
}
const ip = await getClientIp();
if (!rateLimit(`2fa-verify:${ip}:${customerId}`, TWO_FACTOR_VERIFY_ATTEMPTS_PER_WINDOW, TWO_FACTOR_VERIFY_WINDOW_MS)) {
fail("/konto/zwei-faktor-code", "Zu viele Versuche. Bitte versuche es später erneut.");
return fail("/konto/zwei-faktor-code", "Zu viele Versuche. Bitte versuche es später erneut.");
}
// CAPTCHA vor jeder weiteren Arbeit: haelt automatisierte Versuche ab,
// bevor sie Datenbank, Mailversand oder das Ratenlimit belasten.
if (!(await isTurnstileValid(formData, ip))) {
fail("/konto/zwei-faktor-code", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
return fail("/konto/zwei-faktor-code", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
}
const code = String(formData.get("code") ?? "").trim();
const customer = await prisma.customer.findUnique({ where: { id: customerId } });
if (!customer || !customer.twoFactorEnabled || !customer.twoFactorSecret) {
fail("/konto", "Die Anmeldung ist abgelaufen. Bitte melde dich erneut an.");
return fail("/konto", "Die Anmeldung ist abgelaufen. Bitte melde dich erneut an.");
}
const totpStep =
@@ -152,7 +153,7 @@ export async function verifyTwoFactorAction(formData: FormData): Promise<void> {
}
if (!authenticated) {
fail("/konto/zwei-faktor-code", "Der Code ist ungültig oder abgelaufen.");
return fail("/konto/zwei-faktor-code", "Der Code ist ungültig oder abgelaufen.");
}
await clearTwoFactorChallengeCookie();
@@ -162,5 +163,5 @@ export async function verifyTwoFactorAction(formData: FormData): Promise<void> {
export async function logoutAction(): Promise<void> {
await destroySession();
redirect("/konto");
return succeed("/konto", "Du wurdest abgemeldet.");
}
+3 -1
View File
@@ -4,10 +4,11 @@ import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { getCurrentCustomer } from "@/lib/auth";
import { fail } from "./_shared";
import { setFlash } from "@/lib/flash";
export async function toggleNewsletterAction(): Promise<void> {
const customer = await getCurrentCustomer();
if (!customer) fail("/konto", "Bitte melde dich an.");
if (!customer) return fail("/konto", "Bitte melde dich an.");
const existing = await prisma.newsletterSubscriber.findUnique({ where: { customerId: customer.id } });
@@ -28,4 +29,5 @@ export async function toggleNewsletterAction(): Promise<void> {
}
revalidatePath("/konto");
await setFlash("success", "Deine Erntepost-Einstellung wurde gespeichert.");
}
+13 -13
View File
@@ -14,6 +14,7 @@ import { securityAlertEmail } from "@/lib/email-templates";
import { getLocale } from "@/i18n/locale";
import { fail } from "./_shared";
import { isTurnstileValid } from "@/lib/turnstile";
import { succeed } from "@/lib/flash";
const RESET_REQUEST_ATTEMPTS_PER_WINDOW = 5;
const RESET_REQUEST_WINDOW_MS = 60 * 60 * 1000;
@@ -26,18 +27,18 @@ const MIN_RESET_REQUEST_RESPONSE_MS = 400;
export async function requestPasswordResetAction(formData: FormData): Promise<void> {
const ip = await getClientIp();
if (!rateLimit(`reset-request:${ip}`, RESET_REQUEST_ATTEMPTS_PER_WINDOW, RESET_REQUEST_WINDOW_MS)) {
fail("/konto/passwort-vergessen", "Zu viele Anfragen. Bitte versuche es später erneut.");
return fail("/konto/passwort-vergessen", "Zu viele Anfragen. Bitte versuche es später erneut.");
}
// CAPTCHA vor jeder weiteren Arbeit: haelt automatisierte Versuche ab,
// bevor sie Datenbank, Mailversand oder das Ratenlimit belasten.
if (!(await isTurnstileValid(formData, ip))) {
fail("/konto/passwort-vergessen", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
return fail("/konto/passwort-vergessen", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
}
const parsed = requestPasswordResetSchema.safeParse({ email: formData.get("email") });
if (!parsed.success) {
fail("/konto/passwort-vergessen", "Bitte gib eine gültige E-Mail-Adresse ein.");
return fail("/konto/passwort-vergessen", "Bitte gib eine gültige E-Mail-Adresse ein.");
}
const customer = await prisma.customer.findUnique({ where: { email: parsed.data.email } });
@@ -57,13 +58,13 @@ export async function requestPasswordResetAction(formData: FormData): Promise<vo
export async function resetPasswordAction(formData: FormData): Promise<void> {
const ip = await getClientIp();
if (!rateLimit(`reset-submit:${ip}`, RESET_SUBMIT_ATTEMPTS_PER_WINDOW, RESET_SUBMIT_WINDOW_MS)) {
fail("/konto/passwort-vergessen", "Zu viele Anfragen. Bitte versuche es später erneut.");
return fail("/konto/passwort-vergessen", "Zu viele Anfragen. Bitte versuche es später erneut.");
}
// CAPTCHA vor jeder weiteren Arbeit: haelt automatisierte Versuche ab,
// bevor sie Datenbank, Mailversand oder das Ratenlimit belasten.
if (!(await isTurnstileValid(formData, ip))) {
fail("/konto/passwort-vergessen", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
return fail("/konto/passwort-vergessen", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
}
const parsed = resetPasswordSchema.safeParse({
@@ -71,7 +72,7 @@ export async function resetPasswordAction(formData: FormData): Promise<void> {
password: formData.get("password"),
});
if (!parsed.success) {
fail("/konto/passwort-vergessen", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
return fail("/konto/passwort-vergessen", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
}
const resetToken = await prisma.passwordResetToken.findUnique({
@@ -80,7 +81,7 @@ export async function resetPasswordAction(formData: FormData): Promise<void> {
const isValid = resetToken && resetToken.usedAt === null && resetToken.expiresAt > new Date();
if (!isValid) {
fail(
return fail(
"/konto/passwort-vergessen",
"Dieser Link ist ungültig oder abgelaufen. Bitte fordere einen neuen an."
);
@@ -92,7 +93,7 @@ export async function resetPasswordAction(formData: FormData): Promise<void> {
// handlungsfaehig machen — sonst waere die Sperre per Reset-Link
// eigenstaendig umgehbar.
if (targetCustomer?.blockedAt) {
fail(
return fail(
"/konto/passwort-vergessen",
"Dieser Link ist ungültig oder abgelaufen. Bitte fordere einen neuen an."
);
@@ -103,10 +104,9 @@ export async function resetPasswordAction(formData: FormData): Promise<void> {
// Weiterleitung mitgegeben, damit die Kundschaft direkt ein anderes
// Passwort versuchen kann, statt einen komplett neuen Reset-Link
// anfordern zu muessen.
redirect(
`/konto/passwort-zuruecksetzen?token=${encodeURIComponent(parsed.data.token)}&error=${encodeURIComponent(
"Dieses Passwort wurde in bekannten Datenlecks gefunden. Bitte wähle ein anderes Passwort."
)}`
return fail(
`/konto/passwort-zuruecksetzen?token=${encodeURIComponent(parsed.data.token)}`,
"Dieses Passwort wurde in bekannten Datenlecks gefunden. Bitte wähle ein anderes Passwort."
);
}
@@ -147,5 +147,5 @@ export async function resetPasswordAction(formData: FormData): Promise<void> {
}
await createSession(updatedCustomer.id, updatedCustomer.role, updatedCustomer.sessionVersion);
redirect("/konto?message=" + encodeURIComponent("Dein Passwort wurde geändert."));
return succeed("/konto", "Dein Passwort wurde geändert.");
}
+28 -12
View File
@@ -9,6 +9,7 @@ import { createAndSendVerificationEmail } from "@/lib/email-verification";
import { verifyTurnstileToken } from "@/lib/turnstile";
import { isPasswordBreached } from "@/lib/password-breach-check";
import { fail } from "./_shared";
import { succeed } from "@/lib/flash";
const REGISTER_ATTEMPTS_PER_WINDOW = 5;
const REGISTER_WINDOW_MS = 60 * 60 * 1000;
@@ -18,12 +19,12 @@ const VERIFY_RESEND_WINDOW_MS = 60 * 60 * 1000;
export async function registerAction(formData: FormData): Promise<void> {
const ip = await getClientIp();
if (!rateLimit(`register:${ip}`, REGISTER_ATTEMPTS_PER_WINDOW, REGISTER_WINDOW_MS)) {
fail("/konto", "Zu viele Registrierungsversuche. Bitte versuche es später erneut.");
return fail("/konto", "Zu viele Registrierungsversuche. Bitte versuche es später erneut.");
}
const turnstileToken = String(formData.get("turnstileToken") ?? "");
if (!(await verifyTurnstileToken(turnstileToken, ip))) {
fail("/konto", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
return fail("/konto", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
}
const parsed = registerSchema.safeParse({
@@ -33,15 +34,15 @@ export async function registerAction(formData: FormData): Promise<void> {
password: formData.get("password"),
});
if (!parsed.success) {
fail("/konto", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
return fail("/konto", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
}
if (formData.get("password") !== formData.get("passwordConfirm")) {
fail("/konto", "Die Passwörter stimmen nicht überein.");
return fail("/konto", "Die Passwörter stimmen nicht überein.");
}
if (await isPasswordBreached(parsed.data.password)) {
fail(
return fail(
"/konto",
"Dieses Passwort wurde in bekannten Datenlecks gefunden. Bitte wähle ein anderes Passwort."
);
@@ -54,7 +55,7 @@ export async function registerAction(formData: FormData): Promise<void> {
// beides wuerde sonst verraten, welche E-Mail-Adressen registriert sind
// (Timing- bzw. Text-Oracle), analog zum Login-Dummy-Hash in lib/auth.ts.
await hashPassword(parsed.data.password);
fail(
return fail(
"/konto",
"Falls für diese E-Mail-Adresse noch kein Konto besteht, erhältst du in Kürze eine Bestätigungsmail."
);
@@ -72,22 +73,37 @@ export async function registerAction(formData: FormData): Promise<void> {
},
});
await createAndSendVerificationEmail(customer);
const mailSent = await createAndSendVerificationEmail(customer);
await createSession(customer.id, customer.role, customer.sessionVersion);
redirect("/konto");
if (!mailSent) {
return fail(
"/konto",
"Konto erstellt — aber die Bestätigungs-E-Mail konnte nicht versendet werden. Bitte nutze gleich „Link erneut senden“."
);
}
return succeed(
"/konto",
`Willkommen! Bitte bestätige deine E-Mail-Adresse über den Link, den wir an ${customer.email} geschickt haben.`
);
}
/** Rate-limitiert erneutes Zusenden des Bestaetigungslinks, falls die erste Mail verloren ging. */
export async function resendVerificationEmailAction(): Promise<void> {
const customer = await getCurrentCustomer();
if (!customer) fail("/konto", "Bitte melde dich an.");
if (!customer) return fail("/konto", "Bitte melde dich an.");
if (customer.emailVerifiedAt) redirect("/konto");
const ip = await getClientIp();
if (!rateLimit(`verify-resend:${ip}:${customer.id}`, VERIFY_RESEND_ATTEMPTS_PER_WINDOW, VERIFY_RESEND_WINDOW_MS)) {
fail("/konto", "Zu viele Anfragen. Bitte versuche es später erneut.");
return fail("/konto", "Zu viele Anfragen. Bitte versuche es später erneut.");
}
await createAndSendVerificationEmail(customer);
redirect("/konto?message=" + encodeURIComponent("Bestätigungslink wurde erneut gesendet."));
const mailSent = await createAndSendVerificationEmail(customer);
if (!mailSent) {
return fail(
"/konto",
"Die E-Mail konnte gerade nicht versendet werden. Bitte versuche es in ein paar Minuten erneut oder schreib uns an [email protected]."
);
}
return succeed("/konto", `Wir haben dir einen neuen Bestätigungslink an ${customer.email} geschickt.`);
}
+13 -10
View File
@@ -19,13 +19,14 @@ import { sendMail } from "@/lib/mail";
import { securityAlertEmail } from "@/lib/email-templates";
import { getLocale } from "@/i18n/locale";
import { fail, ACCOUNT_PASSWORD_CONFIRM_ATTEMPTS_PER_WINDOW, ACCOUNT_PASSWORD_CONFIRM_WINDOW_MS } from "./_shared";
import { succeed } from "@/lib/flash";
const TWO_FACTOR_SETUP_CONFIRM_ATTEMPTS_PER_WINDOW = 8;
const TWO_FACTOR_SETUP_CONFIRM_WINDOW_MS = 10 * 60 * 1000;
export async function startTwoFactorSetupAction(): Promise<void> {
const customer = await getCurrentCustomer();
if (!customer) fail("/konto", "Bitte melde dich an.");
if (!customer) return fail("/konto", "Bitte melde dich an.");
if (customer.twoFactorEnabled) redirect("/konto/zwei-faktor");
const secret = generateTotpSecret();
@@ -36,11 +37,11 @@ export async function startTwoFactorSetupAction(): Promise<void> {
export async function confirmTwoFactorSetupAction(formData: FormData): Promise<void> {
const customer = await getCurrentCustomer();
if (!customer) fail("/konto", "Bitte melde dich an.");
if (!customer) return fail("/konto", "Bitte melde dich an.");
const setup = await getTwoFactorSetup();
if (!setup || setup.customerId !== customer.id) {
fail("/konto/zwei-faktor", "Die Einrichtung ist abgelaufen. Bitte starte erneut.");
return fail("/konto/zwei-faktor", "Die Einrichtung ist abgelaufen. Bitte starte erneut.");
}
const ip = await getClientIp();
@@ -51,12 +52,12 @@ export async function confirmTwoFactorSetupAction(formData: FormData): Promise<v
TWO_FACTOR_SETUP_CONFIRM_WINDOW_MS
)
) {
fail("/konto/zwei-faktor/einrichten", "Zu viele Versuche. Bitte versuche es später erneut.");
return fail("/konto/zwei-faktor/einrichten", "Zu viele Versuche. Bitte versuche es später erneut.");
}
const code = String(formData.get("code") ?? "").trim();
if (!verifyTotpCode(setup.secret, code, customer.email)) {
fail("/konto/zwei-faktor/einrichten", "Der Code ist ungültig. Bitte versuche es erneut.");
return fail("/konto/zwei-faktor/einrichten", "Der Code ist ungültig. Bitte versuche es erneut.");
}
const { plain, hashes } = generateBackupCodes();
@@ -83,12 +84,14 @@ export async function confirmTwoFactorSetupAction(formData: FormData): Promise<v
export async function dismissBackupCodesAction(): Promise<void> {
await clearTwoFactorBackupCodesDisplayCookie();
redirect("/konto/zwei-faktor");
// Zurueck ins Profil statt auf die 2FA-Seite: dort steht als einzige
// Aktion "deaktivieren" — direkt nach dem Einrichten die falsche Einladung.
return succeed("/konto", "Zwei-Faktor-Authentifizierung ist jetzt aktiv. Dein Konto ist zusätzlich geschützt.");
}
export async function disableTwoFactorAction(formData: FormData): Promise<void> {
const customer = await getCurrentCustomer();
if (!customer) fail("/konto", "Bitte melde dich an.");
if (!customer) return fail("/konto", "Bitte melde dich an.");
const ip = await getClientIp();
if (
@@ -98,13 +101,13 @@ export async function disableTwoFactorAction(formData: FormData): Promise<void>
ACCOUNT_PASSWORD_CONFIRM_WINDOW_MS
)
) {
fail("/konto/zwei-faktor", "Zu viele Versuche. Bitte versuche es später erneut.");
return fail("/konto/zwei-faktor", "Zu viele Versuche. Bitte versuche es später erneut.");
}
const password = String(formData.get("password") ?? "");
const passwordValid = await verifyPassword(password, customer.passwordHash);
if (!passwordValid) {
fail("/konto/zwei-faktor", "Passwort ist falsch.");
return fail("/konto/zwei-faktor", "Passwort ist falsch.");
}
await prisma.$transaction([
@@ -121,5 +124,5 @@ export async function disableTwoFactorAction(formData: FormData): Promise<void>
const { subject, html, text } = await securityAlertEmail(await getLocale(), "twoFactorDisabled");
await sendMail({ to: updated.email, subject, html, text });
redirect("/konto/zwei-faktor?message=" + encodeURIComponent("Zwei-Faktor-Authentifizierung wurde deaktiviert."));
return succeed("/konto", "Zwei-Faktor-Authentifizierung wurde deaktiviert.");
}
+1 -5
View File
@@ -13,9 +13,8 @@ import { getDictionary } from "@/i18n/get-dictionary";
export const metadata: Metadata = { title: "Meine Adressen" };
export const dynamic = "force-dynamic";
export default async function KontoAdressenPage(props: PageProps<"/konto/adressen">) {
export default async function KontoAdressenPage() {
const t = getDictionary(await getLocale());
const { error } = await props.searchParams;
const customer = await getCurrentCustomer();
if (!customer) redirect("/konto");
@@ -27,9 +26,6 @@ export default async function KontoAdressenPage(props: PageProps<"/konto/adresse
<BackLink href="/konto" label={t.ordersPage.backToAccount} />
<SectionHeading eyebrow={t.account.eyebrow} title={t.addressesPage.title} />
{error ? (
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
<div className="mt-10 space-y-4">
{addresses.map((address) => (
@@ -21,7 +21,6 @@ const RETURN_ELIGIBLE_STATUSES = [
export default async function RetourePage(props: PageProps<"/konto/bestellungen/[id]/retoure">) {
const t = getDictionary(await getLocale());
const { id } = await props.params;
const { error } = await props.searchParams;
const customer = await getCurrentCustomer();
if (!customer) redirect("/konto");
@@ -55,9 +54,6 @@ export default async function RetourePage(props: PageProps<"/konto/bestellungen/
))}
</ul>
{error ? (
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
<form action={requestReturnAction} className="mt-6 space-y-4">
<input type="hidden" name="orderId" value={order.id} />
+7 -12
View File
@@ -1,6 +1,6 @@
"use server";
import { redirect } from "next/navigation";
import { fail, succeed } from "@/lib/flash";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { getCurrentCustomer } from "@/lib/auth";
@@ -17,13 +17,10 @@ const RETURN_ELIGIBLE_STATUSES = [
"ABGEHOLT",
];
function fail(path: string, message: string): never {
redirect(`${path}?error=${encodeURIComponent(message)}`);
}
export async function requestReturnAction(formData: FormData): Promise<void> {
const customer = await getCurrentCustomer();
if (!customer) fail("/konto", "Bitte melde dich an, um eine Rücksendung anzufragen.");
if (!customer) return fail("/konto", "Bitte melde dich an, um eine Rücksendung anzufragen.");
const parsed = returnRequestSchema.safeParse({
orderId: formData.get("orderId"),
@@ -31,7 +28,7 @@ export async function requestReturnAction(formData: FormData): Promise<void> {
});
if (!parsed.success) {
const orderId = String(formData.get("orderId") ?? "");
fail(`/konto/bestellungen/${orderId}/retoure`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
return fail(`/konto/bestellungen/${orderId}/retoure`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
}
const order = await prisma.order.findUnique({
@@ -40,13 +37,13 @@ export async function requestReturnAction(formData: FormData): Promise<void> {
});
if (!order || order.customerId !== customer.id) {
fail("/konto/bestellungen", "Bestellung nicht gefunden.");
return fail("/konto/bestellungen", "Bestellung nicht gefunden.");
}
if (!RETURN_ELIGIBLE_STATUSES.includes(order.status)) {
fail("/konto/bestellungen", "Für diese Bestellung kann keine Rücksendung angefragt werden.");
return fail("/konto/bestellungen", "Für diese Bestellung kann keine Rücksendung angefragt werden.");
}
if (order.returnRequest) {
fail("/konto/bestellungen", "Für diese Bestellung liegt bereits eine Rücksendeanfrage vor.");
return fail("/konto/bestellungen", "Für diese Bestellung liegt bereits eine Rücksendeanfrage vor.");
}
await prisma.returnRequest.create({
@@ -54,7 +51,5 @@ export async function requestReturnAction(formData: FormData): Promise<void> {
});
revalidatePath("/konto/bestellungen");
redirect(
`/konto/bestellungen?message=${encodeURIComponent("Deine Rücksendeanfrage wurde übermittelt. Wir melden uns bei dir.")}`
);
return succeed("/konto/bestellungen", "Deine Rücksendeanfrage wurde übermittelt. Wir melden uns bei dir.");
}
+1 -8
View File
@@ -25,12 +25,11 @@ const RETURN_ELIGIBLE_STATUSES = [
"ABGEHOLT",
];
export default async function KontoBestellungenPage(props: PageProps<"/konto/bestellungen">) {
export default async function KontoBestellungenPage() {
const locale = await getLocale();
const t = getDictionary(locale);
const statusLabels = t.orderStatusBadge;
const returnStatusLabels = t.ordersPage.returnStatusLabels;
const { error, message } = await props.searchParams;
const customer = await getCurrentCustomer();
if (!customer) redirect("/konto");
@@ -42,12 +41,6 @@ export default async function KontoBestellungenPage(props: PageProps<"/konto/bes
<BackLink href="/konto" label={t.ordersPage.backToAccount} />
<SectionHeading eyebrow={t.account.eyebrow} title={t.ordersPage.title} />
{error ? (
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
{message ? (
<p className="mt-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">{message}</p>
) : null}
{orders.length === 0 ? (
<AccountEmptyState
+1 -22
View File
@@ -22,9 +22,8 @@ import {
export const metadata: Metadata = { title: "Mein Konto" };
export const dynamic = "force-dynamic";
export default async function KontoPage(props: PageProps<"/konto">) {
export default async function KontoPage() {
const t = getDictionary(await getLocale());
const { error, message } = await props.searchParams;
const customer = await getCurrentCustomer();
if (!customer) {
@@ -32,16 +31,6 @@ export default async function KontoPage(props: PageProps<"/konto">) {
<div className="py-20">
<Container className="max-w-3xl">
<SectionHeading eyebrow={t.account.eyebrow} title={t.account.loginRegisterTitle} />
{error ? (
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">
{error}
</p>
) : null}
{message ? (
<p className="mt-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">
{message}
</p>
) : null}
<div className="mt-10 grid md:grid-cols-2 md:divide-x md:divide-ink/10 gap-12 md:gap-0">
<form action={loginAction} className="space-y-4 md:pr-12">
<h2 className="text-lg text-forest">{t.account.login}</h2>
@@ -134,16 +123,6 @@ export default async function KontoPage(props: PageProps<"/konto">) {
<Container className="max-w-2xl">
<SectionHeading eyebrow={t.account.eyebrow} title={t.account.greeting(customer.firstName)} />
{error ? (
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">
{error}
</p>
) : null}
{message ? (
<p className="mt-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">
{message}
</p>
) : null}
{!customer.emailVerifiedAt ? (
<div className="mt-6 flex flex-wrap items-center justify-between gap-4 text-sm bg-honey/10 border border-honey/30 px-4 py-3">
+1 -4
View File
@@ -10,7 +10,7 @@ export const metadata: Metadata = { title: "Passwort vergessen" };
export default async function PasswortVergessenPage(props: PageProps<"/konto/passwort-vergessen">) {
const t = getDictionary(await getLocale());
const { error, sent } = await props.searchParams;
const { sent } = await props.searchParams;
return (
<div className="py-20">
@@ -18,9 +18,6 @@ export default async function PasswortVergessenPage(props: PageProps<"/konto/pas
<BackLink href="/konto" label={t.passwortVergessenPage.backLabel} />
<SectionHeading eyebrow={t.account.eyebrow} title={t.passwortVergessenPage.title} />
{error ? (
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
{sent ? (
<p className="mt-6 text-sm text-ink/70 leading-relaxed">
@@ -13,7 +13,7 @@ export default async function PasswortZuruecksetzenPage(
props: PageProps<"/konto/passwort-zuruecksetzen">
) {
const t = getDictionary(await getLocale());
const { token, error } = await props.searchParams;
const { token } = await props.searchParams;
return (
<div className="py-20">
@@ -21,9 +21,6 @@ export default async function PasswortZuruecksetzenPage(
<BackLink href="/konto" label={t.passwortZuruecksetzenPage.backLabel} />
<SectionHeading eyebrow={t.account.eyebrow} title={t.passwortZuruecksetzenPage.title} />
{error ? (
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
{!token ? (
<p className="mt-6 text-sm text-ink/70">
+1 -5
View File
@@ -11,9 +11,8 @@ import { getDictionary } from "@/i18n/get-dictionary";
export const metadata: Metadata = { title: "Anmeldung bestätigen" };
export const dynamic = "force-dynamic";
export default async function ZweiFaktorCodePage(props: PageProps<"/konto/zwei-faktor-code">) {
export default async function ZweiFaktorCodePage() {
const t = getDictionary(await getLocale());
const { error } = await props.searchParams;
const customerId = await getTwoFactorChallengeCustomerId();
if (!customerId) redirect("/konto");
@@ -28,9 +27,6 @@ export default async function ZweiFaktorCodePage(props: PageProps<"/konto/zwei-f
subtitle={t.twoFactorCodePage.subtitle}
/>
{error ? (
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
<form action={verifyTwoFactorAction} className="mt-6 space-y-4">
<label className="block text-sm">
@@ -11,11 +11,8 @@ import { getDictionary } from "@/i18n/get-dictionary";
export const metadata: Metadata = { title: "Zwei-Faktor-Authentifizierung einrichten" };
export const dynamic = "force-dynamic";
export default async function ZweiFaktorEinrichtenPage(
props: PageProps<"/konto/zwei-faktor/einrichten">
) {
export default async function ZweiFaktorEinrichtenPage() {
const t = getDictionary(await getLocale());
const { error } = await props.searchParams;
const customer = await getCurrentCustomer();
if (!customer) redirect("/konto");
@@ -35,9 +32,6 @@ export default async function ZweiFaktorEinrichtenPage(
subtitle={t.twoFactorSetupPage.subtitle}
/>
{error ? (
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
<div className="mt-8 border border-ink/10 p-6 flex flex-col items-center">
{/* eslint-disable-next-line @next/next/no-img-element -- Data-URI, kein Next-Image-Optimierungspfad noetig */}
+1 -8
View File
@@ -13,9 +13,8 @@ import { getDictionary } from "@/i18n/get-dictionary";
export const metadata: Metadata = { title: "Zwei-Faktor-Authentifizierung" };
export const dynamic = "force-dynamic";
export default async function ZweiFaktorPage(props: PageProps<"/konto/zwei-faktor">) {
export default async function ZweiFaktorPage() {
const t = getDictionary(await getLocale());
const { error, message } = await props.searchParams;
const customer = await getCurrentCustomer();
if (!customer) redirect("/konto");
@@ -34,12 +33,6 @@ export default async function ZweiFaktorPage(props: PageProps<"/konto/zwei-fakto
subtitle={t.twoFactorPage.subtitle}
/>
{error ? (
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
{message ? (
<p className="mt-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">{message}</p>
) : null}
{customer.twoFactorEnabled ? (
<div className="mt-8 space-y-6">