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:
1 parent
fb9c2353c6
commit
839bbff595
71 files changed
+762
-416
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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.`);
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user