Show suspended since and new emails for suspend continue approve and rejection
All checks were successful
Build and Push / build (push) Successful in 1m26s
All checks were successful
Build and Push / build (push) Successful in 1m26s
This commit is contained in:
@@ -142,6 +142,53 @@ export default async function TenantDetailPage({
|
|||||||
<div className="text-xs text-text-secondary mt-1">
|
<div className="text-xs text-text-secondary mt-1">
|
||||||
{t("suspendedDescription")}
|
{t("suspendedDescription")}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
clearEncryptedSecrets,
|
clearEncryptedSecrets,
|
||||||
} from "@/lib/db";
|
} from "@/lib/db";
|
||||||
import { createTenant, patchTenantSpec, setTenantAnnotation } from "@/lib/k8s";
|
import { createTenant, patchTenantSpec, setTenantAnnotation } from "@/lib/k8s";
|
||||||
import { sendApprovalEmail } from "@/lib/email";
|
import { sendApprovalEmail, sendResumeApprovalEmail } from "@/lib/email";
|
||||||
import { decryptSecrets } from "@/lib/crypto";
|
import { decryptSecrets } from "@/lib/crypto";
|
||||||
import { writePackageSecrets } from "@/lib/openbao";
|
import { writePackageSecrets } from "@/lib/openbao";
|
||||||
import {
|
import {
|
||||||
@@ -105,11 +105,11 @@ export async function POST(
|
|||||||
|
|
||||||
await updateTenantRequestStatus(id, "approved", { adminNotes });
|
await updateTenantRequestStatus(id, "approved", { adminNotes });
|
||||||
|
|
||||||
await sendApprovalEmail(
|
await sendResumeApprovalEmail(
|
||||||
tenantRequest.contactEmail,
|
tenantRequest.contactEmail,
|
||||||
tenantRequest.contactName,
|
tenantRequest.contactName,
|
||||||
tenantRequest.companyName
|
tenantRequest.companyName
|
||||||
).catch((e) => console.error("approval email failed:", e));
|
).catch((e) => console.error("resume approval email failed:", e));
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
message: "Resume approved. Tenant is reactivating.",
|
message: "Resume approved. Tenant is reactivating.",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
|
|||||||
import { requirePlatformRole } from "@/lib/session";
|
import { requirePlatformRole } from "@/lib/session";
|
||||||
import { getTenantRequestById, updateTenantRequestStatus } from "@/lib/db";
|
import { getTenantRequestById, updateTenantRequestStatus } from "@/lib/db";
|
||||||
import { setTenantAnnotation } from "@/lib/k8s";
|
import { setTenantAnnotation } from "@/lib/k8s";
|
||||||
import { sendRejectionEmail } from "@/lib/email";
|
import { sendRejectionEmail, sendResumeRejectionEmail } from "@/lib/email";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/admin/requests/[id]/reject
|
* POST /api/admin/requests/[id]/reject
|
||||||
@@ -65,13 +65,25 @@ export async function POST(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify customer
|
// Notify customer. Resume requests get a different email — the
|
||||||
await sendRejectionEmail(
|
// tenant already exists; copy needs to mention "stays suspended" and
|
||||||
tenantRequest.contactEmail,
|
// the 60-day retention deadline. Provision rejections use the
|
||||||
tenantRequest.contactName,
|
// original onboarding-rejection wording.
|
||||||
tenantRequest.companyName,
|
if (tenantRequest.requestType === "resume") {
|
||||||
adminNotes
|
await sendResumeRejectionEmail(
|
||||||
);
|
tenantRequest.contactEmail,
|
||||||
|
tenantRequest.contactName,
|
||||||
|
tenantRequest.companyName,
|
||||||
|
adminNotes
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await sendRejectionEmail(
|
||||||
|
tenantRequest.contactEmail,
|
||||||
|
tenantRequest.contactName,
|
||||||
|
tenantRequest.companyName,
|
||||||
|
adminNotes
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
message: "Request rejected.",
|
message: "Request rejected.",
|
||||||
|
|||||||
115
src/lib/email.ts
115
src/lib/email.ts
@@ -156,6 +156,121 @@ export async function sendRejectionEmail(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bug 37a: separate email for resume request approval. The tenant
|
||||||
|
* already exists; the message is "we're un-suspending it" rather than
|
||||||
|
* "we're provisioning a new instance". Avoids confusing the customer
|
||||||
|
* with onboarding language for a tenant they already had.
|
||||||
|
*/
|
||||||
|
export async function sendResumeApprovalEmail(
|
||||||
|
to: string,
|
||||||
|
contactName: string,
|
||||||
|
companyName: string
|
||||||
|
): Promise<void> {
|
||||||
|
const safeName = escapeHtml(contactName);
|
||||||
|
const safeCompany = escapeHtml(companyName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await getTransporter().sendMail({
|
||||||
|
from: getFrom(),
|
||||||
|
to,
|
||||||
|
subject: `Your PieCed AI assistant has been reactivated — ${companyName}`,
|
||||||
|
text: [
|
||||||
|
`Hello ${contactName},`,
|
||||||
|
"",
|
||||||
|
`Good news — your reactivation request for ${companyName} has been approved.`,
|
||||||
|
"",
|
||||||
|
"Your AI assistant is being brought back online and should be ready in a few minutes.",
|
||||||
|
"You can check the status in your dashboard at https://app.pieced.ch",
|
||||||
|
"",
|
||||||
|
"Best regards,",
|
||||||
|
"PieCed IT",
|
||||||
|
].join("\n"),
|
||||||
|
html: `
|
||||||
|
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 560px; margin: 0 auto; color: #e0e0e0; background: #1a1a1a; padding: 32px; border-radius: 12px;">
|
||||||
|
<h2 style="color: #ffffff; margin-top: 0;">Your AI assistant has been reactivated</h2>
|
||||||
|
<p>Hello ${safeName},</p>
|
||||||
|
<p>Good news — your reactivation request for <strong>${safeCompany}</strong> has been approved.</p>
|
||||||
|
<p>Your AI assistant is being brought back online and should be ready in a few minutes.</p>
|
||||||
|
<p>
|
||||||
|
<a href="https://app.pieced.ch" style="display: inline-block; padding: 10px 24px; background: #3b82f6; color: #ffffff; text-decoration: none; border-radius: 8px; font-weight: 500;">
|
||||||
|
Go to Dashboard
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<hr style="border: none; border-top: 1px solid #333; margin: 24px 0;" />
|
||||||
|
<p style="color: #666; font-size: 12px;">PieCed IT — Hosted on-premises in Switzerland</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to send resume approval email:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bug 37a: separate email for resume request rejection. Differs from
|
||||||
|
* the onboarding rejection in two ways: it explicitly mentions the
|
||||||
|
* tenant remains suspended, and it points the customer to the
|
||||||
|
* 60-day retention window so they understand the deletion clock is
|
||||||
|
* still ticking. The latter is important — a customer reading a
|
||||||
|
* generic "request rejected" email might not realise their data is
|
||||||
|
* still on a countdown.
|
||||||
|
*/
|
||||||
|
export async function sendResumeRejectionEmail(
|
||||||
|
to: string,
|
||||||
|
contactName: string,
|
||||||
|
companyName: string,
|
||||||
|
adminNotes?: string
|
||||||
|
): Promise<void> {
|
||||||
|
const safeName = escapeHtml(contactName);
|
||||||
|
const safeCompany = escapeHtml(companyName);
|
||||||
|
const safeNotes = adminNotes ? escapeHtml(adminNotes) : "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const notesBlock = adminNotes
|
||||||
|
? `\nNote from our team:\n${adminNotes}\n`
|
||||||
|
: "";
|
||||||
|
const notesHtml = safeNotes
|
||||||
|
? `<div style="background: #2a2a2a; border-left: 3px solid #ef4444; padding: 12px 16px; border-radius: 6px; margin: 16px 0;">
|
||||||
|
<p style="color: #ccc; font-size: 13px; margin: 0;"><strong>Note from our team:</strong></p>
|
||||||
|
<p style="color: #aaa; font-size: 13px; margin: 8px 0 0 0;">${safeNotes}</p>
|
||||||
|
</div>`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
await getTransporter().sendMail({
|
||||||
|
from: getFrom(),
|
||||||
|
to,
|
||||||
|
subject: `Update on your reactivation request — ${companyName}`,
|
||||||
|
text: [
|
||||||
|
`Hello ${contactName},`,
|
||||||
|
"",
|
||||||
|
`Thank you for your reactivation request for ${companyName}. Unfortunately, we were unable to approve it at this time.`,
|
||||||
|
notesBlock,
|
||||||
|
"Your tenant remains suspended. As a reminder, your data is preserved for 60 days from the original cancellation date, after which it will be permanently deleted. You can submit a new reactivation request at any time before then.",
|
||||||
|
"",
|
||||||
|
"If you have questions, please reply to this email.",
|
||||||
|
"",
|
||||||
|
"Best regards,",
|
||||||
|
"PieCed IT",
|
||||||
|
].join("\n"),
|
||||||
|
html: `
|
||||||
|
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 560px; margin: 0 auto; color: #e0e0e0; background: #1a1a1a; padding: 32px; border-radius: 12px;">
|
||||||
|
<h2 style="color: #ffffff; margin-top: 0;">Update on your reactivation request</h2>
|
||||||
|
<p>Hello ${safeName},</p>
|
||||||
|
<p>Thank you for your reactivation request for <strong>${safeCompany}</strong>. Unfortunately, we were unable to approve it at this time.</p>
|
||||||
|
${notesHtml}
|
||||||
|
<p>Your tenant remains suspended. As a reminder, your data is preserved for 60 days from the original cancellation date, after which it will be permanently deleted. You can submit a new reactivation request at any time before then.</p>
|
||||||
|
<p>If you have questions, please reply to this email.</p>
|
||||||
|
<hr style="border: none; border-top: 1px solid #333; margin: 24px 0;" />
|
||||||
|
<p style="color: #666; font-size: 12px;">PieCed IT — Hosted on-premises in Switzerland</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to send resume rejection email:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendAdminNotificationEmail(
|
export async function sendAdminNotificationEmail(
|
||||||
companyName: string,
|
companyName: string,
|
||||||
contactName: string,
|
contactName: string,
|
||||||
|
|||||||
@@ -166,7 +166,10 @@
|
|||||||
"resumeRequestPendingTitle": "Reaktivierungsanfrage ausstehend",
|
"resumeRequestPendingTitle": "Reaktivierungsanfrage ausstehend",
|
||||||
"resumeRequestPendingDescription": "Eingereicht {when}. Ein Administrator wird die Anfrage in Kürze prüfen.",
|
"resumeRequestPendingDescription": "Eingereicht {when}. Ein Administrator wird die Anfrage in Kürze prüfen.",
|
||||||
"resumeRequestPendingNoteAdmin": "Ein Inhaber hat eine Reaktivierung angefragt; Sie können direkt oben fortfahren oder die Anfrage in der Admin-Warteschlange bearbeiten.",
|
"resumeRequestPendingNoteAdmin": "Ein Inhaber hat eine Reaktivierung angefragt; Sie können direkt oben fortfahren oder die Anfrage in der Admin-Warteschlange bearbeiten.",
|
||||||
"cancelConfirmRetentionWarning": "Ihre Daten bleiben nach der Kündigung 60 Tage lang erhalten. Danach werden alle Tenant-Daten – Konfiguration, Geheimnisse, Konversationen und Dateien – endgültig gelöscht."
|
"cancelConfirmRetentionWarning": "Ihre Daten bleiben nach der Kündigung 60 Tage lang erhalten. Danach werden alle Tenant-Daten – Konfiguration, Geheimnisse, Konversationen und Dateien – endgültig gelöscht.",
|
||||||
|
"suspendedSince": "Gekündigt am {date}",
|
||||||
|
"suspendedDeletionIn": "Datenlöschung in {days, plural, one {# Tag} other {# Tagen}} ({date})",
|
||||||
|
"suspendedDeletionImminent": "Daten werden jetzt gelöscht"
|
||||||
},
|
},
|
||||||
"usage": {
|
"usage": {
|
||||||
"inputTokens": "Input-Tokens",
|
"inputTokens": "Input-Tokens",
|
||||||
|
|||||||
@@ -166,7 +166,10 @@
|
|||||||
"resumeRequestPendingTitle": "Reactivation request pending",
|
"resumeRequestPendingTitle": "Reactivation request pending",
|
||||||
"resumeRequestPendingDescription": "Submitted {when}. An administrator will review it shortly.",
|
"resumeRequestPendingDescription": "Submitted {when}. An administrator will review it shortly.",
|
||||||
"resumeRequestPendingNoteAdmin": "An owner has requested reactivation; you can resume directly above or process the request from the admin queue.",
|
"resumeRequestPendingNoteAdmin": "An owner has requested reactivation; you can resume directly above or process the request from the admin queue.",
|
||||||
"cancelConfirmRetentionWarning": "Your data is preserved for 60 days after cancellation. After that, all tenant data — configuration, secrets, conversations, and files — will be permanently deleted."
|
"cancelConfirmRetentionWarning": "Your data is preserved for 60 days after cancellation. After that, all tenant data — configuration, secrets, conversations, and files — will be permanently deleted.",
|
||||||
|
"suspendedSince": "Suspended on {date}",
|
||||||
|
"suspendedDeletionIn": "data deletion in {days, plural, one {# day} other {# days}} ({date})",
|
||||||
|
"suspendedDeletionImminent": "data is being deleted now"
|
||||||
},
|
},
|
||||||
"usage": {
|
"usage": {
|
||||||
"inputTokens": "Input Tokens",
|
"inputTokens": "Input Tokens",
|
||||||
|
|||||||
@@ -166,7 +166,10 @@
|
|||||||
"resumeRequestPendingTitle": "Demande de réactivation en attente",
|
"resumeRequestPendingTitle": "Demande de réactivation en attente",
|
||||||
"resumeRequestPendingDescription": "Soumise {when}. Un administrateur l'examinera sous peu.",
|
"resumeRequestPendingDescription": "Soumise {when}. Un administrateur l'examinera sous peu.",
|
||||||
"resumeRequestPendingNoteAdmin": "Un propriétaire a demandé la réactivation ; vous pouvez reprendre directement ci-dessus ou traiter la demande depuis la file d'attente d'administration.",
|
"resumeRequestPendingNoteAdmin": "Un propriétaire a demandé la réactivation ; vous pouvez reprendre directement ci-dessus ou traiter la demande depuis la file d'attente d'administration.",
|
||||||
"cancelConfirmRetentionWarning": "Vos données sont conservées pendant 60 jours après l'annulation. Passé ce délai, toutes les données du locataire — configuration, secrets, conversations et fichiers — seront définitivement supprimées."
|
"cancelConfirmRetentionWarning": "Vos données sont conservées pendant 60 jours après l'annulation. Passé ce délai, toutes les données du locataire — configuration, secrets, conversations et fichiers — seront définitivement supprimées.",
|
||||||
|
"suspendedSince": "Suspendu le {date}",
|
||||||
|
"suspendedDeletionIn": "suppression des données dans {days, plural, one {# jour} other {# jours}} ({date})",
|
||||||
|
"suspendedDeletionImminent": "les données sont en cours de suppression"
|
||||||
},
|
},
|
||||||
"usage": {
|
"usage": {
|
||||||
"inputTokens": "Tokens d'entrée",
|
"inputTokens": "Tokens d'entrée",
|
||||||
|
|||||||
@@ -166,7 +166,10 @@
|
|||||||
"resumeRequestPendingTitle": "Richiesta di riattivazione in sospeso",
|
"resumeRequestPendingTitle": "Richiesta di riattivazione in sospeso",
|
||||||
"resumeRequestPendingDescription": "Inviata {when}. Un amministratore la esaminerà a breve.",
|
"resumeRequestPendingDescription": "Inviata {when}. Un amministratore la esaminerà a breve.",
|
||||||
"resumeRequestPendingNoteAdmin": "Un proprietario ha richiesto la riattivazione; puoi riprendere direttamente sopra o elaborare la richiesta dalla coda di amministrazione.",
|
"resumeRequestPendingNoteAdmin": "Un proprietario ha richiesto la riattivazione; puoi riprendere direttamente sopra o elaborare la richiesta dalla coda di amministrazione.",
|
||||||
"cancelConfirmRetentionWarning": "I tuoi dati sono conservati per 60 giorni dopo l'annullamento. Trascorso tale periodo, tutti i dati del tenant — configurazione, segreti, conversazioni e file — verranno eliminati definitivamente."
|
"cancelConfirmRetentionWarning": "I tuoi dati sono conservati per 60 giorni dopo l'annullamento. Trascorso tale periodo, tutti i dati del tenant — configurazione, segreti, conversazioni e file — verranno eliminati definitivamente.",
|
||||||
|
"suspendedSince": "Sospeso il {date}",
|
||||||
|
"suspendedDeletionIn": "eliminazione dei dati tra {days, plural, one {# giorno} other {# giorni}} ({date})",
|
||||||
|
"suspendedDeletionImminent": "i dati vengono eliminati ora"
|
||||||
},
|
},
|
||||||
"usage": {
|
"usage": {
|
||||||
"inputTokens": "Token di input",
|
"inputTokens": "Token di input",
|
||||||
|
|||||||
@@ -103,6 +103,16 @@ export interface PiecedTenantStatus {
|
|||||||
litellmKeyAlias?: string;
|
litellmKeyAlias?: string;
|
||||||
tenantNamespace?: string;
|
tenantNamespace?: string;
|
||||||
enabledPackages?: string[];
|
enabledPackages?: string[];
|
||||||
|
/**
|
||||||
|
* RFC3339 timestamp of when the tenant first transitioned to
|
||||||
|
* suspended (Bug 37). Stamped by the operator on the first reconcile
|
||||||
|
* with `spec.suspend=true` and cleared when the tenant resumes. Used
|
||||||
|
* by the portal to render the "deleted in N days" countdown in the
|
||||||
|
* suspended banner. The retention policy is 60 days from this
|
||||||
|
* timestamp; see operator's `retentionAfterSuspend` constant for the
|
||||||
|
* authoritative value.
|
||||||
|
*/
|
||||||
|
suspendedAt?: string;
|
||||||
/**
|
/**
|
||||||
* Non-fatal issues from downstream resources surfaced by the operator
|
* Non-fatal issues from downstream resources surfaced by the operator
|
||||||
* (e.g. an OpenClawInstance sub-condition reporting failure). The
|
* (e.g. an OpenClawInstance sub-condition reporting failure). The
|
||||||
|
|||||||
Reference in New Issue
Block a user