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
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
Reference in new issue
Block a user