From fceeddf5ea38d7faabba1273c7417f3521ffe77c Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Sun, 24 May 2026 04:35:57 +0000 Subject: [PATCH] feat: app-wide pending-deletion banner + email every org member --- internal/app/dangerzone/service.go | 91 ++++++--- web/src/components/layout/AppShell.tsx | 6 + .../components/layout/PendingDeletionBar.tsx | 184 ++++++++++++++++++ .../dangerzone/useOrganizationDangerZone.ts | 8 +- 4 files changed, 262 insertions(+), 27 deletions(-) create mode 100644 web/src/components/layout/PendingDeletionBar.tsx diff --git a/internal/app/dangerzone/service.go b/internal/app/dangerzone/service.go index d8aa0c1c..5c2e33c8 100644 --- a/internal/app/dangerzone/service.go +++ b/internal/app/dangerzone/service.go @@ -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) + } + } +} diff --git a/web/src/components/layout/AppShell.tsx b/web/src/components/layout/AppShell.tsx index ca712fca..ddc6758f 100644 --- a/web/src/components/layout/AppShell.tsx +++ b/web/src/components/layout/AppShell.tsx @@ -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() {
+ {/* 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. */} + +
diff --git a/web/src/components/layout/PendingDeletionBar.tsx b/web/src/components/layout/PendingDeletionBar.tsx new file mode 100644 index 00000000..1c2d331a --- /dev/null +++ b/web/src/components/layout/PendingDeletionBar.tsx @@ -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 ( +
+ {orgPending && org.data && ( + + This workspace will be permanently deleted in{" "} + + {formatRemaining(new Date(orgPending.execute_after))} + + . + + } + detail={ + <> + {org.data.resource_name} 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 && ( + + Your account will be permanently deleted in{" "} + + {formatRemaining(new Date(accountPending.execute_after))} + + . + + } + 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} + /> + )} +
+ ); +} + +function Banner({ + title, + detail, + showCancel, + onCancel, + cancelLoading, +}: { + title: React.ReactNode; + detail: React.ReactNode; + showCancel: boolean; + onCancel: () => void | Promise; + cancelLoading: boolean; +}) { + return ( +
+
+ +
+
+
+ {title} +
+
+ {detail} +
+
+
+ {showCancel && ( + + )} + + Settings + +
+
+ ); +} diff --git a/web/src/lib/api/hooks/app/dangerzone/useOrganizationDangerZone.ts b/web/src/lib/api/hooks/app/dangerzone/useOrganizationDangerZone.ts index 04307acb..f433c14a 100644 --- a/web/src/lib/api/hooks/app/dangerzone/useOrganizationDangerZone.ts +++ b/web/src/lib/api/hooks/app/dangerzone/useOrganizationDangerZone.ts @@ -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, }); }