mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-19 00:01:14 +00:00
feat: app-wide pending-deletion banner + email every org member
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user