Show suspended since and new emails for suspend continue approve and rejection
All checks were successful
Build and Push / build (push) Successful in 1m26s

This commit is contained in:
2026-05-01 22:37:23 +02:00
parent 647afcfbe7
commit 46369fda01
9 changed files with 211 additions and 15 deletions

View File

@@ -142,6 +142,53 @@ export default async function TenantDetailPage({
<div className="text-xs text-text-secondary mt-1">
{t("suspendedDescription")}
</div>
{/* Retention countdown. suspendedAt is stamped by the
operator on first transition to suspended; missing
values fall through silently rather than rendering
garbage (operator hasn't reconciled yet, edge case).
The 60-day window is the operator's
retentionAfterSuspend constant; if you change one,
change both. We don't expose the constant via API —
the value rarely changes and duplicating it here
beats fetching a single int over the network. */}
{tenant.status?.suspendedAt && (() => {
const suspendedAt = new Date(tenant.status.suspendedAt);
const deletionAt = new Date(suspendedAt);
deletionAt.setDate(deletionAt.getDate() + 60);
const now = new Date();
const msRemaining = deletionAt.getTime() - now.getTime();
const daysRemaining = Math.max(
0,
Math.ceil(msRemaining / (1000 * 60 * 60 * 24))
);
// < 7 days: red/critical to draw attention. Otherwise
// amber, matching the banner.
const urgent = daysRemaining < 7;
return (
<div
className={`text-xs mt-2 ${
urgent ? "text-red-400" : "text-text-muted"
}`}
>
{t("suspendedSince", {
date: formatDateTime(
tenant.status.suspendedAt,
f
),
})}
{" · "}
{daysRemaining > 0
? t("suspendedDeletionIn", {
days: daysRemaining,
date: formatDateTime(
deletionAt.toISOString(),
f
),
})
: t("suspendedDeletionImminent")}
</div>
);
})()}
</div>
</div>
</div>

View File

@@ -6,7 +6,7 @@ import {
clearEncryptedSecrets,
} from "@/lib/db";
import { createTenant, patchTenantSpec, setTenantAnnotation } from "@/lib/k8s";
import { sendApprovalEmail } from "@/lib/email";
import { sendApprovalEmail, sendResumeApprovalEmail } from "@/lib/email";
import { decryptSecrets } from "@/lib/crypto";
import { writePackageSecrets } from "@/lib/openbao";
import {
@@ -105,11 +105,11 @@ export async function POST(
await updateTenantRequestStatus(id, "approved", { adminNotes });
await sendApprovalEmail(
await sendResumeApprovalEmail(
tenantRequest.contactEmail,
tenantRequest.contactName,
tenantRequest.companyName
).catch((e) => console.error("approval email failed:", e));
).catch((e) => console.error("resume approval email failed:", e));
return NextResponse.json({
message: "Resume approved. Tenant is reactivating.",

View File

@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
import { requirePlatformRole } from "@/lib/session";
import { getTenantRequestById, updateTenantRequestStatus } from "@/lib/db";
import { setTenantAnnotation } from "@/lib/k8s";
import { sendRejectionEmail } from "@/lib/email";
import { sendRejectionEmail, sendResumeRejectionEmail } from "@/lib/email";
/**
* POST /api/admin/requests/[id]/reject
@@ -65,13 +65,25 @@ export async function POST(
}
}
// Notify customer
await sendRejectionEmail(
tenantRequest.contactEmail,
tenantRequest.contactName,
tenantRequest.companyName,
adminNotes
);
// Notify customer. Resume requests get a different email — the
// tenant already exists; copy needs to mention "stays suspended" and
// the 60-day retention deadline. Provision rejections use the
// original onboarding-rejection wording.
if (tenantRequest.requestType === "resume") {
await sendResumeRejectionEmail(
tenantRequest.contactEmail,
tenantRequest.contactName,
tenantRequest.companyName,
adminNotes
);
} else {
await sendRejectionEmail(
tenantRequest.contactEmail,
tenantRequest.contactName,
tenantRequest.companyName,
adminNotes
);
}
return NextResponse.json({
message: "Request rejected.",