fix(portal): security hardening for pilot readiness
- C1: Rewrite /api/usage to resolve teamId server-side from tenant CR; customers can no longer pass arbitrary teamId (IDOR fix) - C2: Remove POST /api/tenants — tenants are only created via admin approval flow - H1: Validate packages against catalog, workspaceFiles against allowlist, and field lengths in PATCH /api/tenants/[name] - H2: Remove full ZITADEL profile claims logging from JWT callback - H3: Add safeError() utility; sanitize all error responses to clients, toggle raw errors via PORTAL_DEBUG_ERRORS=true - H4/H5: Escape HTML entities in all email templates (contactName, companyName, adminNotes)
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
|||||||
getDefaultAgentsMd,
|
getDefaultAgentsMd,
|
||||||
generateToolsMd,
|
generateToolsMd,
|
||||||
} from "@/lib/workspace-defaults";
|
} from "@/lib/workspace-defaults";
|
||||||
|
import { safeError } from "@/lib/errors";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/admin/requests/[id]/approve
|
* POST /api/admin/requests/[id]/approve
|
||||||
@@ -133,7 +134,7 @@ export async function POST(
|
|||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error("Failed to create tenant:", e);
|
console.error("Failed to create tenant:", e);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: `Failed to create tenant: ${e.message}` },
|
{ error: safeError(e, "Failed to create tenant") },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
|||||||
import { requirePlatformRole } from "@/lib/session";
|
import { requirePlatformRole } from "@/lib/session";
|
||||||
import { getTenant, deleteTenant } from "@/lib/k8s";
|
import { getTenant, deleteTenant } from "@/lib/k8s";
|
||||||
import { markTenantRequestDeletedByTenantName } from "@/lib/db";
|
import { markTenantRequestDeletedByTenantName } from "@/lib/db";
|
||||||
|
import { safeError } from "@/lib/errors";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/admin/tenants/[name]/delete
|
* POST /api/admin/tenants/[name]/delete
|
||||||
@@ -42,7 +43,7 @@ export async function POST(
|
|||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error("Failed to delete tenant:", e);
|
console.error("Failed to delete tenant:", e);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: `Failed to delete tenant: ${e.message}` },
|
{ error: safeError(e, "Failed to delete tenant") },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requirePlatformRole } from "@/lib/session";
|
import { requirePlatformRole } from "@/lib/session";
|
||||||
import { getTenant, patchTenantSpec } from "@/lib/k8s";
|
import { getTenant, patchTenantSpec } from "@/lib/k8s";
|
||||||
|
import { safeError } from "@/lib/errors";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/admin/tenants/[name]/suspend
|
* POST /api/admin/tenants/[name]/suspend
|
||||||
@@ -35,7 +36,7 @@ export async function POST(
|
|||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error("Failed to update tenant suspend state:", e);
|
console.error("Failed to update tenant suspend state:", e);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: `Failed to update tenant: ${e.message}` },
|
{ error: safeError(e, "Failed to update tenant") },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getSessionUser } from "@/lib/session";
|
import { getSessionUser } from "@/lib/session";
|
||||||
import { getTenant, patchTenantSpec } from "@/lib/k8s";
|
import { getTenant, patchTenantSpec } from "@/lib/k8s";
|
||||||
|
import { getPackageDef } from "@/lib/packages";
|
||||||
|
import { safeError } from "@/lib/errors";
|
||||||
|
|
||||||
|
const ALLOWED_WORKSPACE_FILES = ["SOUL.md", "AGENTS.md", "TOOLS.md"];
|
||||||
|
const MAX_WORKSPACE_FILE_SIZE = 10_000;
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_req: NextRequest,
|
_req: NextRequest,
|
||||||
@@ -27,7 +32,7 @@ export async function GET(
|
|||||||
return NextResponse.json(tenant);
|
return NextResponse.json(tenant);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: e.message },
|
{ error: safeError(e, "Failed to fetch tenant") },
|
||||||
{ status: e.statusCode || 500 }
|
{ status: e.statusCode || 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -61,20 +66,130 @@ export async function PATCH(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const specPatch: Record<string, any> = {};
|
const specPatch: Record<string, any> = {};
|
||||||
if (body.packages !== undefined) specPatch.packages = body.packages;
|
|
||||||
if (body.workspaceFiles !== undefined)
|
// ── Validate packages against catalog ──
|
||||||
|
if (body.packages !== undefined) {
|
||||||
|
if (!Array.isArray(body.packages) || body.packages.length > 10) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid packages: must be an array of at most 10 items" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const pkg of body.packages) {
|
||||||
|
if (typeof pkg !== "string" || !getPackageDef(pkg)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Unknown package: ${pkg}` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
specPatch.packages = body.packages;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Validate workspaceFiles ──
|
||||||
|
if (body.workspaceFiles !== undefined) {
|
||||||
|
if (
|
||||||
|
typeof body.workspaceFiles !== "object" ||
|
||||||
|
body.workspaceFiles === null ||
|
||||||
|
Array.isArray(body.workspaceFiles)
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid workspaceFiles: must be an object" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const [key, value] of Object.entries(body.workspaceFiles)) {
|
||||||
|
if (!ALLOWED_WORKSPACE_FILES.includes(key)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: `Invalid workspace file: ${key}. Allowed: ${ALLOWED_WORKSPACE_FILES.join(", ")}`,
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof value !== "string" ||
|
||||||
|
value.length > MAX_WORKSPACE_FILE_SIZE
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: `Workspace file ${key} must be a string of at most ${MAX_WORKSPACE_FILE_SIZE} characters`,
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
specPatch.workspaceFiles = body.workspaceFiles;
|
specPatch.workspaceFiles = body.workspaceFiles;
|
||||||
if (body.displayName !== undefined)
|
}
|
||||||
|
|
||||||
|
// ── Simple string fields ──
|
||||||
|
if (body.displayName !== undefined) {
|
||||||
|
if (
|
||||||
|
typeof body.displayName !== "string" ||
|
||||||
|
body.displayName.length < 1 ||
|
||||||
|
body.displayName.length > 100
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "displayName must be 1-100 characters" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
specPatch.displayName = body.displayName;
|
specPatch.displayName = body.displayName;
|
||||||
if (body.agentName !== undefined) specPatch.agentName = body.agentName;
|
}
|
||||||
if (body.channelUsers !== undefined)
|
|
||||||
|
if (body.agentName !== undefined) {
|
||||||
|
if (
|
||||||
|
typeof body.agentName !== "string" ||
|
||||||
|
body.agentName.length < 1 ||
|
||||||
|
body.agentName.length > 50
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "agentName must be 1-50 characters" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
specPatch.agentName = body.agentName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── channelUsers (basic shape validation) ──
|
||||||
|
if (body.channelUsers !== undefined) {
|
||||||
|
if (
|
||||||
|
typeof body.channelUsers !== "object" ||
|
||||||
|
body.channelUsers === null ||
|
||||||
|
Array.isArray(body.channelUsers)
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid channelUsers: must be an object" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const [channel, users] of Object.entries(body.channelUsers)) {
|
||||||
|
if (typeof channel !== "string" || channel.length > 50) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Invalid channel name: ${channel}` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!Array.isArray(users) ||
|
||||||
|
(users as any[]).some(
|
||||||
|
(u: any) => typeof u !== "string" || u.length > 100
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Invalid user IDs for channel ${channel}` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
specPatch.channelUsers = body.channelUsers;
|
specPatch.channelUsers = body.channelUsers;
|
||||||
|
}
|
||||||
|
|
||||||
const updated = await patchTenantSpec(name, specPatch);
|
const updated = await patchTenantSpec(name, specPatch);
|
||||||
return NextResponse.json(updated);
|
return NextResponse.json(updated);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: e.message },
|
{ error: safeError(e, "Failed to update tenant") },
|
||||||
{ status: e.statusCode || 500 }
|
{ status: e.statusCode || 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getSessionUser } from "@/lib/session";
|
import { getSessionUser } from "@/lib/session";
|
||||||
import { listTenants, getTenant, createTenant } from "@/lib/k8s";
|
import { listTenants } from "@/lib/k8s";
|
||||||
import type { PiecedTenantSpec } from "@/types";
|
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const user = await getSessionUser();
|
const user = await getSessionUser();
|
||||||
@@ -20,37 +19,3 @@ export async function GET() {
|
|||||||
);
|
);
|
||||||
return NextResponse.json(own);
|
return NextResponse.json(own);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
const user = await getSessionUser();
|
|
||||||
if (!user)
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
if (!user.isPlatform && !user.roles.includes("owner")) {
|
|
||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = (await request.json()) as {
|
|
||||||
name: string;
|
|
||||||
spec: PiecedTenantSpec;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(body.name) || body.name.length > 63) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Invalid tenant name: lowercase alphanumeric and hyphens, 2-63 chars" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = await getTenant(body.name);
|
|
||||||
if (existing) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Tenant already exists" },
|
|
||||||
{ status: 409 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tenant = await createTenant(body.name, body.spec, {
|
|
||||||
"pieced.ch/zitadel-org-id": user.orgId,
|
|
||||||
});
|
|
||||||
return NextResponse.json(tenant, { status: 201 });
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,20 +1,49 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getSessionUser } from "@/lib/session";
|
import { getSessionUser } from "@/lib/session";
|
||||||
|
import { listTenants } from "@/lib/k8s";
|
||||||
import { getTeamInfo, getTeamSpendLogsV2 } from "@/lib/litellm";
|
import { getTeamInfo, getTeamSpendLogsV2 } from "@/lib/litellm";
|
||||||
|
import { safeError } from "@/lib/errors";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/usage
|
||||||
|
*
|
||||||
|
* Customers: teamId is resolved server-side from the tenant matching the
|
||||||
|
* user's orgId. No client-supplied teamId accepted.
|
||||||
|
* Platform admins: may pass ?teamId=... to inspect any tenant's usage.
|
||||||
|
*/
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const user = await getSessionUser();
|
const user = await getSessionUser();
|
||||||
if (!user)
|
if (!user)
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const teamId = req.nextUrl.searchParams.get("teamId");
|
let teamId: string | null = null;
|
||||||
if (!teamId)
|
|
||||||
return NextResponse.json({ error: "teamId required" }, { status: 400 });
|
if (user.isPlatform) {
|
||||||
|
// Admins may pass a specific teamId to inspect any tenant
|
||||||
|
teamId = req.nextUrl.searchParams.get("teamId") ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For customers (or admins without explicit teamId): resolve from their tenant
|
||||||
|
if (!teamId) {
|
||||||
|
const tenants = await listTenants();
|
||||||
|
const orgTenant = tenants.find(
|
||||||
|
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!orgTenant?.status?.litellmTeamId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "No active tenant found for your organization" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
teamId = orgTenant.status.litellmTeamId;
|
||||||
|
}
|
||||||
|
|
||||||
// Month param: YYYY-MM, defaults to current month
|
// Month param: YYYY-MM, defaults to current month
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const monthParam = req.nextUrl.searchParams.get("month")
|
const monthParam =
|
||||||
|| `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
req.nextUrl.searchParams.get("month") ||
|
||||||
|
`${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||||
|
|
||||||
const [year, month] = monthParam.split("-").map(Number);
|
const [year, month] = monthParam.split("-").map(Number);
|
||||||
const startDate = new Date(year, month - 1, 1);
|
const startDate = new Date(year, month - 1, 1);
|
||||||
@@ -30,18 +59,28 @@ export async function GET(req: NextRequest) {
|
|||||||
const allRequests: any[] = [];
|
const allRequests: any[] = [];
|
||||||
let page = 1;
|
let page = 1;
|
||||||
while (true) {
|
while (true) {
|
||||||
const result = await getTeamSpendLogsV2(teamId, startStr, endStr, page, 100);
|
const result = await getTeamSpendLogsV2(
|
||||||
|
teamId,
|
||||||
|
startStr,
|
||||||
|
endStr,
|
||||||
|
page,
|
||||||
|
100
|
||||||
|
);
|
||||||
allRequests.push(...(result.data || []));
|
allRequests.push(...(result.data || []));
|
||||||
if (page >= (result.total_pages || 1)) break;
|
if (page >= (result.total_pages || 1)) break;
|
||||||
page++;
|
page++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Aggregate by day
|
// Aggregate by day
|
||||||
const byDay: Record<string, { inputTokens: number; outputTokens: number; spend: number }> = {};
|
const byDay: Record<
|
||||||
|
string,
|
||||||
|
{ inputTokens: number; outputTokens: number; spend: number }
|
||||||
|
> = {};
|
||||||
for (const r of allRequests) {
|
for (const r of allRequests) {
|
||||||
const day = (r.startTime || r.endTime || "").slice(0, 10);
|
const day = (r.startTime || r.endTime || "").slice(0, 10);
|
||||||
if (!day) continue;
|
if (!day) continue;
|
||||||
if (!byDay[day]) byDay[day] = { inputTokens: 0, outputTokens: 0, spend: 0 };
|
if (!byDay[day])
|
||||||
|
byDay[day] = { inputTokens: 0, outputTokens: 0, spend: 0 };
|
||||||
byDay[day].inputTokens += r.prompt_tokens || 0;
|
byDay[day].inputTokens += r.prompt_tokens || 0;
|
||||||
byDay[day].outputTokens += r.completion_tokens || 0;
|
byDay[day].outputTokens += r.completion_tokens || 0;
|
||||||
byDay[day].spend += r.spend || 0;
|
byDay[day].spend += r.spend || 0;
|
||||||
@@ -51,8 +90,14 @@ export async function GET(req: NextRequest) {
|
|||||||
.sort(([a], [b]) => a.localeCompare(b))
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
.map(([date, d]) => ({ date, ...d }));
|
.map(([date, d]) => ({ date, ...d }));
|
||||||
|
|
||||||
const totalInput = allRequests.reduce((s, r) => s + (r.prompt_tokens || 0), 0);
|
const totalInput = allRequests.reduce(
|
||||||
const totalOutput = allRequests.reduce((s, r) => s + (r.completion_tokens || 0), 0);
|
(s, r) => s + (r.prompt_tokens || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const totalOutput = allRequests.reduce(
|
||||||
|
(s, r) => s + (r.completion_tokens || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
const totalSpend = allRequests.reduce((s, r) => s + (r.spend || 0), 0);
|
const totalSpend = allRequests.reduce((s, r) => s + (r.spend || 0), 0);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -79,6 +124,9 @@ export async function GET(req: NextRequest) {
|
|||||||
});
|
});
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error("Usage fetch error:", e.message);
|
console.error("Usage fetch error:", e.message);
|
||||||
return NextResponse.json({ error: "Failed to fetch usage" }, { status: 500 });
|
return NextResponse.json(
|
||||||
|
{ error: safeError(e, "Failed to fetch usage") },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ export const authConfig: NextAuthConfig = {
|
|||||||
callbacks: {
|
callbacks: {
|
||||||
async jwt({ token, account, profile }) {
|
async jwt({ token, account, profile }) {
|
||||||
if (account && profile) {
|
if (account && profile) {
|
||||||
console.log("ZITADEL profile claims:", JSON.stringify(profile, null, 2));
|
|
||||||
const claims = profile as unknown as ZitadelClaims;
|
const claims = profile as unknown as ZitadelClaims;
|
||||||
token.orgId = claims["urn:zitadel:iam:user:resourceowner:id"];
|
token.orgId = claims["urn:zitadel:iam:user:resourceowner:id"];
|
||||||
token.orgName = claims["urn:zitadel:iam:user:resourceowner:name"];
|
token.orgName = claims["urn:zitadel:iam:user:resourceowner:name"];
|
||||||
|
|||||||
@@ -42,11 +42,26 @@ function getFrom(): string {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape HTML entities to prevent injection in HTML emails.
|
||||||
|
*/
|
||||||
|
function escapeHtml(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendApprovalEmail(
|
export async function sendApprovalEmail(
|
||||||
to: string,
|
to: string,
|
||||||
contactName: string,
|
contactName: string,
|
||||||
companyName: string
|
companyName: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const safeName = escapeHtml(contactName);
|
||||||
|
const safeCompany = escapeHtml(companyName);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await getTransporter().sendMail({
|
await getTransporter().sendMail({
|
||||||
from: getFrom(),
|
from: getFrom(),
|
||||||
@@ -68,8 +83,8 @@ export async function sendApprovalEmail(
|
|||||||
html: `
|
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;">
|
<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 is being set up</h2>
|
<h2 style="color: #ffffff; margin-top: 0;">Your AI assistant is being set up</h2>
|
||||||
<p>Hello ${contactName},</p>
|
<p>Hello ${safeName},</p>
|
||||||
<p>Great news! Your onboarding request for <strong>${companyName}</strong> has been approved.</p>
|
<p>Great news! Your onboarding request for <strong>${safeCompany}</strong> has been approved.</p>
|
||||||
<p>Your AI assistant instance is now being provisioned. This usually takes a few minutes.</p>
|
<p>Your AI assistant instance is now being provisioned. This usually takes a few minutes.</p>
|
||||||
<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;">
|
<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;">
|
||||||
@@ -95,14 +110,18 @@ export async function sendRejectionEmail(
|
|||||||
companyName: string,
|
companyName: string,
|
||||||
adminNotes?: string
|
adminNotes?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const safeName = escapeHtml(contactName);
|
||||||
|
const safeCompany = escapeHtml(companyName);
|
||||||
|
const safeNotes = adminNotes ? escapeHtml(adminNotes) : "";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const notesBlock = adminNotes
|
const notesBlock = adminNotes
|
||||||
? `\nNote from our team:\n${adminNotes}\n`
|
? `\nNote from our team:\n${adminNotes}\n`
|
||||||
: "";
|
: "";
|
||||||
const notesHtml = adminNotes
|
const notesHtml = safeNotes
|
||||||
? `<div style="background: #2a2a2a; border-left: 3px solid #ef4444; padding: 12px 16px; border-radius: 6px; margin: 16px 0;">
|
? `<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: #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;">${adminNotes}</p>
|
<p style="color: #aaa; font-size: 13px; margin: 8px 0 0 0;">${safeNotes}</p>
|
||||||
</div>`
|
</div>`
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
@@ -123,8 +142,8 @@ export async function sendRejectionEmail(
|
|||||||
html: `
|
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;">
|
<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 onboarding request</h2>
|
<h2 style="color: #ffffff; margin-top: 0;">Update on your onboarding request</h2>
|
||||||
<p>Hello ${contactName},</p>
|
<p>Hello ${safeName},</p>
|
||||||
<p>Thank you for your interest in PieCed IT. Unfortunately, we were unable to approve your onboarding request for <strong>${companyName}</strong> at this time.</p>
|
<p>Thank you for your interest in PieCed IT. Unfortunately, we were unable to approve your onboarding request for <strong>${safeCompany}</strong> at this time.</p>
|
||||||
${notesHtml}
|
${notesHtml}
|
||||||
<p>If you have questions or would like to discuss this further, please reply to this email.</p>
|
<p>If you have questions or would like to discuss this further, please reply to this email.</p>
|
||||||
<hr style="border: none; border-top: 1px solid #333; margin: 24px 0;" />
|
<hr style="border: none; border-top: 1px solid #333; margin: 24px 0;" />
|
||||||
@@ -145,6 +164,10 @@ export async function sendAdminNotificationEmail(
|
|||||||
const adminEmail = process.env.ADMIN_NOTIFICATION_EMAIL;
|
const adminEmail = process.env.ADMIN_NOTIFICATION_EMAIL;
|
||||||
if (!adminEmail) return;
|
if (!adminEmail) return;
|
||||||
|
|
||||||
|
const safeCompany = escapeHtml(companyName);
|
||||||
|
const safeName = escapeHtml(contactName);
|
||||||
|
const safeEmail = escapeHtml(contactEmail);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await getTransporter().sendMail({
|
await getTransporter().sendMail({
|
||||||
from: getFrom(),
|
from: getFrom(),
|
||||||
@@ -158,6 +181,23 @@ export async function sendAdminNotificationEmail(
|
|||||||
"",
|
"",
|
||||||
`Review it at https://app.pieced.ch/admin`,
|
`Review it at https://app.pieced.ch/admin`,
|
||||||
].join("\n"),
|
].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;">New onboarding request</h2>
|
||||||
|
<p>A new onboarding request has been submitted.</p>
|
||||||
|
<table style="color: #ccc; font-size: 14px; margin: 16px 0;">
|
||||||
|
<tr><td style="padding: 4px 12px 4px 0; color: #888;">Company:</td><td>${safeCompany}</td></tr>
|
||||||
|
<tr><td style="padding: 4px 12px 4px 0; color: #888;">Contact:</td><td>${safeName} (${safeEmail})</td></tr>
|
||||||
|
</table>
|
||||||
|
<p>
|
||||||
|
<a href="https://app.pieced.ch/admin" style="display: inline-block; padding: 10px 24px; background: #3b82f6; color: #ffffff; text-decoration: none; border-radius: 8px; font-weight: 500;">
|
||||||
|
Review Request
|
||||||
|
</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) {
|
} catch (err) {
|
||||||
console.error("Failed to send admin notification email:", err);
|
console.error("Failed to send admin notification email:", err);
|
||||||
|
|||||||
37
src/lib/errors.ts
Normal file
37
src/lib/errors.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Error sanitization for API responses.
|
||||||
|
*
|
||||||
|
* By default, returns a generic message to the client and logs the full
|
||||||
|
* error server-side. Set PORTAL_DEBUG_ERRORS=true to return the raw
|
||||||
|
* error message to the client (useful during development/debugging).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DEBUG_ERRORS = process.env.PORTAL_DEBUG_ERRORS === "true";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a safe error string for API responses.
|
||||||
|
*
|
||||||
|
* - In debug mode (PORTAL_DEBUG_ERRORS=true): returns the raw e.message
|
||||||
|
* - In production mode: returns the fallback string and logs the real error
|
||||||
|
*
|
||||||
|
* Recognises common HTTP status codes from k8s/vault errors and returns
|
||||||
|
* appropriate short messages even in production mode.
|
||||||
|
*/
|
||||||
|
export function safeError(e: unknown, fallback: string): string {
|
||||||
|
const err = e instanceof Error ? e : new Error(String(e));
|
||||||
|
const statusCode = (err as any).statusCode as number | undefined;
|
||||||
|
|
||||||
|
if (DEBUG_ERRORS) {
|
||||||
|
return err.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map well-known status codes to safe messages
|
||||||
|
if (statusCode === 404) return "Not found";
|
||||||
|
if (statusCode === 403) return "Forbidden";
|
||||||
|
if (statusCode === 409) return "Conflict";
|
||||||
|
if (statusCode === 401) return "Unauthorized";
|
||||||
|
|
||||||
|
// Log full error server-side, return generic to client
|
||||||
|
console.error(`${fallback}:`, err.message);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user