feat: Wabenhain Honig-Shop Anwendung
Vollstaendige Next.js/Prisma-E-Commerce-Anwendung fuer eine Imkerei: Shop, Warenkorb, Stripe-Checkout, Kundenkonto mit 2FA, Admin-Bereich (Produkte, Ernten, Chargen, Bestellungen, Gutscheine, admin-editierbare Website-Inhalte), i18n (DE/EN), Rate-Limiting/CSP/Audit-Log-Haertung, PostgreSQL- und MySQL-Unterstuetzung ueber austauschbare Prisma- Treiber-Adapter, Unit-/Komponenten-/E2E-Tests.
This commit is contained in:
1 parent
5c76fcc266
commit
c7927f2883
240 files changed
+26096
-137
No files matched your search
@@ -0,0 +1,11 @@
|
||||
// Barrel-Datei: die eigentlichen Server Actions leben domainweise unter
|
||||
// ./actions/* (waren zuvor alle in dieser einen 600+-Zeilen-Datei). Bestehende
|
||||
// Importe ("@/app/konto/actions" bzw. "../actions" aus den Konto-Seiten)
|
||||
// bleiben dadurch unveraendert funktionsfaehig.
|
||||
export * from "./actions/registration";
|
||||
export * from "./actions/login";
|
||||
export * from "./actions/two-factor";
|
||||
export * from "./actions/account";
|
||||
export * from "./actions/addresses";
|
||||
export * from "./actions/newsletter";
|
||||
export * from "./actions/password-reset";
|
||||
@@ -0,0 +1,12 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export function fail(path: string, message: string): never {
|
||||
redirect(`${path}?error=${encodeURIComponent(message)}`);
|
||||
}
|
||||
|
||||
// Passwortbestaetigung fuer sicherheitskritische Kontoaktionen (2FA
|
||||
// deaktivieren, Konto loeschen) — ohne dieses Limit koennte eine gestohlene
|
||||
// Session das Passwort per Brute-Force erraten und damit z.B. den
|
||||
// 2FA-Schutz aushebeln, den verifyTwoFactorAction gerade durchsetzt.
|
||||
export const ACCOUNT_PASSWORD_CONFIRM_ATTEMPTS_PER_WINDOW = 8;
|
||||
export const ACCOUNT_PASSWORD_CONFIRM_WINDOW_MS = 15 * 60 * 1000;
|
||||
@@ -0,0 +1,65 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { createSession, getCurrentCustomer, verifyPassword, destroySession } from "@/lib/auth";
|
||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||
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";
|
||||
|
||||
/**
|
||||
* Selbstbedienungs-Kontoloeschung (Art. 17 DSGVO). Bestellungen bleiben aus
|
||||
* Aufbewahrungspflichten erhalten (Order.customer hat onDelete: SetNull) —
|
||||
* sie speichern Name/Adresse bereits als eigenstaendigen Snapshot, sodass die
|
||||
* Rechnung auch ohne verknuepftes Kundenkonto vollstaendig bleibt. Alle
|
||||
* anderen persoenlichen Daten (Adressen, Wunschliste, Bewertungen,
|
||||
* Newsletter, Backup-Codes) werden per Cascade mitgeloescht.
|
||||
*/
|
||||
export async function deleteOwnAccountAction(formData: FormData): Promise<void> {
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) 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.");
|
||||
}
|
||||
|
||||
const ip = await getClientIp();
|
||||
if (
|
||||
!rateLimit(
|
||||
`delete-account:${ip}:${customer.id}`,
|
||||
ACCOUNT_PASSWORD_CONFIRM_ATTEMPTS_PER_WINDOW,
|
||||
ACCOUNT_PASSWORD_CONFIRM_WINDOW_MS
|
||||
)
|
||||
) {
|
||||
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.");
|
||||
}
|
||||
|
||||
const { subject, html, text } = await securityAlertEmail(await getLocale(), "accountDeleted");
|
||||
await sendMail({ to: customer.email, subject, html, text });
|
||||
|
||||
await prisma.customer.delete({ where: { id: customer.id } });
|
||||
await destroySession();
|
||||
redirect("/konto?message=" + encodeURIComponent("Dein Konto wurde endgültig gelöscht."));
|
||||
}
|
||||
|
||||
/** Widerruft alle bestehenden Sessions (auch andere Geraete) und meldet nur das aktuelle Tab neu an. */
|
||||
export async function logoutAllDevicesAction(): Promise<void> {
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) fail("/konto", "Bitte melde dich an.");
|
||||
|
||||
const updated = await prisma.customer.update({
|
||||
where: { id: customer.id },
|
||||
data: { sessionVersion: { increment: 1 } },
|
||||
});
|
||||
|
||||
await createSession(updated.id, updated.role, updated.sessionVersion);
|
||||
redirect("/konto?message=" + encodeURIComponent("Alle anderen Geräte wurden abgemeldet."));
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getCurrentCustomer } from "@/lib/auth";
|
||||
import { addressSchema } from "@/lib/validation";
|
||||
import { fail } from "./_shared";
|
||||
|
||||
export async function addAddressAction(formData: FormData): Promise<void> {
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) fail("/konto", "Bitte melde dich an.");
|
||||
|
||||
const parsed = addressSchema.safeParse({
|
||||
label: formData.get("label"),
|
||||
firstName: formData.get("firstName"),
|
||||
lastName: formData.get("lastName"),
|
||||
street: formData.get("street"),
|
||||
zip: formData.get("zip"),
|
||||
city: formData.get("city"),
|
||||
country: "DE",
|
||||
isDefault: formData.get("isDefault") === "on",
|
||||
});
|
||||
if (!parsed.success) {
|
||||
fail("/konto/adressen", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
}
|
||||
|
||||
if (parsed.data.isDefault) {
|
||||
await prisma.address.updateMany({ where: { customerId: customer.id }, data: { isDefault: false } });
|
||||
}
|
||||
|
||||
await prisma.address.create({ data: { ...parsed.data, customerId: customer.id } });
|
||||
revalidatePath("/konto/adressen");
|
||||
}
|
||||
|
||||
export async function deleteAddressAction(formData: FormData): Promise<void> {
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) 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");
|
||||
}
|
||||
|
||||
export async function setDefaultAddressAction(formData: FormData): Promise<void> {
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) 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.");
|
||||
|
||||
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");
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { createSession, destroySession, verifyPassword, getTwoFactorChallengeCustomerId, setTwoFactorChallengeCookie, clearTwoFactorChallengeCookie } from "@/lib/auth";
|
||||
import { signTwoFactorChallenge } from "@/lib/session";
|
||||
import { verifyTotpCodeAndGetStep, hashBackupCode } from "@/lib/two-factor";
|
||||
import { loginSchema } from "@/lib/validation";
|
||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||
import type { Customer } from "@/generated/prisma/client";
|
||||
import { fail } from "./_shared";
|
||||
|
||||
const LOGIN_ATTEMPTS_PER_WINDOW = 10;
|
||||
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
|
||||
const LOGIN_ATTEMPTS_PER_EMAIL_WINDOW = 8;
|
||||
|
||||
// Konto-Sperre nach wiederholten Fehlversuchen — zusaetzlich zum IP-basierten
|
||||
// Rate-Limit, da dieses In-Memory ist und einen Neustart nicht ueberlebt.
|
||||
const LOCKOUT_THRESHOLD = 5;
|
||||
const LOCKOUT_DURATION_MS = 15 * 60 * 1000;
|
||||
|
||||
const TWO_FACTOR_VERIFY_ATTEMPTS_PER_WINDOW = 8;
|
||||
const TWO_FACTOR_VERIFY_WINDOW_MS = 10 * 60 * 1000;
|
||||
|
||||
async function registerFailedLogin(customer: Pick<Customer, "id" | "failedLoginAttempts">): Promise<void> {
|
||||
const attempts = customer.failedLoginAttempts + 1;
|
||||
await prisma.customer.update({
|
||||
where: { id: customer.id },
|
||||
data: {
|
||||
failedLoginAttempts: attempts,
|
||||
lockedUntil: attempts >= LOCKOUT_THRESHOLD ? new Date(Date.now() + LOCKOUT_DURATION_MS) : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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.");
|
||||
}
|
||||
|
||||
const parsed = loginSchema.safeParse({
|
||||
email: formData.get("email"),
|
||||
password: formData.get("password"),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
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.");
|
||||
}
|
||||
|
||||
const customer = await prisma.customer.findUnique({ where: { email: parsed.data.email } });
|
||||
|
||||
if (customer?.blockedAt) {
|
||||
fail("/konto", "Dieses Konto wurde gesperrt. Bitte kontaktiere uns.");
|
||||
}
|
||||
|
||||
if (customer?.lockedUntil && customer.lockedUntil > new Date()) {
|
||||
fail(
|
||||
"/konto",
|
||||
"Dieses Konto ist wegen zu vieler Fehlversuche vorübergehend gesperrt. Bitte versuche es später erneut oder setze dein Passwort zurück."
|
||||
);
|
||||
}
|
||||
|
||||
// verifyPassword vergleicht bei unbekannter E-Mail gegen einen Dummy-Hash,
|
||||
// damit die Antwortzeit nicht verraet, ob ein Konto existiert.
|
||||
const passwordValid = await verifyPassword(parsed.data.password, customer?.passwordHash ?? null);
|
||||
|
||||
if (!customer || !passwordValid) {
|
||||
if (customer) await registerFailedLogin(customer);
|
||||
fail("/konto", "E-Mail-Adresse oder Passwort ist falsch.");
|
||||
}
|
||||
|
||||
if (customer.failedLoginAttempts > 0 || customer.lockedUntil) {
|
||||
await prisma.customer.update({
|
||||
where: { id: customer.id },
|
||||
data: { failedLoginAttempts: 0, lockedUntil: null },
|
||||
});
|
||||
}
|
||||
|
||||
if (customer.twoFactorEnabled) {
|
||||
const challengeToken = await signTwoFactorChallenge(customer.id);
|
||||
await setTwoFactorChallengeCookie(challengeToken);
|
||||
redirect("/konto/zwei-faktor-code");
|
||||
}
|
||||
|
||||
await createSession(customer.id, customer.role, customer.sessionVersion);
|
||||
redirect("/konto");
|
||||
}
|
||||
|
||||
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.");
|
||||
}
|
||||
|
||||
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.");
|
||||
}
|
||||
|
||||
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.");
|
||||
}
|
||||
|
||||
const totpStep =
|
||||
code.length > 0 ? verifyTotpCodeAndGetStep(customer.twoFactorSecret, code, customer.email) : null;
|
||||
// Ein Code aus einem bereits verbrauchten (oder frueheren) Zeitfenster gilt
|
||||
// nicht mehr als gueltig — verhindert, dass ein mitgelesener/abgefangener
|
||||
// Code ein zweites Mal funktioniert.
|
||||
let authenticated =
|
||||
totpStep !== null && (customer.lastTotpStep === null || totpStep > customer.lastTotpStep);
|
||||
|
||||
if (authenticated) {
|
||||
await prisma.customer.update({ where: { id: customer.id }, data: { lastTotpStep: totpStep } });
|
||||
}
|
||||
|
||||
if (!authenticated && code.length > 0) {
|
||||
const codeHash = hashBackupCode(code.toUpperCase());
|
||||
const backupCode = await prisma.twoFactorBackupCode.findFirst({
|
||||
where: { customerId: customer.id, codeHash, usedAt: null },
|
||||
});
|
||||
if (backupCode) {
|
||||
await prisma.twoFactorBackupCode.update({ where: { id: backupCode.id }, data: { usedAt: new Date() } });
|
||||
authenticated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!authenticated) {
|
||||
fail("/konto/zwei-faktor-code", "Der Code ist ungültig oder abgelaufen.");
|
||||
}
|
||||
|
||||
await clearTwoFactorChallengeCookie();
|
||||
await createSession(customer.id, customer.role, customer.sessionVersion);
|
||||
redirect("/konto");
|
||||
}
|
||||
|
||||
export async function logoutAction(): Promise<void> {
|
||||
await destroySession();
|
||||
redirect("/konto");
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getCurrentCustomer } from "@/lib/auth";
|
||||
import { fail } from "./_shared";
|
||||
|
||||
export async function toggleNewsletterAction(): Promise<void> {
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) fail("/konto", "Bitte melde dich an.");
|
||||
|
||||
const existing = await prisma.newsletterSubscriber.findUnique({ where: { customerId: customer.id } });
|
||||
|
||||
if (existing?.unsubscribedAt === null) {
|
||||
await prisma.newsletterSubscriber.update({
|
||||
where: { id: existing.id },
|
||||
data: { unsubscribedAt: new Date() },
|
||||
});
|
||||
} else if (existing) {
|
||||
await prisma.newsletterSubscriber.update({
|
||||
where: { id: existing.id },
|
||||
data: { unsubscribedAt: null, confirmed: true },
|
||||
});
|
||||
} else {
|
||||
await prisma.newsletterSubscriber.create({
|
||||
data: { email: customer.email, customerId: customer.id, confirmed: true },
|
||||
});
|
||||
}
|
||||
|
||||
revalidatePath("/konto");
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { createSession, hashPassword } from "@/lib/auth";
|
||||
import { setTwoFactorChallengeCookie } from "@/lib/auth";
|
||||
import { signTwoFactorChallenge } from "@/lib/session";
|
||||
import { requestPasswordResetSchema, resetPasswordSchema } from "@/lib/validation";
|
||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||
import { hashResetToken, createAndSendPasswordResetEmail } from "@/lib/password-reset";
|
||||
import { isPasswordBreached } from "@/lib/password-breach-check";
|
||||
import { sendMail } from "@/lib/mail";
|
||||
import { securityAlertEmail } from "@/lib/email-templates";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { fail } from "./_shared";
|
||||
|
||||
const RESET_REQUEST_ATTEMPTS_PER_WINDOW = 5;
|
||||
const RESET_REQUEST_WINDOW_MS = 60 * 60 * 1000;
|
||||
const RESET_SUBMIT_ATTEMPTS_PER_WINDOW = 10;
|
||||
const RESET_SUBMIT_WINDOW_MS = 15 * 60 * 1000;
|
||||
// Mindestlaufzeit fuer requestPasswordResetAction, unabhaengig davon, ob ein
|
||||
// Konto existiert — siehe Kommentar dort.
|
||||
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.");
|
||||
}
|
||||
|
||||
const parsed = requestPasswordResetSchema.safeParse({ email: formData.get("email") });
|
||||
if (!parsed.success) {
|
||||
fail("/konto/passwort-vergessen", "Bitte gib eine gültige E-Mail-Adresse ein.");
|
||||
}
|
||||
|
||||
const customer = await prisma.customer.findUnique({ where: { email: parsed.data.email } });
|
||||
|
||||
// Immer derselbe Erfolgspfad UND dieselbe Mindestlaufzeit, unabhaengig
|
||||
// davon, ob ein Konto existiert — verraet sonst nicht nur per Seiteninhalt,
|
||||
// sondern auch per Antwortzeit (echter Pfad macht DB-Schreibvorgaenge +
|
||||
// SMTP-Versand, der andere sofort) welche E-Mail-Adressen registriert
|
||||
// sind. Promise.all erzwingt die Mindestlaufzeit auch dann, wenn der echte
|
||||
// Pfad schneller waere als MIN_RESET_REQUEST_RESPONSE_MS.
|
||||
const work = customer ? createAndSendPasswordResetEmail(customer) : Promise.resolve();
|
||||
await Promise.all([work, new Promise((resolve) => setTimeout(resolve, MIN_RESET_REQUEST_RESPONSE_MS))]);
|
||||
|
||||
redirect("/konto/passwort-vergessen?sent=1");
|
||||
}
|
||||
|
||||
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.");
|
||||
}
|
||||
|
||||
const parsed = resetPasswordSchema.safeParse({
|
||||
token: formData.get("token"),
|
||||
password: formData.get("password"),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
fail("/konto/passwort-vergessen", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
}
|
||||
|
||||
const resetToken = await prisma.passwordResetToken.findUnique({
|
||||
where: { tokenHash: hashResetToken(parsed.data.token) },
|
||||
});
|
||||
|
||||
const isValid = resetToken && resetToken.usedAt === null && resetToken.expiresAt > new Date();
|
||||
if (!isValid) {
|
||||
fail(
|
||||
"/konto/passwort-vergessen",
|
||||
"Dieser Link ist ungültig oder abgelaufen. Bitte fordere einen neuen an."
|
||||
);
|
||||
}
|
||||
|
||||
const targetCustomer = await prisma.customer.findUnique({ where: { id: resetToken.customerId } });
|
||||
|
||||
// Ein Passwort-Reset darf eine admin-gesperrte Kundschaft nicht wieder
|
||||
// handlungsfaehig machen — sonst waere die Sperre per Reset-Link
|
||||
// eigenstaendig umgehbar.
|
||||
if (targetCustomer?.blockedAt) {
|
||||
fail(
|
||||
"/konto/passwort-vergessen",
|
||||
"Dieser Link ist ungültig oder abgelaufen. Bitte fordere einen neuen an."
|
||||
);
|
||||
}
|
||||
|
||||
if (await isPasswordBreached(parsed.data.password)) {
|
||||
// Anders als bei fail(): der Token bleibt gueltig und wird in der
|
||||
// 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."
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(parsed.data.password);
|
||||
|
||||
// sessionVersion wird hochgezaehlt, damit ein Passwort-Reset (z.B. weil das
|
||||
// Konto kompromittiert wurde) alle bestehenden Sessions ungueltig macht.
|
||||
// Ein erfolgreich ueber den zugesandten Link gesetztes Passwort beweist
|
||||
// ausserdem die Kontrolle ueber das Postfach — deckt sowohl "Passwort
|
||||
// vergessen" als auch admin-angelegte Konten (erste Anmeldung laeuft ueber
|
||||
// denselben Mechanismus) ab, ohne eine zusaetzliche Bestaetigungs-Mail zu
|
||||
// benoetigen.
|
||||
const [updatedCustomer] = await prisma.$transaction([
|
||||
prisma.customer.update({
|
||||
where: { id: resetToken.customerId },
|
||||
data: {
|
||||
passwordHash,
|
||||
sessionVersion: { increment: 1 },
|
||||
failedLoginAttempts: 0,
|
||||
lockedUntil: null,
|
||||
emailVerifiedAt: targetCustomer?.emailVerifiedAt ?? new Date(),
|
||||
},
|
||||
}),
|
||||
// Alle offenen Reset-Tokens des Kontos entwerten, nicht nur das benutzte.
|
||||
prisma.passwordResetToken.deleteMany({ where: { customerId: resetToken.customerId } }),
|
||||
]);
|
||||
|
||||
const { subject, html, text } = await securityAlertEmail(await getLocale(), "passwordChanged");
|
||||
await sendMail({ to: updatedCustomer.email, subject, html, text });
|
||||
|
||||
// Ein Passwort-Reset darf 2FA nicht umgehen — sonst wuerde der zweite
|
||||
// Faktor genau in dem Szenario ausgehebelt, fuer das er gedacht ist (Zugriff
|
||||
// auf das Postfach, z.B. weil das Passwort kompromittiert war).
|
||||
if (updatedCustomer.twoFactorEnabled) {
|
||||
const challengeToken = await signTwoFactorChallenge(updatedCustomer.id);
|
||||
await setTwoFactorChallengeCookie(challengeToken);
|
||||
redirect("/konto/zwei-faktor-code");
|
||||
}
|
||||
|
||||
await createSession(updatedCustomer.id, updatedCustomer.role, updatedCustomer.sessionVersion);
|
||||
redirect("/konto?message=" + encodeURIComponent("Dein Passwort wurde geändert."));
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { createSession, getCurrentCustomer, hashPassword } from "@/lib/auth";
|
||||
import { registerSchema } from "@/lib/validation";
|
||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||
import { createAndSendVerificationEmail } from "@/lib/email-verification";
|
||||
import { verifyTurnstileToken } from "@/lib/turnstile";
|
||||
import { isPasswordBreached } from "@/lib/password-breach-check";
|
||||
import { fail } from "./_shared";
|
||||
|
||||
const REGISTER_ATTEMPTS_PER_WINDOW = 5;
|
||||
const REGISTER_WINDOW_MS = 60 * 60 * 1000;
|
||||
const VERIFY_RESEND_ATTEMPTS_PER_WINDOW = 5;
|
||||
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.");
|
||||
}
|
||||
|
||||
const turnstileToken = String(formData.get("turnstileToken") ?? "");
|
||||
if (!(await verifyTurnstileToken(turnstileToken, ip))) {
|
||||
fail("/konto", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
|
||||
}
|
||||
|
||||
const parsed = registerSchema.safeParse({
|
||||
firstName: formData.get("firstName"),
|
||||
lastName: formData.get("lastName"),
|
||||
email: formData.get("email"),
|
||||
password: formData.get("password"),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
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.");
|
||||
}
|
||||
|
||||
if (await isPasswordBreached(parsed.data.password)) {
|
||||
fail(
|
||||
"/konto",
|
||||
"Dieses Passwort wurde in bekannten Datenlecks gefunden. Bitte wähle ein anderes Passwort."
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await prisma.customer.findUnique({ where: { email: parsed.data.email } });
|
||||
if (existing) {
|
||||
// Absichtlich keine eindeutige "Konto existiert bereits"-Meldung und
|
||||
// absichtlich derselbe bcrypt-Aufwand wie im Erfolgsfall (Zeile unten) —
|
||||
// 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(
|
||||
"/konto",
|
||||
"Falls für diese E-Mail-Adresse noch kein Konto besteht, erhältst du in Kürze eine Bestätigungsmail."
|
||||
);
|
||||
}
|
||||
|
||||
// Rolle wird bewusst nicht aus formData gelesen — neue Konten sind immer
|
||||
// KUNDE (Prisma-Default). Admin-Rechte werden ausschliesslich ueber die
|
||||
// Datenbank vergeben, nie ueber ein Client-Feld.
|
||||
const customer = await prisma.customer.create({
|
||||
data: {
|
||||
firstName: parsed.data.firstName,
|
||||
lastName: parsed.data.lastName,
|
||||
email: parsed.data.email,
|
||||
passwordHash: await hashPassword(parsed.data.password),
|
||||
},
|
||||
});
|
||||
|
||||
await createAndSendVerificationEmail(customer);
|
||||
await createSession(customer.id, customer.role, customer.sessionVersion);
|
||||
redirect("/konto");
|
||||
}
|
||||
|
||||
/** 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.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.");
|
||||
}
|
||||
|
||||
await createAndSendVerificationEmail(customer);
|
||||
redirect("/konto?message=" + encodeURIComponent("Bestätigungslink wurde erneut gesendet."));
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
createSession,
|
||||
getCurrentCustomer,
|
||||
verifyPassword,
|
||||
setTwoFactorSetupCookie,
|
||||
getTwoFactorSetup,
|
||||
clearTwoFactorSetupCookie,
|
||||
setTwoFactorBackupCodesDisplayCookie,
|
||||
clearTwoFactorBackupCodesDisplayCookie,
|
||||
} from "@/lib/auth";
|
||||
import { signTwoFactorSetup, signTwoFactorBackupCodesDisplay } from "@/lib/session";
|
||||
import { generateTotpSecret, verifyTotpCode, generateBackupCodes } from "@/lib/two-factor";
|
||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||
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";
|
||||
|
||||
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.twoFactorEnabled) redirect("/konto/zwei-faktor");
|
||||
|
||||
const secret = generateTotpSecret();
|
||||
const token = await signTwoFactorSetup(customer.id, secret);
|
||||
await setTwoFactorSetupCookie(token);
|
||||
redirect("/konto/zwei-faktor/einrichten");
|
||||
}
|
||||
|
||||
export async function confirmTwoFactorSetupAction(formData: FormData): Promise<void> {
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) 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.");
|
||||
}
|
||||
|
||||
const ip = await getClientIp();
|
||||
if (
|
||||
!rateLimit(
|
||||
`2fa-setup-confirm:${ip}:${customer.id}`,
|
||||
TWO_FACTOR_SETUP_CONFIRM_ATTEMPTS_PER_WINDOW,
|
||||
TWO_FACTOR_SETUP_CONFIRM_WINDOW_MS
|
||||
)
|
||||
) {
|
||||
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.");
|
||||
}
|
||||
|
||||
const { plain, hashes } = generateBackupCodes();
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.customer.update({
|
||||
where: { id: customer.id },
|
||||
data: { twoFactorSecret: setup.secret, twoFactorEnabled: true, sessionVersion: { increment: 1 } },
|
||||
}),
|
||||
prisma.twoFactorBackupCode.createMany({
|
||||
data: hashes.map((codeHash) => ({ customerId: customer.id, codeHash })),
|
||||
}),
|
||||
]);
|
||||
|
||||
await clearTwoFactorSetupCookie();
|
||||
|
||||
const updated = await prisma.customer.findUniqueOrThrow({ where: { id: customer.id } });
|
||||
await createSession(updated.id, updated.role, updated.sessionVersion);
|
||||
|
||||
const displayToken = await signTwoFactorBackupCodesDisplay(customer.id, plain);
|
||||
await setTwoFactorBackupCodesDisplayCookie(displayToken);
|
||||
redirect("/konto/zwei-faktor/backup-codes");
|
||||
}
|
||||
|
||||
export async function dismissBackupCodesAction(): Promise<void> {
|
||||
await clearTwoFactorBackupCodesDisplayCookie();
|
||||
redirect("/konto/zwei-faktor");
|
||||
}
|
||||
|
||||
export async function disableTwoFactorAction(formData: FormData): Promise<void> {
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) fail("/konto", "Bitte melde dich an.");
|
||||
|
||||
const ip = await getClientIp();
|
||||
if (
|
||||
!rateLimit(
|
||||
`2fa-disable:${ip}:${customer.id}`,
|
||||
ACCOUNT_PASSWORD_CONFIRM_ATTEMPTS_PER_WINDOW,
|
||||
ACCOUNT_PASSWORD_CONFIRM_WINDOW_MS
|
||||
)
|
||||
) {
|
||||
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.");
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.customer.update({
|
||||
where: { id: customer.id },
|
||||
data: { twoFactorEnabled: false, twoFactorSecret: null, sessionVersion: { increment: 1 } },
|
||||
}),
|
||||
prisma.twoFactorBackupCode.deleteMany({ where: { customerId: customer.id } }),
|
||||
]);
|
||||
|
||||
const updated = await prisma.customer.findUniqueOrThrow({ where: { id: customer.id } });
|
||||
await createSession(updated.id, updated.role, updated.sessionVersion);
|
||||
|
||||
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."));
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { AccountEmptyState, AddressIcon } from "@/components/account-ui";
|
||||
import { getCurrentCustomer } from "@/lib/auth";
|
||||
import { getAddressesForCustomer } from "@/lib/account-data";
|
||||
import { addAddressAction, deleteAddressAction, setDefaultAddressAction } from "../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
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">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { error } = await props.searchParams;
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) redirect("/konto");
|
||||
|
||||
const addresses = await getAddressesForCustomer(customer.id);
|
||||
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container className="max-w-3xl">
|
||||
<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) => (
|
||||
<div
|
||||
key={address.id}
|
||||
className="border border-ink/10 p-6 flex justify-between gap-4 transition-colors hover:border-ink/25"
|
||||
>
|
||||
<div className="flex gap-4">
|
||||
<span className="flex-shrink-0 flex items-center justify-center w-11 h-11 border border-ink/15 text-forest">
|
||||
<AddressIcon />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-forest">
|
||||
{address.label}{" "}
|
||||
{address.isDefault ? <span className="text-honey-dark text-xs">· {t.addressesPage.standard}</span> : null}
|
||||
</p>
|
||||
<p className="text-sm text-ink/70 mt-1">
|
||||
{address.firstName} {address.lastName}
|
||||
<br />
|
||||
{address.street}
|
||||
<br />
|
||||
{address.zip} {address.city}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2 text-sm flex-shrink-0">
|
||||
{!address.isDefault ? (
|
||||
<form action={setDefaultAddressAction}>
|
||||
<input type="hidden" name="addressId" value={address.id} />
|
||||
<button type="submit" className="text-forest hover:underline">
|
||||
{t.addressesPage.setDefault}
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
<form action={deleteAddressAction}>
|
||||
<input type="hidden" name="addressId" value={address.id} />
|
||||
<button type="submit" className="text-ink/65 hover:text-red-700 hover:underline">
|
||||
{t.addressesPage.delete}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{addresses.length === 0 ? (
|
||||
<AccountEmptyState
|
||||
icon={<AddressIcon />}
|
||||
title={t.addressesPage.emptyTitle}
|
||||
description={t.addressesPage.emptyDescription}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-14 border-t border-ink/10 pt-10">
|
||||
<h2 className="text-lg text-forest mb-6">{t.addressesPage.addNewHeading}</h2>
|
||||
<form action={addAddressAction} className="space-y-4 max-w-md">
|
||||
<Field name="label" label={t.addressesPage.labelField} defaultValue={t.addressesPage.labelDefaultValue} required />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field name="firstName" label={t.account.firstNameLabel} required />
|
||||
<Field name="lastName" label={t.account.lastNameLabel} required />
|
||||
</div>
|
||||
<Field name="street" label={t.checkoutPage.streetLabel} required />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field name="zip" label={t.checkoutPage.zipLabel} required />
|
||||
<Field name="city" label={t.checkoutPage.cityLabel} required />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-ink/70">
|
||||
<input type="checkbox" name="isDefault" />
|
||||
{t.addressesPage.useAsDefault}
|
||||
</label>
|
||||
<Button type="submit">{t.addressesPage.save}</Button>
|
||||
</form>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
name,
|
||||
label,
|
||||
defaultValue,
|
||||
required = false,
|
||||
}: {
|
||||
name: string;
|
||||
label: string;
|
||||
defaultValue?: string;
|
||||
required?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{label}</span>
|
||||
<input
|
||||
name={name}
|
||||
defaultValue={defaultValue}
|
||||
required={required}
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-forest"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { redirect, notFound } from "next/navigation";
|
||||
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { getCurrentCustomer } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requestReturnAction } from "../../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const RETURN_ELIGIBLE_STATUSES = [
|
||||
"BEZAHLT",
|
||||
"IN_VORBEREITUNG",
|
||||
"VERSANDBEREIT",
|
||||
"VERSENDET",
|
||||
"ABHOLBEREIT",
|
||||
"ABGEHOLT",
|
||||
];
|
||||
|
||||
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");
|
||||
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
orderNumber: true,
|
||||
customerId: true,
|
||||
status: true,
|
||||
items: { select: { id: true, productName: true, quantity: true } },
|
||||
returnRequest: { select: { id: true } },
|
||||
},
|
||||
});
|
||||
if (!order || order.customerId !== customer.id) notFound();
|
||||
if (!RETURN_ELIGIBLE_STATUSES.includes(order.status) || order.returnRequest) {
|
||||
redirect("/konto/bestellungen");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container className="max-w-lg">
|
||||
<BackLink href="/konto/bestellungen" label={t.retourePage.backLabel} />
|
||||
<SectionHeading eyebrow={t.retourePage.orderEyebrow(order.orderNumber)} title={t.retourePage.title} />
|
||||
|
||||
<ul className="mt-8 text-sm text-ink/70 space-y-1">
|
||||
{order.items.map((item) => (
|
||||
<li key={item.id}>
|
||||
{item.quantity} × {item.productName}
|
||||
</li>
|
||||
))}
|
||||
</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} />
|
||||
<div>
|
||||
<label htmlFor="reason" className="text-sm text-ink/70">
|
||||
{t.retourePage.reasonLabel}
|
||||
</label>
|
||||
<textarea
|
||||
id="reason"
|
||||
name="reason"
|
||||
required
|
||||
minLength={3}
|
||||
rows={5}
|
||||
placeholder={t.retourePage.reasonPlaceholder}
|
||||
className="mt-2 w-full border border-ink/20 bg-background px-4 py-3 text-sm focus:outline-none focus:border-forest"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit">{t.retourePage.submit}</Button>
|
||||
</form>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getCurrentCustomer } from "@/lib/auth";
|
||||
import { returnRequestSchema } from "@/lib/validation";
|
||||
|
||||
// Rueckgabe nur fuer Bestellungen, die tatsaechlich bezahlt und nicht bereits
|
||||
// storniert wurden — "NEU" (unbezahlt) und "STORNIERT" sind ausgeschlossen.
|
||||
const RETURN_ELIGIBLE_STATUSES = [
|
||||
"BEZAHLT",
|
||||
"IN_VORBEREITUNG",
|
||||
"VERSANDBEREIT",
|
||||
"VERSENDET",
|
||||
"ABHOLBEREIT",
|
||||
"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.");
|
||||
|
||||
const parsed = returnRequestSchema.safeParse({
|
||||
orderId: formData.get("orderId"),
|
||||
reason: formData.get("reason"),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
const orderId = String(formData.get("orderId") ?? "");
|
||||
fail(`/konto/bestellungen/${orderId}/retoure`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
}
|
||||
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id: parsed.data.orderId },
|
||||
select: { customerId: true, status: true, returnRequest: { select: { id: true } } },
|
||||
});
|
||||
|
||||
if (!order || order.customerId !== customer.id) {
|
||||
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.");
|
||||
}
|
||||
if (order.returnRequest) {
|
||||
fail("/konto/bestellungen", "Für diese Bestellung liegt bereits eine Rücksendeanfrage vor.");
|
||||
}
|
||||
|
||||
await prisma.returnRequest.create({
|
||||
data: { orderId: parsed.data.orderId, reason: parsed.data.reason },
|
||||
});
|
||||
|
||||
revalidatePath("/konto/bestellungen");
|
||||
redirect(
|
||||
`/konto/bestellungen?message=${encodeURIComponent("Deine Rücksendeanfrage wurde übermittelt. Wir melden uns bei dir.")}`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Container } from "@/components/ui";
|
||||
|
||||
/**
|
||||
* Next.js-Suspense-Skeleton fuer /konto/bestellungen: die Seite ist
|
||||
* "force-dynamic" und laedt die vollstaendige Bestellhistorie samt Items,
|
||||
* Versand/Abholstatus und Rechnung bei jedem Aufruf neu. Orientiert sich am
|
||||
* tatsaechlichen Karten-Layout der Seite.
|
||||
*/
|
||||
export default function OrdersLoading() {
|
||||
return (
|
||||
<div className="py-20 animate-pulse">
|
||||
<Container className="max-w-3xl">
|
||||
<div className="h-3 w-32 bg-ink/10 rounded-sm" />
|
||||
<div className="mt-3 h-10 w-56 bg-ink/10 rounded-sm" />
|
||||
|
||||
<div className="mt-10 space-y-6">
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<div key={i} className="border border-ink/10 p-6">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="h-4 w-32 bg-ink/10 rounded-sm" />
|
||||
<div className="mt-2 h-3 w-24 bg-ink/10 rounded-sm" />
|
||||
</div>
|
||||
<div className="h-6 w-24 bg-ink/10 rounded-sm" />
|
||||
</div>
|
||||
<div className="mt-4 space-y-1.5">
|
||||
<div className="h-3 w-2/3 bg-ink/10 rounded-sm" />
|
||||
<div className="h-3 w-1/2 bg-ink/10 rounded-sm" />
|
||||
</div>
|
||||
<div className="mt-5 h-2 w-full bg-ink/10" />
|
||||
<div className="mt-5 h-4 w-20 bg-ink/10 rounded-sm" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Container, SectionHeading, LinkButton } from "@/components/ui";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { ReorderButton } from "@/components/reorder-button";
|
||||
import { AccountEmptyState, OrderStatusBadge, OrderStatusTimeline, OrderIcon } from "@/components/account-ui";
|
||||
import { getCurrentCustomer } from "@/lib/auth";
|
||||
import { getOrdersForCustomer } from "@/lib/account-data";
|
||||
import { formatPrice, formatDate } from "@/lib/format";
|
||||
import { getCarrierTrackingUrl } from "@/lib/shipment-tracking";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export const metadata: Metadata = { title: "Meine Bestellungen" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Deckt sich mit RETURN_ELIGIBLE_STATUSES in bestellungen/actions.ts.
|
||||
const RETURN_ELIGIBLE_STATUSES = [
|
||||
"BEZAHLT",
|
||||
"IN_VORBEREITUNG",
|
||||
"VERSANDBEREIT",
|
||||
"VERSENDET",
|
||||
"ABHOLBEREIT",
|
||||
"ABGEHOLT",
|
||||
];
|
||||
|
||||
export default async function KontoBestellungenPage(props: PageProps<"/konto/bestellungen">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const statusLabels = t.orderStatusBadge;
|
||||
const returnStatusLabels = t.ordersPage.returnStatusLabels;
|
||||
const { error, message } = await props.searchParams;
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) redirect("/konto");
|
||||
|
||||
const orders = await getOrdersForCustomer(customer.id);
|
||||
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container className="max-w-3xl">
|
||||
<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
|
||||
icon={<OrderIcon />}
|
||||
title={t.ordersPage.emptyTitle}
|
||||
description={t.ordersPage.emptyDescription}
|
||||
action={<LinkButton href="/shop">{t.cartPage.goToShop}</LinkButton>}
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-10 space-y-6">
|
||||
{orders.map((order) => (
|
||||
<div
|
||||
key={order.id}
|
||||
className="border border-ink/10 p-6 transition-colors hover:border-ink/25"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-forest">{order.orderNumber}</p>
|
||||
<p className="text-xs text-ink/65">{formatDate(order.createdAt)}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<OrderStatusBadge status={order.status} label={statusLabels[order.status] ?? order.status} />
|
||||
<p className="mt-1.5 text-xs text-ink/65">
|
||||
{order.fulfillmentMethod === "VERSAND"
|
||||
? order.shipment?.shippedAt
|
||||
? t.ordersPage.shippedOn(formatDate(order.shipment.shippedAt))
|
||||
: t.ordersPage.shippingPending
|
||||
: order.pickup?.pickedUpAt
|
||||
? t.ordersPage.pickedUpOn(formatDate(order.pickup.pickedUpAt))
|
||||
: order.pickup?.readyAt
|
||||
? t.ordersPage.readySince(formatDate(order.pickup.readyAt))
|
||||
: t.ordersPage.pickupPending}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="mt-4 text-sm text-ink/70 space-y-1">
|
||||
{order.items.map((item) => (
|
||||
<li key={item.id}>
|
||||
{item.quantity} × {item.productName}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-5 max-w-md">
|
||||
<OrderStatusTimeline
|
||||
status={order.status}
|
||||
fulfillmentMethod={order.fulfillmentMethod}
|
||||
labels={{
|
||||
ordered: t.orderStatusTimeline.ordered,
|
||||
paid: t.orderStatusTimeline.paid,
|
||||
preparing: t.orderStatusTimeline.preparing,
|
||||
readyToShip: t.orderStatusTimeline.readyToShip,
|
||||
shipped: t.orderStatusTimeline.shipped,
|
||||
readyForPickup: t.orderStatusTimeline.readyForPickup,
|
||||
pickedUp: t.orderStatusTimeline.pickedUp,
|
||||
cancelled: t.orderStatusTimeline.cancelled,
|
||||
ariaLabel: t.orderStatusTimeline.ariaLabel,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{order.shipment?.carrier && order.shipment.trackingNumber ? (
|
||||
<p className="mt-3 text-xs text-ink/65">
|
||||
{t.shipmentInfo.carrierLabel}: {order.shipment.carrier} · {t.shipmentInfo.trackingNumberLabel}:{" "}
|
||||
{order.shipment.trackingNumber}
|
||||
{(() => {
|
||||
const url = getCarrierTrackingUrl(order.shipment.carrier, order.shipment.trackingNumber);
|
||||
return url ? (
|
||||
<>
|
||||
{" "}
|
||||
·{" "}
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" className="text-forest underline">
|
||||
{t.shipmentInfo.trackPackage}
|
||||
</a>
|
||||
</>
|
||||
) : null;
|
||||
})()}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{order.giftMessage ? (
|
||||
<p className="mt-3 text-xs text-ink/65 bg-honey/10 border border-honey-dark/20 px-3 py-2">
|
||||
🎁 {t.ordersPage.giftMessagePrefix} „{order.giftMessage}“
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-4">
|
||||
<p className="text-forest">{formatPrice(order.totalCents, order.currency)}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
{order.invoice ? (
|
||||
<Link
|
||||
href={`/rechnung/${order.invoice.invoiceNumber}`}
|
||||
className="text-sm text-forest underline"
|
||||
>
|
||||
{t.ordersPage.viewInvoice}
|
||||
</Link>
|
||||
) : null}
|
||||
{order.returnRequest ? (
|
||||
<span className="text-sm text-ink/65">
|
||||
{returnStatusLabels[order.returnRequest.status] ?? order.returnRequest.status}
|
||||
</span>
|
||||
) : RETURN_ELIGIBLE_STATUSES.includes(order.status) ? (
|
||||
<Link
|
||||
href={`/konto/bestellungen/${order.id}/retoure`}
|
||||
className="text-sm text-forest underline"
|
||||
>
|
||||
{t.ordersPage.requestReturn}
|
||||
</Link>
|
||||
) : null}
|
||||
<ReorderButton
|
||||
items={order.items.map((item) => ({
|
||||
productId: item.productId,
|
||||
slug: item.product.slug,
|
||||
name: item.productName,
|
||||
weightGrams: item.weightGrams,
|
||||
priceCents: item.unitPriceCents,
|
||||
quantity: item.quantity,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
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 { 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 { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
registerAction,
|
||||
loginAction,
|
||||
logoutAction,
|
||||
toggleNewsletterAction,
|
||||
logoutAllDevicesAction,
|
||||
resendVerificationEmailAction,
|
||||
deleteOwnAccountAction,
|
||||
} from "./actions";
|
||||
|
||||
export const metadata: Metadata = { title: "Mein Konto" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function KontoPage(props: PageProps<"/konto">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { error, message } = await props.searchParams;
|
||||
const customer = await getCurrentCustomer();
|
||||
|
||||
if (!customer) {
|
||||
return (
|
||||
<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>
|
||||
<Field name="email" label={t.account.emailLabel} type="email" required autoComplete="email" />
|
||||
<PasswordField name="password" label={t.account.passwordLabel} required autoComplete="current-password" />
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Button type="submit">{t.account.login}</Button>
|
||||
<Link href="/konto/passwort-vergessen" className="text-xs text-ink/65 hover:text-forest underline">
|
||||
{t.account.forgotPassword}
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form action={registerAction} className="space-y-4 md:pl-12">
|
||||
<h2 className="text-lg text-forest">{t.account.register}</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field name="firstName" label={t.account.firstNameLabel} required autoComplete="given-name" />
|
||||
<Field name="lastName" label={t.account.lastNameLabel} required autoComplete="family-name" />
|
||||
</div>
|
||||
<Field name="email" label={t.account.emailLabel} type="email" required autoComplete="email" />
|
||||
<PasswordField
|
||||
name="password"
|
||||
label={t.account.passwordMinLabel}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
showStrength
|
||||
/>
|
||||
<PasswordField
|
||||
name="passwordConfirm"
|
||||
label={t.account.passwordConfirmLabel}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
{process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ? (
|
||||
<TurnstileField siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY} />
|
||||
) : null}
|
||||
<Button type="submit">{t.account.register}</Button>
|
||||
</form>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
<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">
|
||||
<p className="text-ink/70">
|
||||
{t.account.verifyEmailPrompt}
|
||||
</p>
|
||||
<form action={resendVerificationEmailAction}>
|
||||
<button type="submit" className="text-forest underline whitespace-nowrap">
|
||||
{t.account.resendVerification}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-10 grid sm:grid-cols-2 gap-6">
|
||||
<AccountNavCard
|
||||
href="/konto/bestellungen"
|
||||
icon={<OrderIcon />}
|
||||
title={t.account.navOrders.title}
|
||||
description={t.account.navOrders.description}
|
||||
/>
|
||||
<AccountNavCard
|
||||
href="/konto/adressen"
|
||||
icon={<AddressIcon />}
|
||||
title={t.account.navAddresses.title}
|
||||
description={t.account.navAddresses.description}
|
||||
/>
|
||||
<AccountNavCard
|
||||
href="/konto/wunschliste"
|
||||
icon={<HeartIcon />}
|
||||
title={t.account.navWishlist.title}
|
||||
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}`}
|
||||
/>
|
||||
{customer.role === "ADMIN" ? (
|
||||
<AccountNavCard
|
||||
href="/admin"
|
||||
icon={<ShieldIcon />}
|
||||
title={t.account.navAdmin.title}
|
||||
description={t.account.navAdmin.description}
|
||||
accent
|
||||
className="sm:col-span-2"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-10 border border-ink/10 p-6 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg text-forest">{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-lg text-forest">{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">
|
||||
<Button type="submit" variant="ghost">
|
||||
{t.account.logout}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<h2 className="text-lg text-forest mb-4">{t.account.privacy.heading}</h2>
|
||||
<div className="border border-ink/10 p-6 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-sm text-forest">{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-sm 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>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
name,
|
||||
label,
|
||||
type = "text",
|
||||
required = false,
|
||||
autoComplete,
|
||||
}: {
|
||||
name: string;
|
||||
label: string;
|
||||
type?: string;
|
||||
required?: boolean;
|
||||
autoComplete?: string;
|
||||
}) {
|
||||
return (
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{label}</span>
|
||||
<input
|
||||
name={name}
|
||||
type={type}
|
||||
required={required}
|
||||
autoComplete={autoComplete}
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-forest"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { requestPasswordResetAction } from "../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
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;
|
||||
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container className="max-w-md">
|
||||
<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">
|
||||
{t.passwortVergessenPage.sentMessage}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="mt-6 text-sm text-ink/70">
|
||||
{t.passwortVergessenPage.intro}
|
||||
</p>
|
||||
<form action={requestPasswordResetAction} className="mt-6 space-y-4">
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.account.emailLabel}</span>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-forest"
|
||||
/>
|
||||
</label>
|
||||
<Button type="submit">{t.passwortVergessenPage.requestLink}</Button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { PasswordField } from "@/components/password-field";
|
||||
import { resetPasswordAction } from "../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export const metadata: Metadata = { title: "Neues Passwort vergeben" };
|
||||
|
||||
export default async function PasswortZuruecksetzenPage(
|
||||
props: PageProps<"/konto/passwort-zuruecksetzen">
|
||||
) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { token, error } = await props.searchParams;
|
||||
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container className="max-w-md">
|
||||
<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">
|
||||
{t.passwortZuruecksetzenPage.incompleteLinkTextBefore}
|
||||
<a href="/konto/passwort-vergessen" className="underline text-forest">
|
||||
{t.passwortZuruecksetzenPage.incompleteLinkLabel}
|
||||
</a>
|
||||
{t.passwortZuruecksetzenPage.incompleteLinkTextAfter}
|
||||
</p>
|
||||
) : (
|
||||
<form action={resetPasswordAction} className="mt-6 space-y-4">
|
||||
<input type="hidden" name="token" value={token} />
|
||||
<PasswordField
|
||||
name="password"
|
||||
label={t.passwortZuruecksetzenPage.newPasswordLabel}
|
||||
required
|
||||
minLength={8}
|
||||
autoComplete="new-password"
|
||||
showStrength
|
||||
/>
|
||||
<Button type="submit">{t.passwortZuruecksetzenPage.save}</Button>
|
||||
</form>
|
||||
)}
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Container, SectionHeading, LinkButton } from "@/components/ui";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { ProductCard } from "@/components/product-card";
|
||||
import { AccountEmptyState, HeartIcon } from "@/components/account-ui";
|
||||
import { getCurrentCustomer } from "@/lib/auth";
|
||||
import { getWishlistProducts } from "@/lib/wishlist";
|
||||
import { toggleWishlistAction } from "../../honig/actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export const metadata: Metadata = { title: "Meine Wunschliste" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function KontoWunschlistePage() {
|
||||
const t = getDictionary(await getLocale());
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) redirect("/konto");
|
||||
|
||||
const products = await getWishlistProducts(customer.id);
|
||||
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container>
|
||||
<BackLink href="/konto" label={t.ordersPage.backToAccount} />
|
||||
<SectionHeading eyebrow={t.account.eyebrow} title={t.wishlistPage.title} />
|
||||
|
||||
{products.length === 0 ? (
|
||||
<AccountEmptyState
|
||||
icon={<HeartIcon />}
|
||||
title={t.wishlistPage.emptyTitle}
|
||||
description={t.wishlistPage.emptyDescription}
|
||||
action={<LinkButton href="/shop">{t.cartPage.goToShop}</LinkButton>}
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-14 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-14">
|
||||
{products.map((product) => (
|
||||
<div key={product.id}>
|
||||
<ProductCard product={product} />
|
||||
<form action={toggleWishlistAction} className="mt-3">
|
||||
<input type="hidden" name="productId" value={product.id} />
|
||||
<input type="hidden" name="redirectPath" value="/konto/wunschliste" />
|
||||
<button type="submit" className="text-sm text-ink/65 hover:text-red-700 underline">
|
||||
{t.cart.remove}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { getTwoFactorChallengeCustomerId } from "@/lib/auth";
|
||||
import { verifyTwoFactorAction } from "../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
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">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { error } = await props.searchParams;
|
||||
|
||||
const customerId = await getTwoFactorChallengeCustomerId();
|
||||
if (!customerId) redirect("/konto");
|
||||
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container className="max-w-md">
|
||||
<BackLink href="/konto" label={t.twoFactorCodePage.cancelLabel} />
|
||||
<SectionHeading
|
||||
eyebrow={t.twoFactorCodePage.eyebrow}
|
||||
title={t.twoFactorCodePage.title}
|
||||
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">
|
||||
<span className="text-ink/70">{t.twoFactorCodePage.codeLabel}</span>
|
||||
<input
|
||||
name="code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
autoFocus
|
||||
required
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 tracking-widest focus:outline-none focus:border-forest"
|
||||
/>
|
||||
</label>
|
||||
<Button type="submit">{t.twoFactorCodePage.confirm}</Button>
|
||||
</form>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||
import { getCurrentCustomer, getTwoFactorBackupCodesDisplay } from "@/lib/auth";
|
||||
import { dismissBackupCodesAction } from "../../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export const metadata: Metadata = { title: "Backup-Codes" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ZweiFaktorBackupCodesPage() {
|
||||
const t = getDictionary(await getLocale());
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) redirect("/konto");
|
||||
|
||||
const codes = await getTwoFactorBackupCodesDisplay();
|
||||
if (!codes) redirect("/konto/zwei-faktor");
|
||||
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container className="max-w-md">
|
||||
<SectionHeading
|
||||
eyebrow={t.twoFactorBackupCodesPage.eyebrow}
|
||||
title={t.twoFactorBackupCodesPage.title}
|
||||
subtitle={t.twoFactorBackupCodesPage.subtitle}
|
||||
/>
|
||||
|
||||
<ul className="mt-8 border border-ink/10 divide-y divide-ink/10 font-mono text-sm">
|
||||
{codes.map((code) => (
|
||||
<li key={code} className="px-4 py-3">
|
||||
{code}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">
|
||||
{t.twoFactorBackupCodesPage.warning}
|
||||
</p>
|
||||
|
||||
<form action={dismissBackupCodesAction} className="mt-8">
|
||||
<Button type="submit">{t.twoFactorBackupCodesPage.done}</Button>
|
||||
</form>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { getCurrentCustomer, getTwoFactorSetup } from "@/lib/auth";
|
||||
import { generateTotpQrCodeDataUrl } from "@/lib/two-factor";
|
||||
import { confirmTwoFactorSetupAction } from "../../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
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">
|
||||
) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { error } = await props.searchParams;
|
||||
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) redirect("/konto");
|
||||
|
||||
const setup = await getTwoFactorSetup();
|
||||
if (!setup || setup.customerId !== customer.id) redirect("/konto/zwei-faktor");
|
||||
|
||||
const qrCodeDataUrl = await generateTotpQrCodeDataUrl(setup.secret, customer.email);
|
||||
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container className="max-w-md">
|
||||
<BackLink href="/konto/zwei-faktor" label={t.twoFactorSetupPage.backLabel} />
|
||||
<SectionHeading
|
||||
eyebrow={t.twoFactorSetupPage.eyebrow}
|
||||
title={t.twoFactorSetupPage.title}
|
||||
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 */}
|
||||
<img src={qrCodeDataUrl} alt={t.twoFactorSetupPage.qrAlt} width={240} height={240} />
|
||||
<p className="mt-4 text-xs text-ink/65 break-all text-center">
|
||||
{t.twoFactorSetupPage.manualEntryLabel} <span className="font-mono">{setup.secret}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form action={confirmTwoFactorSetupAction} className="mt-8 space-y-4">
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.twoFactorSetupPage.appCodeLabel}</span>
|
||||
<input
|
||||
name="code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
required
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 tracking-widest focus:outline-none focus:border-forest"
|
||||
/>
|
||||
</label>
|
||||
<Button type="submit">{t.twoFactorSetupPage.activate}</Button>
|
||||
</form>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { ShieldIcon } from "@/components/account-ui";
|
||||
import { PasswordField } from "@/components/password-field";
|
||||
import { getCurrentCustomer } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { startTwoFactorSetupAction, disableTwoFactorAction } from "../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
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">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { error, message } = await props.searchParams;
|
||||
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) redirect("/konto");
|
||||
|
||||
const remainingBackupCodes = customer.twoFactorEnabled
|
||||
? await prisma.twoFactorBackupCode.count({ where: { customerId: customer.id, usedAt: null } })
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container className="max-w-md">
|
||||
<BackLink href="/konto" label={t.twoFactorPage.backLabel} />
|
||||
<SectionHeading
|
||||
eyebrow={t.twoFactorPage.eyebrow}
|
||||
title={t.twoFactorPage.title}
|
||||
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">
|
||||
<p className="flex items-center gap-3 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">
|
||||
<ShieldIcon />
|
||||
<span>
|
||||
{t.twoFactorPage.activeStatus(remainingBackupCodes)}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<form action={disableTwoFactorAction} className="space-y-4 border border-ink/10 p-6">
|
||||
<h2 className="text-forest">{t.twoFactorPage.disableHeading}</h2>
|
||||
<PasswordField
|
||||
name="password"
|
||||
label={t.twoFactorPage.passwordConfirmLabel}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
<Button type="submit" variant="ghost">
|
||||
{t.twoFactorPage.disableButton}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
<form action={startTwoFactorSetupAction} className="mt-8">
|
||||
<Button type="submit">{t.twoFactorPage.setupButton}</Button>
|
||||
</form>
|
||||
)}
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user