feat: app-wide pending-deletion banner + email every org member

This commit is contained in:
Matthew Meszaros
2026-05-24 04:35:57 +00:00
parent 889c6d1835
commit fceeddf5ea
4 changed files with 262 additions and 27 deletions
+65 -26
View File
@@ -402,30 +402,26 @@ func (s *service) sendOrgScheduledEmail(ctx context.Context, org *models.Organiz
if s.notifier == nil {
return
}
owner, _ := s.userRepo.GetUser(ctx, org.OwnerUserID)
if owner == nil {
recipients := s.orgRecipients(ctx, org)
if len(recipients) == 0 {
return
}
subject := fmt.Sprintf("%s scheduled for deletion", org.Name)
body := orgScheduledHTML(org, d, s.frontendBaseURL)
if err := s.notifier.Send(ctx, []string{owner.Email}, nil, nil, subject, body); err != nil {
sentry.CaptureException(err)
}
s.sendToEach(ctx, recipients, subject, body)
}
func (s *service) sendOrgCancelledEmail(ctx context.Context, org *models.Organization, d *models.ScheduledDeletion) {
if s.notifier == nil {
return
}
owner, _ := s.userRepo.GetUser(ctx, org.OwnerUserID)
if owner == nil {
recipients := s.orgRecipients(ctx, org)
if len(recipients) == 0 {
return
}
subject := fmt.Sprintf("Deletion cancelled for %s", org.Name)
body := orgCancelledHTML(org, d)
if err := s.notifier.Send(ctx, []string{owner.Email}, nil, nil, subject, body); err != nil {
sentry.CaptureException(err)
}
s.sendToEach(ctx, recipients, subject, body)
}
func (s *service) sendUserScheduledEmail(ctx context.Context, user *models.User, d *models.ScheduledDeletion) {
@@ -455,38 +451,36 @@ func (s *service) sendReminderEmail(ctx context.Context, d *models.ScheduledDele
return
}
to, subject, body := s.buildReminder(ctx, d, bit)
if to == "" {
recipients, subject, body := s.buildReminder(ctx, d, bit)
if len(recipients) == 0 {
return
}
if err := s.notifier.Send(ctx, []string{to}, nil, nil, subject, body); err != nil {
sentry.CaptureException(err)
}
s.sendToEach(ctx, recipients, subject, body)
}
func (s *service) buildReminder(ctx context.Context, d *models.ScheduledDeletion, bit int) (to, subject, body string) {
func (s *service) buildReminder(ctx context.Context, d *models.ScheduledDeletion, bit int) (recipients []string, subject, body string) {
var resourceName string
switch d.ResourceType {
case models.DeletionResourceOrganization:
org, _ := s.orgRepo.GetByID(ctx, d.ResourceID)
if org == nil {
return "", "", ""
return nil, "", ""
}
owner, _ := s.userRepo.GetUser(ctx, org.OwnerUserID)
if owner == nil {
return "", "", ""
}
to = owner.Email
recipients = s.orgRecipients(ctx, org)
resourceName = org.Name
case models.DeletionResourceUser:
user, _ := s.userRepo.GetUser(ctx, d.ResourceID)
if user == nil {
return "", "", ""
return nil, "", ""
}
to = user.Email
recipients = []string{user.Email}
resourceName = displayName(user)
default:
return "", "", ""
return nil, "", ""
}
if len(recipients) == 0 {
return nil, "", ""
}
switch bit {
@@ -499,7 +493,7 @@ func (s *service) buildReminder(ctx context.Context, d *models.ScheduledDeletion
}
body = reminderHTML(resourceName, d, s.frontendBaseURL)
return to, subject, body
return recipients, subject, body
}
func (s *service) sendCompletionEmail(ctx context.Context, d *models.ScheduledDeletion) {
@@ -550,3 +544,48 @@ func displayName(u *models.User) string {
}
return u.Email
}
// orgRecipients returns every member email for an org, owner first.
// Owner is always included even if GetMembers somehow fails to load
// them (defensive — the owner is the only person who can actually
// cancel, so they MUST get the email).
func (s *service) orgRecipients(ctx context.Context, org *models.Organization) []string {
seen := make(map[string]struct{}, 8)
out := make([]string, 0, 8)
if owner, _ := s.userRepo.GetUser(ctx, org.OwnerUserID); owner != nil && owner.Email != "" {
key := strings.ToLower(owner.Email)
seen[key] = struct{}{}
out = append(out, owner.Email)
}
members, err := s.orgRepo.GetMembers(ctx, org.ID)
if err != nil {
sentry.CaptureException(err)
return out
}
for i := range members {
u := members[i].User
if u == nil || u.Email == "" {
continue
}
key := strings.ToLower(u.Email)
if _, dup := seen[key]; dup {
continue
}
seen[key] = struct{}{}
out = append(out, u.Email)
}
return out
}
// sendToEach mails one-to-one rather than To/Cc/Bcc-fanning. Keeps
// every recipient unaware of the others' addresses, and makes per-
// person bounces/spam reports cleanly attributable.
func (s *service) sendToEach(ctx context.Context, recipients []string, subject, body string) {
for _, to := range recipients {
if err := s.notifier.Send(ctx, []string{to}, nil, nil, subject, body); err != nil {
sentry.CaptureException(err)
}
}
}
+6
View File
@@ -19,6 +19,7 @@ import { Outlet } from "react-router-dom";
import { SkyChrome } from "./SkyChrome";
import { AppHeader } from "./AppHeader";
import { AppNav } from "./AppNav";
import PendingDeletionBar from "./PendingDeletionBar";
import { RouteBoundary } from "./ErrorBoundary";
import { ShortcutsModal } from "@/components/shared/ShortcutsModal";
import { CommandPalette } from "@/components/shared/CommandPalette";
@@ -32,6 +33,11 @@ export function AppShell() {
<SkyChrome />
<div className="relative z-10 flex flex-col h-full">
{/* Sits above the header so it can't be missed. Only
renders when the current workspace or the user's
own account is scheduled for deletion. */}
<PendingDeletionBar />
<AppHeader />
<div className="flex-1 flex min-h-0">
@@ -0,0 +1,184 @@
// App-wide banner shown when the current workspace (or the user's own
// account) is scheduled for deletion. Sits above AppHeader so it's
// impossible to miss while clicking around the dashboard.
//
// Two banners can stack:
// 1. Workspace pending deletion (anyone in the org sees this)
// 2. Personal account pending deletion (only the user themselves)
//
// We deliberately don't make these dismissible — they need to nag.
import React from "react";
import { Link } from "react-router-dom";
import { AlertOctagonIcon, Loader2Icon, UndoIcon } from "lucide-react";
import toast from "react-hot-toast";
import useFeatureAccess from "@/hooks/useFeatureAccess";
import useOrganizationDangerZone from "@/lib/api/hooks/app/dangerzone/useOrganizationDangerZone";
import useAccountDangerZone from "@/lib/api/hooks/app/dangerzone/useAccountDangerZone";
import useCancelOrganizationDeletion from "@/lib/api/hooks/app/dangerzone/useCancelOrganizationDeletion";
import useCancelAccountDeletion from "@/lib/api/hooks/app/dangerzone/useCancelAccountDeletion";
import buildError from "@/lib/helper/buildError";
import type { AppError } from "@/lib/api/client/normalizeError";
// Re-render every minute so "X days Y hours" stays honest on a tab
// that's been open for hours. Cheap — single setState per banner.
function useMinuteTick() {
const [, setTick] = React.useState(0);
React.useEffect(() => {
const id = window.setInterval(() => setTick((t) => t + 1), 60_000);
return () => window.clearInterval(id);
}, []);
}
function formatRemaining(executeAfter: Date): string {
const ms = executeAfter.getTime() - Date.now();
if (ms <= 0) return "any moment now";
const totalMinutes = Math.floor(ms / 60_000);
const days = Math.floor(totalMinutes / (60 * 24));
const hours = Math.floor((totalMinutes % (60 * 24)) / 60);
const minutes = totalMinutes % 60;
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
export default function PendingDeletionBar() {
useMinuteTick();
const access = useFeatureAccess();
const org = useOrganizationDangerZone();
const account = useAccountDangerZone();
const cancelOrg = useCancelOrganizationDeletion();
const cancelAccount = useCancelAccountDeletion();
const orgPending = org.data?.pending_deletion;
const accountPending = account.data?.pending_deletion;
if (!orgPending && !accountPending) return null;
return (
<div className="relative z-20">
{orgPending && org.data && (
<Banner
title={
<>
This workspace will be permanently deleted in{" "}
<strong className="tabular-nums">
{formatRemaining(new Date(orgPending.execute_after))}
</strong>
.
</>
}
detail={
<>
<strong>{org.data.resource_name}</strong> is scheduled
for deletion on{" "}
{new Date(orgPending.execute_after).toLocaleString()}.
{access.isOwner
? " Cancel below to keep it."
: " Only the workspace owner can cancel — reach out to them now."}
</>
}
showCancel={access.isOwner}
onCancel={async () => {
try {
await cancelOrg.mutateAsync(undefined);
toast.success("Workspace deletion cancelled");
} catch (err) {
toast.error(buildError(err as AppError));
}
}}
cancelLoading={cancelOrg.isPending}
/>
)}
{accountPending && account.data && (
<Banner
title={
<>
Your account will be permanently deleted in{" "}
<strong className="tabular-nums">
{formatRemaining(new Date(accountPending.execute_after))}
</strong>
.
</>
}
detail={
<>
All workspaces you own and every campaign / contact /
mailbox in them will be removed on{" "}
{new Date(accountPending.execute_after).toLocaleString()}.
</>
}
showCancel={true}
onCancel={async () => {
try {
await cancelAccount.mutateAsync(undefined);
toast.success("Account deletion cancelled");
} catch (err) {
toast.error(buildError(err as AppError));
}
}}
cancelLoading={cancelAccount.isPending}
/>
)}
</div>
);
}
function Banner({
title,
detail,
showCancel,
onCancel,
cancelLoading,
}: {
title: React.ReactNode;
detail: React.ReactNode;
showCancel: boolean;
onCancel: () => void | Promise<void>;
cancelLoading: boolean;
}) {
return (
<div
role="alert"
className="bg-red-600 text-white px-4 py-2 flex items-center gap-3 border-b border-red-800/50 shadow-[0_1px_0_rgba(0,0,0,0.05)]"
>
<div className="size-5 rounded bg-white/15 flex items-center justify-center shrink-0">
<AlertOctagonIcon className="w-3 h-3" />
</div>
<div className="min-w-0 flex-1">
<div className="text-[12.5px] font-medium leading-tight">
{title}
</div>
<div className="text-[11.5px] text-white/85 leading-tight mt-0.5">
{detail}
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{showCancel && (
<button
type="button"
onClick={() => onCancel()}
disabled={cancelLoading}
className="h-7 px-2.5 rounded-md bg-white text-red-700 hover:bg-red-50 text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors disabled:opacity-70"
>
{cancelLoading ? (
<Loader2Icon className="w-3 h-3 animate-spin" />
) : (
<UndoIcon className="w-3 h-3" />
)}
Cancel deletion
</button>
)}
<Link
to="/app/settings/danger"
className="h-7 px-2.5 rounded-md bg-red-700 hover:bg-red-800 text-white text-[12px] font-medium inline-flex items-center transition-colors"
>
Settings
</Link>
</div>
</div>
);
}
@@ -1,10 +1,16 @@
import { useQuery } from "@tanstack/react-query";
import getOrganizationDangerZone from "@/lib/api/client/app/dangerzone/getOrganizationDangerZone";
import { useCurrentOrg } from "@/stores/useAppStore";
// Gated on having an org selected — otherwise the endpoint 400s and
// react-query would stamp the cache with a useless error that the
// global banner has to special-case around.
export default function useOrganizationDangerZone() {
const org = useCurrentOrg();
return useQuery({
queryKey: ["dangerzone", "organization"],
queryKey: ["dangerzone", "organization", org?.id ?? null],
queryFn: () => getOrganizationDangerZone(),
staleTime: 30_000,
enabled: !!org,
});
}