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

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

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

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

No files matched your search

+2 -2
View File
@@ -16,7 +16,7 @@ export function hashVerificationToken(token: string): string {
* Link. Wird nach der Selbst-Registrierung aufgerufen sowie ueber den
* "Link erneut senden"-Button auf /konto, solange das Konto unbestaetigt ist.
*/
export async function createAndSendVerificationEmail(customer: { id: string; email: string }): Promise<void> {
export async function createAndSendVerificationEmail(customer: { id: string; email: string }): Promise<boolean> {
const rawToken = randomBytes(32).toString("hex");
await prisma.emailVerificationToken.deleteMany({ where: { customerId: customer.id, usedAt: null } });
await prisma.emailVerificationToken.create({
@@ -29,5 +29,5 @@ export async function createAndSendVerificationEmail(customer: { id: string; ema
const verifyUrl = `${getBaseUrl()}/api/konto/email-bestaetigen?token=${rawToken}`;
const { subject, html, text } = await emailVerificationEmail(await getLocale(), verifyUrl);
await sendMail({ to: customer.email, subject, html, text });
return sendMail({ to: customer.email, subject, html, text });
}
+61
View File
@@ -0,0 +1,61 @@
/**
* Reine Hilfsfunktionen der Flash-Meldungen (siehe lib/flash.ts) ohne
* next/headers — damit auch der Proxy (src/proxy.ts) und Route Handler sie
* nutzen koennen.
*/
export const FLASH_COOKIE = "wh_flash";
export const MAX_FLASH_MESSAGE_LENGTH = 300;
/** Ohne JavaScript loescht niemand das Cookie — dann verfaellt es von selbst. */
const FLASH_MAX_AGE_SECONDS = 60;
const FLASH_KINDS = ["success", "error", "info"] as const;
export type FlashKind = (typeof FLASH_KINDS)[number];
export interface Flash {
id: string;
kind: FlashKind;
message: string;
}
export function serializeFlash(flash: Flash): string {
return encodeURIComponent(JSON.stringify({ ...flash, message: flash.message.slice(0, MAX_FLASH_MESSAGE_LENGTH) }));
}
export function parseFlash(raw: string | undefined): Flash | null {
if (!raw) return null;
let value: unknown;
try {
value = JSON.parse(decodeURIComponent(raw));
} catch {
return null;
}
if (typeof value !== "object" || value === null) return null;
const { id, kind, message } = value as Record<string, unknown>;
if (typeof id !== "string" || typeof message !== "string" || message.trim() === "") return null;
if (!(FLASH_KINDS as readonly unknown[]).includes(kind)) return null;
return { id: id.slice(0, 64), kind: kind as FlashKind, message: message.slice(0, MAX_FLASH_MESSAGE_LENGTH) };
}
export function flashCookieOptions() {
return {
path: "/",
maxAge: FLASH_MAX_AGE_SECONDS,
sameSite: "lax" as const,
secure: process.env.NODE_ENV === "production",
httpOnly: false,
};
}
/**
* Fuer Route Handler, die selbst eine NextResponse zurueckgeben (z.B. der
* Link aus der Bestaetigungs-E-Mail): haengt die Meldung direkt an die
* Weiterleitung.
*/
export function withFlash<T extends { cookies: { set: (name: string, value: string, options: object) => unknown } }>(
response: T,
kind: FlashKind,
message: string
): T {
response.cookies.set(FLASH_COOKIE, serializeFlash({ id: crypto.randomUUID(), kind, message }), flashCookieOptions());
return response;
}
+61
View File
@@ -0,0 +1,61 @@
import { describe, it, expect } from "vitest";
import { readdirSync, readFileSync, statSync } from "node:fs";
import path from "node:path";
import { parseFlash, serializeFlash, MAX_FLASH_MESSAGE_LENGTH } from "./flash";
describe("serializeFlash / parseFlash", () => {
it("uebertraegt Art und Text unveraendert", () => {
const raw = serializeFlash({ id: "abc", kind: "success", message: "Retoure angenommen." });
expect(parseFlash(raw)).toEqual({ id: "abc", kind: "success", message: "Retoure angenommen." });
});
it("kuerzt ueberlange Texte, damit das Cookie klein bleibt", () => {
const raw = serializeFlash({ id: "abc", kind: "error", message: "x".repeat(5000) });
expect(parseFlash(raw)?.message.length).toBe(MAX_FLASH_MESSAGE_LENGTH);
});
it.each([
["fehlendes Cookie", undefined],
["kein JSON", "%%%"],
["unbekannte Art", encodeURIComponent(JSON.stringify({ id: "a", kind: "html", message: "x" }))],
["leerer Text", encodeURIComponent(JSON.stringify({ id: "a", kind: "info", message: "" }))],
])("verwirft %s", (_label, raw) => {
expect(parseFlash(raw)).toBeNull();
});
});
/**
* fail()/succeed()/setFlash() sind asynchron (Cookies lassen sich in
* Next.js 16 nur asynchron setzen). Ohne "await" liefe eine Server Action
* nach einem fail() — etwa nach einer gescheiterten Berechtigungs- oder
* Eingabepruefung — einfach weiter. ESLint hat dafuer keine Regel aktiv,
* deshalb diese Pruefung ueber den Quelltext.
*/
describe("Aufrufe der Rueckmelde-Helfer", () => {
function sourceFiles(dir: string): string[] {
return readdirSync(dir).flatMap((name) => {
const full = path.join(dir, name);
if (statSync(full).isDirectory()) return name === "generated" ? [] : sourceFiles(full);
return /\.tsx?$/.test(name) && !/\.test\.tsx?$/.test(name) ? [full] : [];
});
}
it("werden ausnahmslos mit await (oder return) aufgerufen", () => {
const srcDir = path.join(__dirname, "..");
const offenders = sourceFiles(srcDir).flatMap((file) =>
readFileSync(file, "utf8")
.split("\n")
.map((line, i) => ({ line: line.trim(), where: `${path.relative(srcDir, file)}:${i + 1}` }))
// "[^)]": nur echte Aufrufe mit Argument, keine Erwaehnung wie "fail()" in Kommentaren
.filter(({ line }) => /\b(fail|setFlash|succeed)\([^)]/.test(line))
.filter(({ line }) => !/^(export )?(async )?function /.test(line))
.filter(({ line }) => !/^(\/\/|\*|\/\*)/.test(line))
.filter(({ line }) => !/\b(await|return) (fail|setFlash|succeed)\(/.test(line))
.map(({ where, line }) => `${where}: ${line}`)
);
expect(offenders).toEqual([]);
});
});
+70
View File
@@ -0,0 +1,70 @@
/**
* Rueckmeldungen ("Flash-Meldungen") nach einer Aktion — z.B. "Retoure
* angenommen" oder "Code ungueltig" — als kurzlebiges Cookie statt als
* ?message=/?error= in der URL.
*
* Warum kein URL-Parameter mehr: jeder konnte einen Link wie
* /konto?error=Bitte%20ruf%200900...%20an bauen, und der Text erschien im
* Wabenhain-Layout wie eine echte Systemmeldung (Phishing-Hebel). Ein Cookie
* fuer diese Domain kann eine fremde Seite nicht setzen. Nebenbei erscheint
* dieselbe Meldung beim zweiten Klick erneut (neue id), statt dass sich
* die URL nicht aendert und scheinbar nichts passiert.
*
* Ablauf: Server Action ruft setFlash() -> Next.js rendert Seite und Layouts
* neu, weil ein Cookie gesetzt wurde -> das Root-Layout liest die Meldung
* (readFlash) und reicht sie an <FlashToast> weiter -> der Client zeigt sie
* an und loescht das Cookie. Deshalb ist das Cookie NICHT httpOnly; es
* enthaelt nichts Geheimes und wird ausschliesslich als React-Text
* gerendert, nie als HTML.
*/
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import {
FLASH_COOKIE,
flashCookieOptions,
parseFlash,
serializeFlash,
type Flash,
type FlashKind,
} from "@/lib/flash-core";
export {
FLASH_COOKIE,
MAX_FLASH_MESSAGE_LENGTH,
parseFlash,
serializeFlash,
withFlash,
type Flash,
type FlashKind,
} from "@/lib/flash-core";
/** Nur in Server Actions und Route Handlern aufrufbar (dort sind Cookies schreibbar). */
export async function setFlash(kind: FlashKind, message: string): Promise<void> {
const store = await cookies();
store.set(FLASH_COOKIE, serializeFlash({ id: crypto.randomUUID(), kind, message }), flashCookieOptions());
}
export async function readFlash(): Promise<Flash | null> {
const store = await cookies();
return parseFlash(store.get(FLASH_COOKIE)?.value);
}
/**
* Meldet einen Fehler als Flash-Meldung und leitet auf path weiter. Immer
* mit await aufrufen — sonst laeuft die Action nach dem Fehler weiter
* (abgesichert durch flash.test.ts).
*/
export async function fail(path: string, message: string): Promise<never> {
await setFlash("error", message);
redirect(path);
}
/** Erfolgsmeldung setzen und weiterleiten. */
export async function succeed(path: string, message: string): Promise<never> {
await setFlash("success", message);
redirect(path);
}
/** Einheitlicher Zusatz, wenn eine Aktion geklappt hat, die zugehoerige E-Mail aber nicht rausging. */
export const MAIL_FAILED_HINT = "Die E-Mail konnte nicht versendet werden — bitte Mailserver (SMTP) prüfen.";
+10 -2
View File
@@ -66,8 +66,14 @@ function getTransport() {
* damit lokale Entwicklung und Deployments ohne Mail-Setup nicht abstuerzen.
* Wirft absichtlich keinen Fehler: ein Mail-Ausfall darf eine Bestellung
* oder Registrierung nicht verhindern.
*
* Liefert true, wenn der Mailserver die Nachricht angenommen hat, sonst
* false (nicht konfiguriert oder Versand fehlgeschlagen). Aktionen mit
* sichtbarer Rueckmeldung ("Link erneut gesendet", "Kundschaft informiert")
* duerfen Erfolg nur melden, wenn hier true zurueckkam — sonst wartet die
* Kundschaft auf eine Mail, die nie kommt.
*/
export async function sendMail(input: SendMailInput): Promise<void> {
export async function sendMail(input: SendMailInput): Promise<boolean> {
const transport = getTransport();
const from = process.env.SMTP_FROM || "Wabenhain <[email protected]>";
@@ -75,7 +81,7 @@ export async function sendMail(input: SendMailInput): Promise<void> {
console.warn(
`[mail] SMTP nicht konfiguriert — E-Mail an ${maskEmail(input.to)} ("${input.subject}") wurde nicht gesendet.`
);
return;
return false;
}
try {
@@ -87,8 +93,10 @@ export async function sendMail(input: SendMailInput): Promise<void> {
text: input.text,
...(input.attachments?.length ? { attachments: input.attachments } : {}),
});
return true;
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
console.error(`[mail] Versand an ${maskEmail(input.to)} fehlgeschlagen: ${message}`);
return false;
}
}
+33
View File
@@ -0,0 +1,33 @@
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
/**
* Feste Hinweise fuer Weiterleitungen aus Server Components, die selbst
* keine Cookies setzen duerfen (siehe app/api/hinweis/route.ts).
*
* Bewusst nur feste Codes mit festem Ziel — weder Text noch Ziel kommen aus
* der URL. Sonst liesse sich ueber den Endpunkt beliebiger Text im
* Wabenhain-Layout anzeigen (Phishing) oder auf fremde Seiten weiterleiten
* (Open Redirect).
*/
export const NOTICES = {
"admin-login": { to: "/konto", message: async () => "Bitte melde dich mit einem Admin-Konto an." },
"admin-2fa": {
to: "/konto/zwei-faktor",
message: async () => "Für Admin-Konten ist Zwei-Faktor-Authentifizierung erforderlich. Bitte richte sie jetzt ein.",
},
"invoice-login": {
to: "/konto",
message: async () => getDictionary(await getLocale()).invoicePage.loginRequiredError,
},
} as const satisfies Record<string, { to: string; message: () => Promise<string> }>;
export type NoticeCode = keyof typeof NOTICES;
export function isNoticeCode(value: string | null): value is NoticeCode {
return value !== null && Object.hasOwn(NOTICES, value);
}
export function noticeUrl(code: NoticeCode): string {
return `/api/hinweis?code=${code}`;
}
+2 -2
View File
@@ -17,7 +17,7 @@ export function hashResetToken(token: string): string {
* und der Admin-Benutzerverwaltung (statt dass ein Admin dort direkt ein
* neues Passwort setzen kann, bekommt der Kunde immer nur diesen Link).
*/
export async function createAndSendPasswordResetEmail(customer: { id: string; email: string }): Promise<void> {
export async function createAndSendPasswordResetEmail(customer: { id: string; email: string }): Promise<boolean> {
const rawToken = randomBytes(32).toString("hex");
await prisma.passwordResetToken.deleteMany({ where: { customerId: customer.id, usedAt: null } });
await prisma.passwordResetToken.create({
@@ -30,5 +30,5 @@ export async function createAndSendPasswordResetEmail(customer: { id: string; em
const resetUrl = `${getBaseUrl()}/konto/passwort-zuruecksetzen?token=${rawToken}`;
const { subject, html, text } = await passwordResetEmail(await getLocale(), resetUrl);
await sendMail({ to: customer.email, subject, html, text });
return sendMail({ to: customer.email, subject, html, text });
}