mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 00:14:12 +00:00
A newsletter's List-Unsubscribe `mailto:` was sent from the user's own account as written: every `to=` field became a recipient, and subject and body were the sender's choice. The confirmation said only "Unsubscribe from this sender?", so one click could mail "I resign" to someone's boss and HR. The unsubscribe now goes to the single address in the URL (a list of addresses is refused and `to=` is ignored), the subject is kept on one line and both subject and body are capped. Both confirmations show the recipient, subject and body before anything is sent.
120 lines
3.4 KiB
TypeScript
120 lines
3.4 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useId } from "react";
|
|
import { useFocusTrap } from "@/hooks/use-focus-trap";
|
|
import { useTranslations } from "next-intl";
|
|
import { Button } from "@/components/ui/button";
|
|
import { AlertTriangle } from "@/components/icons";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
interface ConfirmDialogProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
onConfirm: () => void;
|
|
title: string;
|
|
message: string;
|
|
/** Shown under the message, e.g. exactly what confirming will send. */
|
|
details?: React.ReactNode;
|
|
confirmText?: string;
|
|
cancelText?: string;
|
|
variant?: "default" | "destructive";
|
|
}
|
|
|
|
export function ConfirmDialog({
|
|
isOpen,
|
|
onClose,
|
|
onConfirm,
|
|
title,
|
|
message,
|
|
details,
|
|
confirmText,
|
|
cancelText,
|
|
variant = "default",
|
|
}: ConfirmDialogProps) {
|
|
const t = useTranslations("confirm_dialog");
|
|
const id = useId();
|
|
|
|
const dialogRef = useFocusTrap({
|
|
isActive: isOpen,
|
|
onEscape: onClose,
|
|
restoreFocus: true,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
|
|
const handleBackdropClick = (e: MouseEvent) => {
|
|
if (dialogRef.current && !dialogRef.current.contains(e.target as Node)) {
|
|
onClose();
|
|
}
|
|
};
|
|
|
|
document.addEventListener("mousedown", handleBackdropClick);
|
|
return () => document.removeEventListener("mousedown", handleBackdropClick);
|
|
}, [isOpen, onClose, dialogRef]);
|
|
|
|
if (!isOpen) return null;
|
|
|
|
const resolvedConfirmText = confirmText || t("confirm");
|
|
const resolvedCancelText = cancelText || t("cancel");
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150">
|
|
<div
|
|
ref={dialogRef}
|
|
role="alertdialog"
|
|
aria-modal="true"
|
|
aria-labelledby={`${id}-title`}
|
|
aria-describedby={`${id}-message`}
|
|
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-md animate-in zoom-in-95 duration-200"
|
|
>
|
|
<div className="p-6">
|
|
<div className="flex items-start gap-4">
|
|
{variant === "destructive" && (
|
|
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-destructive/10 flex items-center justify-center">
|
|
<AlertTriangle className="w-5 h-5 text-destructive" />
|
|
</div>
|
|
)}
|
|
<div className="flex-1 min-w-0">
|
|
<h2
|
|
id={`${id}-title`}
|
|
className="text-lg font-semibold text-foreground"
|
|
>
|
|
{title}
|
|
</h2>
|
|
<p
|
|
id={`${id}-message`}
|
|
className="mt-2 text-sm text-muted-foreground"
|
|
>
|
|
{message}
|
|
</p>
|
|
{details}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-end gap-3 px-6 pb-6">
|
|
<Button variant="outline" onClick={onClose}>
|
|
{resolvedCancelText}
|
|
</Button>
|
|
<Button
|
|
variant={variant === "destructive" ? "destructive" : "default"}
|
|
onClick={() => {
|
|
try {
|
|
onConfirm();
|
|
} finally {
|
|
onClose();
|
|
}
|
|
}}
|
|
className={cn(
|
|
variant === "destructive" && "shadow-sm"
|
|
)}
|
|
>
|
|
{resolvedConfirmText}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|