Compare commits
2 Commits
94bfd25553
...
97b483c121
| Author | SHA1 | Date | |
|---|---|---|---|
| 97b483c121 | |||
| 9a96d74f5c |
55
deploy/setup-smtp.sh
Normal file
55
deploy/setup-smtp.sh
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Session 6.4 — SMTP secret setup for PieCed Portal
|
||||||
|
#
|
||||||
|
# 1. Store SMTP credentials in OpenBao
|
||||||
|
# 2. Apply the ExternalSecret
|
||||||
|
# 3. Patch the portal deployment to mount the secret
|
||||||
|
#
|
||||||
|
# Prerequisites: bao CLI authenticated, kubectl context set
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# ─── Step 1: Store SMTP creds in OpenBao ───────────────────────────────────────
|
||||||
|
echo "==> Storing SMTP credentials in OpenBao..."
|
||||||
|
bao kv put pieced/portal/smtp \
|
||||||
|
host="smtp.gmail.com" \
|
||||||
|
port="587" \
|
||||||
|
user="noreply@pieced.ch" \
|
||||||
|
password="REPLACE_WITH_APP_PASSWORD" \
|
||||||
|
from="PieCed <noreply@pieced.ch>" \
|
||||||
|
admin_email="admin@pieced.ch"
|
||||||
|
|
||||||
|
echo "==> Verifying..."
|
||||||
|
bao kv get pieced/portal/smtp
|
||||||
|
|
||||||
|
# ─── Step 2: Apply ExternalSecret ──────────────────────────────────────────────
|
||||||
|
echo "==> Applying ExternalSecret..."
|
||||||
|
kubectl apply -f deploy/portal-smtp-externalsecret.yaml
|
||||||
|
|
||||||
|
echo "==> Waiting for ExternalSecret to sync..."
|
||||||
|
kubectl wait --for=condition=Ready externalsecret/portal-smtp -n pieced-system --timeout=60s
|
||||||
|
|
||||||
|
echo "==> Verifying K8s secret created..."
|
||||||
|
kubectl get secret portal-smtp -n pieced-system
|
||||||
|
|
||||||
|
# ─── Step 3: Patch portal deployment to mount SMTP secret ──────────────────────
|
||||||
|
echo "==> Patching portal deployment..."
|
||||||
|
# Add envFrom entry for portal-smtp secret
|
||||||
|
# If your deployment already uses a patch file, add this to the containers[0].envFrom array instead.
|
||||||
|
kubectl patch deployment pieced-portal -n pieced-system --type=json -p='[
|
||||||
|
{
|
||||||
|
"op": "add",
|
||||||
|
"path": "/spec/template/spec/containers/0/envFrom/-",
|
||||||
|
"value": {
|
||||||
|
"secretRef": {
|
||||||
|
"name": "portal-smtp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]'
|
||||||
|
|
||||||
|
echo "==> Restarting portal..."
|
||||||
|
kubectl rollout restart deployment pieced-portal -n pieced-system
|
||||||
|
kubectl rollout status deployment pieced-portal -n pieced-system
|
||||||
|
|
||||||
|
echo "==> Done! SMTP credentials are now available to the portal."
|
||||||
20
package-lock.json
generated
20
package-lock.json
generated
@@ -9,10 +9,12 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kubernetes/client-node": "^1.4.0",
|
"@kubernetes/client-node": "^1.4.0",
|
||||||
|
"@types/nodemailer": "^8.0.0",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
"next": "^15.5.15",
|
"next": "^15.5.15",
|
||||||
"next-auth": "^5.0.0-beta.30",
|
"next-auth": "^5.0.0-beta.30",
|
||||||
"next-intl": "^4.9.0",
|
"next-intl": "^4.9.0",
|
||||||
|
"nodemailer": "^7.0.13",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
@@ -2015,6 +2017,15 @@
|
|||||||
"form-data": "^4.0.4"
|
"form-data": "^4.0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/nodemailer": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/pg": {
|
"node_modules/@types/pg": {
|
||||||
"version": "8.20.0",
|
"version": "8.20.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
|
||||||
@@ -5824,6 +5835,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/nodemailer": {
|
||||||
|
"version": "7.0.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
|
||||||
|
"integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==",
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/oauth4webapi": {
|
"node_modules/oauth4webapi": {
|
||||||
"version": "3.8.5",
|
"version": "3.8.5",
|
||||||
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.5.tgz",
|
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.5.tgz",
|
||||||
|
|||||||
@@ -11,10 +11,12 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kubernetes/client-node": "^1.4.0",
|
"@kubernetes/client-node": "^1.4.0",
|
||||||
|
"@types/nodemailer": "^8.0.0",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
"next": "^15.5.15",
|
"next": "^15.5.15",
|
||||||
"next-auth": "^5.0.0-beta.30",
|
"next-auth": "^5.0.0-beta.30",
|
||||||
"next-intl": "^4.9.0",
|
"next-intl": "^4.9.0",
|
||||||
|
"nodemailer": "^7.0.13",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { getSessionUser } from "@/lib/session";
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { listTenants } from "@/lib/k8s";
|
import { listTenants } from "@/lib/k8s";
|
||||||
import { StatusBadge } from "@/components/ui/status-badge";
|
import { AdminPanel } from "@/components/admin/admin-panel";
|
||||||
|
|
||||||
export default async function AdminPage() {
|
export default async function AdminPage() {
|
||||||
const user = await getSessionUser();
|
const user = await getSessionUser();
|
||||||
@@ -30,76 +30,7 @@ export default async function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="animate-in animate-in-delay-1">
|
<div className="animate-in animate-in-delay-1">
|
||||||
<div className="flex items-baseline gap-3 mb-4">
|
<AdminPanel initialTenants={tenants} />
|
||||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted">
|
|
||||||
{t("allTenants")}
|
|
||||||
</h2>
|
|
||||||
<span className="font-mono text-xs text-text-muted tabular-nums">
|
|
||||||
{tenants.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{tenants.length === 0 ? (
|
|
||||||
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
|
|
||||||
<p className="text-text-secondary text-sm">{t("noTenants")}</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="bg-surface-1 border border-border rounded-xl overflow-hidden">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-border text-left">
|
|
||||||
<th className="px-5 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
|
|
||||||
{t("name")}
|
|
||||||
</th>
|
|
||||||
<th className="px-5 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
|
|
||||||
{t("displayName")}
|
|
||||||
</th>
|
|
||||||
<th className="px-5 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
|
|
||||||
{t("phase")}
|
|
||||||
</th>
|
|
||||||
<th className="px-5 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
|
|
||||||
{t("packages")}
|
|
||||||
</th>
|
|
||||||
<th className="px-5 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
|
|
||||||
{t("created")}
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{tenants.map((tenant) => (
|
|
||||||
<tr
|
|
||||||
key={tenant.metadata.name}
|
|
||||||
className="border-b border-border last:border-0 hover:bg-surface-2/50 transition-colors"
|
|
||||||
>
|
|
||||||
<td className="px-5 py-3 font-mono text-xs text-accent">
|
|
||||||
{tenant.metadata.name}
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-3 text-text-primary">
|
|
||||||
{tenant.spec.displayName}
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-3">
|
|
||||||
<StatusBadge
|
|
||||||
phase={tenant.status?.phase ?? "Pending"}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-3 text-xs text-text-secondary font-mono">
|
|
||||||
{tenant.spec.packages?.join(", ") || "—"}
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-3 text-xs text-text-muted tabular-nums">
|
|
||||||
{tenant.metadata.creationTimestamp
|
|
||||||
? new Date(
|
|
||||||
tenant.metadata.creationTimestamp
|
|
||||||
).toLocaleDateString()
|
|
||||||
: "—"}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ 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 { createTenant } from "@/lib/k8s";
|
import { createTenant } from "@/lib/k8s";
|
||||||
|
import { sendApprovalEmail } from "@/lib/email";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/admin/requests/[id]/approve
|
* POST /api/admin/requests/[id]/approve
|
||||||
* Approve a tenant request: create the PiecedTenant CR and update status.
|
* Approve a tenant request: create the PiecedTenant CR, update status, notify customer.
|
||||||
|
* Also supports re-approving a previously rejected request (clears admin notes).
|
||||||
*/
|
*/
|
||||||
export async function POST(
|
export async function POST(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -29,13 +31,15 @@ export async function POST(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tenantRequest.status !== "pending") {
|
if (tenantRequest.status !== "pending" && tenantRequest.status !== "rejected") {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: `Request is already ${tenantRequest.status}` },
|
{ error: `Request is already ${tenantRequest.status}` },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isReApproval = tenantRequest.status === "rejected";
|
||||||
|
|
||||||
// Derive tenant name from company name: lowercase, alphanumeric + hyphens
|
// Derive tenant name from company name: lowercase, alphanumeric + hyphens
|
||||||
const tenantName = tenantRequest.companyName
|
const tenantName = tenantRequest.companyName
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -60,12 +64,20 @@ export async function POST(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update request status
|
// Update request status — clear admin notes on re-approval
|
||||||
const updated = await updateTenantRequestStatus(id, "provisioning", {
|
const updated = await updateTenantRequestStatus(id, "provisioning", {
|
||||||
adminNotes,
|
adminNotes: isReApproval ? null : adminNotes,
|
||||||
tenantName,
|
tenantName,
|
||||||
|
clearAdminNotes: isReApproval,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Notify customer
|
||||||
|
await sendApprovalEmail(
|
||||||
|
tenantRequest.contactEmail,
|
||||||
|
tenantRequest.contactName,
|
||||||
|
tenantRequest.companyName
|
||||||
|
);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
message: "Tenant approved and provisioning started.",
|
message: "Tenant approved and provisioning started.",
|
||||||
request: updated,
|
request: updated,
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { NextResponse } from "next/server";
|
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 { sendRejectionEmail } from "@/lib/email";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/admin/requests/[id]/reject
|
* POST /api/admin/requests/[id]/reject
|
||||||
* Reject a tenant request.
|
* Reject a tenant request and notify the customer.
|
||||||
*/
|
*/
|
||||||
export async function POST(
|
export async function POST(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -36,6 +37,14 @@ export async function POST(
|
|||||||
adminNotes,
|
adminNotes,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Notify customer
|
||||||
|
await sendRejectionEmail(
|
||||||
|
tenantRequest.contactEmail,
|
||||||
|
tenantRequest.contactName,
|
||||||
|
tenantRequest.companyName,
|
||||||
|
adminNotes
|
||||||
|
);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
message: "Request rejected.",
|
message: "Request rejected.",
|
||||||
request: updated,
|
request: updated,
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requirePlatformRole } from "@/lib/session";
|
import { requirePlatformRole } from "@/lib/session";
|
||||||
import { listTenantRequests } from "@/lib/db";
|
import { listTenantRequests, syncProvisioningStatuses } from "@/lib/db";
|
||||||
|
import { getTenant } from "@/lib/k8s";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/requests
|
* GET /api/admin/requests
|
||||||
* List all tenant requests. Optionally filter by ?status=pending
|
* List all tenant requests. Optionally filter by ?status=pending
|
||||||
|
* Auto-syncs "provisioning" → "active" when the PiecedTenant CR is Ready.
|
||||||
*/
|
*/
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
try {
|
try {
|
||||||
@@ -13,6 +15,12 @@ export async function GET(request: Request) {
|
|||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sync provisioning statuses before listing
|
||||||
|
await syncProvisioningStatuses(async (tenantName: string) => {
|
||||||
|
const tenant = await getTenant(tenantName);
|
||||||
|
return tenant?.status?.phase ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const status = searchParams.get("status") as any;
|
const status = searchParams.get("status") as any;
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
getTenantRequestByOrgId,
|
getTenantRequestByOrgId,
|
||||||
} from "@/lib/db";
|
} from "@/lib/db";
|
||||||
import { getTenant, listTenants } from "@/lib/k8s";
|
import { getTenant, listTenants } from "@/lib/k8s";
|
||||||
|
import { sendAdminNotificationEmail } from "@/lib/email";
|
||||||
import type { OnboardingInput } from "@/types";
|
import type { OnboardingInput } from "@/types";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@@ -87,6 +88,7 @@ export async function GET() {
|
|||||||
* POST /api/onboarding
|
* POST /api/onboarding
|
||||||
* Submit the onboarding wizard. Creates a tenant_request with status "pending".
|
* Submit the onboarding wizard. Creates a tenant_request with status "pending".
|
||||||
* The actual PiecedTenant CR is NOT created yet — admin approval required.
|
* The actual PiecedTenant CR is NOT created yet — admin approval required.
|
||||||
|
* Sends a notification email to the admin.
|
||||||
*/
|
*/
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const user = await getSessionUser();
|
const user = await getSessionUser();
|
||||||
@@ -138,6 +140,13 @@ export async function POST(request: Request) {
|
|||||||
billingNotes: input.billingNotes,
|
billingNotes: input.billingNotes,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Notify admin about the new request
|
||||||
|
await sendAdminNotificationEmail(
|
||||||
|
user.orgName,
|
||||||
|
user.name || user.email,
|
||||||
|
user.email
|
||||||
|
);
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ message: "Onboarding request submitted.", request: tenantRequest },
|
{ message: "Onboarding request submitted.", request: tenantRequest },
|
||||||
{ status: 201 }
|
{ status: 201 }
|
||||||
|
|||||||
508
src/components/admin/admin-panel.tsx
Normal file
508
src/components/admin/admin-panel.tsx
Normal file
@@ -0,0 +1,508 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import type { PiecedTenant, TenantRequest } from "@/types";
|
||||||
|
import { StatusBadge } from "@/components/ui/status-badge";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
type Tab = "requests" | "tenants";
|
||||||
|
type RequestFilter = "all" | "pending" | "provisioning" | "approved" | "rejected";
|
||||||
|
|
||||||
|
interface AdminPanelProps {
|
||||||
|
initialTenants: PiecedTenant[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||||
|
const t = useTranslations("admin");
|
||||||
|
const [tab, setTab] = useState<Tab>("requests");
|
||||||
|
const [requests, setRequests] = useState<TenantRequest[]>([]);
|
||||||
|
const [filter, setFilter] = useState<RequestFilter>("all");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||||
|
const [rejectModal, setRejectModal] = useState<string | null>(null);
|
||||||
|
const [rejectNotes, setRejectNotes] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const fetchRequests = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const url =
|
||||||
|
filter === "all"
|
||||||
|
? "/api/admin/requests"
|
||||||
|
: `/api/admin/requests?status=${filter}`;
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch");
|
||||||
|
const data = await res.json();
|
||||||
|
setRequests(data);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [filter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tab === "requests") {
|
||||||
|
setLoading(true);
|
||||||
|
fetchRequests();
|
||||||
|
}
|
||||||
|
}, [tab, filter, fetchRequests]);
|
||||||
|
|
||||||
|
const handleApprove = async (id: string) => {
|
||||||
|
setActionLoading(id);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/requests/${id}/approve`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.error || "Approve failed");
|
||||||
|
}
|
||||||
|
await fetchRequests();
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setActionLoading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReject = async (id: string) => {
|
||||||
|
setActionLoading(id);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/requests/${id}/reject`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ adminNotes: rejectNotes || undefined }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.error || "Reject failed");
|
||||||
|
}
|
||||||
|
setRejectModal(null);
|
||||||
|
setRejectNotes("");
|
||||||
|
await fetchRequests();
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setActionLoading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const FILTERS: RequestFilter[] = [
|
||||||
|
"all",
|
||||||
|
"pending",
|
||||||
|
"provisioning",
|
||||||
|
"approved",
|
||||||
|
"rejected",
|
||||||
|
];
|
||||||
|
|
||||||
|
const pendingCount = requests.filter((r) => r.status === "pending").length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Tab bar */}
|
||||||
|
<div className="flex gap-1 border-b border-border mb-6">
|
||||||
|
<button
|
||||||
|
onClick={() => setTab("requests")}
|
||||||
|
className={`px-4 py-2.5 text-sm font-medium transition-colors relative ${
|
||||||
|
tab === "requests"
|
||||||
|
? "text-accent"
|
||||||
|
: "text-text-muted hover:text-text-secondary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t("requests")}
|
||||||
|
{pendingCount > 0 && tab !== "requests" && (
|
||||||
|
<span className="ml-1.5 inline-flex items-center justify-center h-4 min-w-[16px] px-1 text-[10px] font-bold bg-accent text-white rounded-full">
|
||||||
|
{pendingCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{tab === "requests" && (
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-accent" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setTab("tenants")}
|
||||||
|
className={`px-4 py-2.5 text-sm font-medium transition-colors relative ${
|
||||||
|
tab === "tenants"
|
||||||
|
? "text-accent"
|
||||||
|
: "text-text-muted hover:text-text-secondary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t("allTenants")}
|
||||||
|
<span className="ml-1.5 text-xs text-text-muted tabular-nums">
|
||||||
|
{initialTenants.length}
|
||||||
|
</span>
|
||||||
|
{tab === "tenants" && (
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-accent" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error banner */}
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2">
|
||||||
|
{error}
|
||||||
|
<button
|
||||||
|
onClick={() => setError("")}
|
||||||
|
className="ml-2 text-red-300 hover:text-red-200"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ───── REQUESTS TAB ───── */}
|
||||||
|
{tab === "requests" && (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-1.5 mb-4 flex-wrap">
|
||||||
|
{FILTERS.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
onClick={() => setFilter(f)}
|
||||||
|
className={`px-3 py-1 text-xs rounded-full transition-colors ${
|
||||||
|
filter === f
|
||||||
|
? "bg-accent text-white"
|
||||||
|
: "bg-surface-2 text-text-muted hover:text-text-secondary border border-border"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(`filter_${f}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
|
||||||
|
<div className="h-5 w-5 border-2 border-accent border-t-transparent rounded-full animate-spin mx-auto mb-2" />
|
||||||
|
<p className="text-text-muted text-xs">{t("loadingRequests")}</p>
|
||||||
|
</div>
|
||||||
|
) : requests.length === 0 ? (
|
||||||
|
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
|
||||||
|
<p className="text-text-secondary text-sm">{t("noRequests")}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-surface-1 border border-border rounded-xl overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border text-left">
|
||||||
|
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
|
||||||
|
{t("company")}
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
|
||||||
|
{t("contact")}
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden md:table-cell">
|
||||||
|
{t("agentName")}
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden lg:table-cell">
|
||||||
|
{t("packages")}
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
|
||||||
|
{t("status")}
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden md:table-cell">
|
||||||
|
{t("submitted")}
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
|
||||||
|
{t("actions")}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{requests.map((req) => (
|
||||||
|
<tr
|
||||||
|
key={req.id}
|
||||||
|
className="border-b border-border last:border-0 hover:bg-surface-2/50 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="font-medium text-text-primary text-sm">
|
||||||
|
{req.companyName}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="text-text-primary text-sm">
|
||||||
|
{req.contactName}
|
||||||
|
</div>
|
||||||
|
<div className="text-text-muted text-xs">
|
||||||
|
{req.contactEmail}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-text-secondary hidden md:table-cell">
|
||||||
|
{req.agentName}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-text-secondary font-mono hidden lg:table-cell">
|
||||||
|
{req.packages?.length
|
||||||
|
? req.packages.join(", ")
|
||||||
|
: "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<RequestStatusBadge status={req.status} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-text-muted tabular-nums hidden md:table-cell">
|
||||||
|
{new Date(req.createdAt).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{req.status === "pending" && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => handleApprove(req.id)}
|
||||||
|
disabled={actionLoading === req.id}
|
||||||
|
className="px-2.5 py-1 text-xs font-medium bg-emerald-500/15 text-emerald-400 rounded-md hover:bg-emerald-500/25 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{actionLoading === req.id
|
||||||
|
? "…"
|
||||||
|
: t("approve")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setRejectModal(req.id)}
|
||||||
|
disabled={actionLoading === req.id}
|
||||||
|
className="px-2.5 py-1 text-xs font-medium bg-red-500/15 text-red-400 rounded-md hover:bg-red-500/25 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t("reject")}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{(req.status === "provisioning" ||
|
||||||
|
req.status === "approved") &&
|
||||||
|
req.tenantName && (
|
||||||
|
<Link
|
||||||
|
href={`/tenants/${req.tenantName}`}
|
||||||
|
className="px-2.5 py-1 text-xs font-medium bg-accent/15 text-accent rounded-md hover:bg-accent/25 transition-colors"
|
||||||
|
>
|
||||||
|
{t("viewTenant")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{req.status === "rejected" && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleApprove(req.id)}
|
||||||
|
disabled={actionLoading === req.id}
|
||||||
|
className="px-2.5 py-1 text-xs font-medium bg-amber-500/15 text-amber-400 rounded-md hover:bg-amber-500/25 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t("reApprove")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{req.adminNotes && (
|
||||||
|
<p className="text-[11px] text-text-muted mt-1 max-w-[200px] truncate">
|
||||||
|
{req.adminNotes}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ───── TENANTS TAB ───── */}
|
||||||
|
{tab === "tenants" && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
|
||||||
|
<SummaryCard
|
||||||
|
label={t("totalTenants")}
|
||||||
|
value={initialTenants.length}
|
||||||
|
/>
|
||||||
|
<SummaryCard
|
||||||
|
label={t("running")}
|
||||||
|
value={
|
||||||
|
initialTenants.filter((t) => t.status?.phase === "Running")
|
||||||
|
.length
|
||||||
|
}
|
||||||
|
color="text-emerald-400"
|
||||||
|
/>
|
||||||
|
<SummaryCard
|
||||||
|
label={t("provisioning")}
|
||||||
|
value={
|
||||||
|
initialTenants.filter(
|
||||||
|
(t) =>
|
||||||
|
t.status?.phase === "Provisioning" ||
|
||||||
|
t.status?.phase === "Pending"
|
||||||
|
).length
|
||||||
|
}
|
||||||
|
color="text-amber-400"
|
||||||
|
/>
|
||||||
|
<SummaryCard
|
||||||
|
label={t("errors")}
|
||||||
|
value={
|
||||||
|
initialTenants.filter((t) => t.status?.phase === "Error").length
|
||||||
|
}
|
||||||
|
color="text-red-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{initialTenants.length === 0 ? (
|
||||||
|
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
|
||||||
|
<p className="text-text-secondary text-sm">{t("noTenants")}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-surface-1 border border-border rounded-xl overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border text-left">
|
||||||
|
<th className="px-5 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
|
||||||
|
{t("name")}
|
||||||
|
</th>
|
||||||
|
<th className="px-5 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
|
||||||
|
{t("displayName")}
|
||||||
|
</th>
|
||||||
|
<th className="px-5 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
|
||||||
|
{t("phase")}
|
||||||
|
</th>
|
||||||
|
<th className="px-5 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden md:table-cell">
|
||||||
|
{t("packages")}
|
||||||
|
</th>
|
||||||
|
<th className="px-5 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden md:table-cell">
|
||||||
|
{t("created")}
|
||||||
|
</th>
|
||||||
|
<th className="px-5 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
|
||||||
|
{t("actions")}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{initialTenants.map((tenant) => (
|
||||||
|
<tr
|
||||||
|
key={tenant.metadata.name}
|
||||||
|
className="border-b border-border last:border-0 hover:bg-surface-2/50 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-5 py-3 font-mono text-xs text-accent">
|
||||||
|
{tenant.metadata.name}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3 text-text-primary">
|
||||||
|
{tenant.spec.displayName}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3">
|
||||||
|
<StatusBadge
|
||||||
|
phase={tenant.status?.phase ?? "Pending"}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3 text-xs text-text-secondary font-mono hidden md:table-cell">
|
||||||
|
{tenant.spec.packages?.join(", ") || "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3 text-xs text-text-muted tabular-nums hidden md:table-cell">
|
||||||
|
{tenant.metadata.creationTimestamp
|
||||||
|
? new Date(
|
||||||
|
tenant.metadata.creationTimestamp
|
||||||
|
).toLocaleDateString()
|
||||||
|
: "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3">
|
||||||
|
<Link
|
||||||
|
href={`/tenants/${tenant.metadata.name}`}
|
||||||
|
className="px-2.5 py-1 text-xs font-medium bg-accent/15 text-accent rounded-md hover:bg-accent/25 transition-colors"
|
||||||
|
>
|
||||||
|
{t("manage")}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ───── REJECT MODAL ───── */}
|
||||||
|
{rejectModal && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||||
|
<div className="bg-surface-1 border border-border rounded-xl p-6 max-w-md w-full mx-4 shadow-2xl">
|
||||||
|
<h3 className="font-display text-lg font-semibold text-text-primary mb-4">
|
||||||
|
{t("rejectTitle")}
|
||||||
|
</h3>
|
||||||
|
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
|
||||||
|
{t("adminNotesLabel")}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={rejectNotes}
|
||||||
|
onChange={(e) => setRejectNotes(e.target.value)}
|
||||||
|
placeholder={t("adminNotesPlaceholder")}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors resize-none mb-4"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setRejectModal(null);
|
||||||
|
setRejectNotes("");
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
|
||||||
|
>
|
||||||
|
{t("cancelAction")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleReject(rejectModal)}
|
||||||
|
disabled={actionLoading === rejectModal}
|
||||||
|
className="px-4 py-2 text-sm font-medium bg-red-500/15 text-red-400 rounded-lg hover:bg-red-500/25 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{actionLoading === rejectModal ? "…" : t("confirmReject")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RequestStatusBadge({ status }: { status: string }) {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
pending: "bg-blue-400/15 text-blue-400",
|
||||||
|
approved: "bg-emerald-400/15 text-emerald-400",
|
||||||
|
provisioning: "bg-amber-400/15 text-amber-400",
|
||||||
|
active: "bg-emerald-400/15 text-emerald-400",
|
||||||
|
rejected: "bg-red-400/15 text-red-400",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1.5 px-2 py-0.5 text-xs font-medium rounded-full ${colors[status] || "bg-surface-2 text-text-muted"}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`h-1.5 w-1.5 rounded-full ${
|
||||||
|
status === "pending"
|
||||||
|
? "bg-blue-400"
|
||||||
|
: status === "approved" || status === "active"
|
||||||
|
? "bg-emerald-400"
|
||||||
|
: status === "provisioning"
|
||||||
|
? "bg-amber-400 animate-pulse"
|
||||||
|
: status === "rejected"
|
||||||
|
? "bg-red-400"
|
||||||
|
: "bg-text-muted"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
color,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
color?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="bg-surface-1 border border-border rounded-xl p-4">
|
||||||
|
<p className="text-xs text-text-muted uppercase tracking-wider mb-1">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className={`font-mono text-2xl font-semibold tabular-nums ${color || "text-text-primary"}`}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -35,22 +35,22 @@ function getPool(): Pool {
|
|||||||
|
|
||||||
const MIGRATION_SQL = `
|
const MIGRATION_SQL = `
|
||||||
CREATE TABLE IF NOT EXISTS tenant_requests (
|
CREATE TABLE IF NOT EXISTS tenant_requests (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
zitadel_org_id TEXT NOT NULL UNIQUE,
|
zitadel_org_id TEXT NOT NULL UNIQUE,
|
||||||
zitadel_user_id TEXT NOT NULL,
|
zitadel_user_id TEXT NOT NULL,
|
||||||
company_name TEXT NOT NULL,
|
company_name TEXT NOT NULL,
|
||||||
contact_name TEXT NOT NULL,
|
contact_name TEXT NOT NULL,
|
||||||
contact_email TEXT NOT NULL,
|
contact_email TEXT NOT NULL,
|
||||||
agent_name TEXT NOT NULL DEFAULT 'Assistant',
|
agent_name TEXT NOT NULL DEFAULT 'Assistant',
|
||||||
soul_md TEXT,
|
soul_md TEXT,
|
||||||
packages TEXT[] DEFAULT '{}',
|
packages TEXT[] DEFAULT '{}',
|
||||||
billing_address JSONB DEFAULT '{}',
|
billing_address JSONB DEFAULT '{}',
|
||||||
billing_notes TEXT,
|
billing_notes TEXT,
|
||||||
status TEXT NOT NULL DEFAULT 'pending',
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
admin_notes TEXT,
|
admin_notes TEXT,
|
||||||
tenant_name TEXT,
|
tenant_name TEXT,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tenant_requests_status ON tenant_requests(status);
|
CREATE INDEX IF NOT EXISTS idx_tenant_requests_status ON tenant_requests(status);
|
||||||
@@ -75,8 +75,8 @@ export async function createTenantRequest(
|
|||||||
await ensureSchema();
|
await ensureSchema();
|
||||||
const result = await getPool().query<TenantRequest>(
|
const result = await getPool().query<TenantRequest>(
|
||||||
`INSERT INTO tenant_requests
|
`INSERT INTO tenant_requests
|
||||||
(zitadel_org_id, zitadel_user_id, company_name, contact_name,
|
(zitadel_org_id, zitadel_user_id, company_name, contact_name,
|
||||||
contact_email, agent_name, soul_md, packages, billing_address, billing_notes)
|
contact_email, agent_name, soul_md, packages, billing_address, billing_notes)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[
|
[
|
||||||
@@ -132,12 +132,19 @@ export async function listTenantRequests(
|
|||||||
export async function updateTenantRequestStatus(
|
export async function updateTenantRequestStatus(
|
||||||
id: string,
|
id: string,
|
||||||
status: TenantRequestStatus,
|
status: TenantRequestStatus,
|
||||||
extra?: { adminNotes?: string; tenantName?: string }
|
extra?: { adminNotes?: string | null; tenantName?: string; clearAdminNotes?: boolean }
|
||||||
): Promise<TenantRequest> {
|
): Promise<TenantRequest> {
|
||||||
await ensureSchema();
|
await ensureSchema();
|
||||||
|
|
||||||
|
// If clearAdminNotes is true, explicitly set admin_notes to NULL
|
||||||
|
// Otherwise use COALESCE to preserve existing value when not provided
|
||||||
|
const adminNotesExpr = extra?.clearAdminNotes
|
||||||
|
? "$2"
|
||||||
|
: "COALESCE($2, admin_notes)";
|
||||||
|
|
||||||
const result = await getPool().query(
|
const result = await getPool().query(
|
||||||
`UPDATE tenant_requests
|
`UPDATE tenant_requests
|
||||||
SET status = $1, admin_notes = COALESCE($2, admin_notes),
|
SET status = $1, admin_notes = ${adminNotesExpr},
|
||||||
tenant_name = COALESCE($3, tenant_name), updated_at = now()
|
tenant_name = COALESCE($3, tenant_name), updated_at = now()
|
||||||
WHERE id = $4
|
WHERE id = $4
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
@@ -147,6 +154,35 @@ export async function updateTenantRequestStatus(
|
|||||||
return mapRow(result.rows[0]);
|
return mapRow(result.rows[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync provisioning statuses: for all requests with status "provisioning",
|
||||||
|
* check if the PiecedTenant CR has reached "Ready" and update to "active".
|
||||||
|
* Called from the admin requests list endpoint.
|
||||||
|
*/
|
||||||
|
export async function syncProvisioningStatuses(
|
||||||
|
checkTenantPhase: (tenantName: string) => Promise<string | null>
|
||||||
|
): Promise<void> {
|
||||||
|
await ensureSchema();
|
||||||
|
const pool = getPool();
|
||||||
|
const result = await pool.query(
|
||||||
|
"SELECT id, tenant_name FROM tenant_requests WHERE status = 'provisioning' AND tenant_name IS NOT NULL"
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const row of result.rows) {
|
||||||
|
try {
|
||||||
|
const phase = await checkTenantPhase(row.tenant_name);
|
||||||
|
if (phase === "Ready" || phase === "Running") {
|
||||||
|
await pool.query(
|
||||||
|
"UPDATE tenant_requests SET status = 'active', updated_at = now() WHERE id = $1",
|
||||||
|
[row.id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to sync status for request ${row.id}:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Row mapping (snake_case → camelCase)
|
// Row mapping (snake_case → camelCase)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
165
src/lib/email.ts
Normal file
165
src/lib/email.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
/**
|
||||||
|
* Email sending utility for the PieCed portal.
|
||||||
|
*
|
||||||
|
* Uses nodemailer with SMTP credentials from environment variables
|
||||||
|
* (populated via ExternalSecret from OpenBao at pieced/portal/smtp).
|
||||||
|
*
|
||||||
|
* Env vars (from portal-smtp K8s secret):
|
||||||
|
* SMTP_HOST — e.g. smtp.gmail.com
|
||||||
|
* SMTP_PORT — e.g. 587 (default)
|
||||||
|
* SMTP_USER — e.g. noreply@pieced.ch
|
||||||
|
* SMTP_PASS — App Password
|
||||||
|
* SMTP_FROM — e.g. "PieCed <noreply@pieced.ch>"
|
||||||
|
* ADMIN_NOTIFICATION_EMAIL — e.g. admin@pieced.ch (optional)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import nodemailer from "nodemailer";
|
||||||
|
|
||||||
|
let _transporter: nodemailer.Transporter | null = null;
|
||||||
|
|
||||||
|
function getTransporter(): nodemailer.Transporter {
|
||||||
|
if (!_transporter) {
|
||||||
|
const host = process.env.SMTP_HOST;
|
||||||
|
const user = process.env.SMTP_USER;
|
||||||
|
const pass = process.env.SMTP_PASS;
|
||||||
|
if (!host || !user || !pass) {
|
||||||
|
throw new Error("SMTP_HOST, SMTP_USER, and SMTP_PASS must be set");
|
||||||
|
}
|
||||||
|
_transporter = nodemailer.createTransport({
|
||||||
|
host,
|
||||||
|
port: parseInt(process.env.SMTP_PORT || "587", 10),
|
||||||
|
secure: process.env.SMTP_SECURE === "true",
|
||||||
|
auth: { user, pass },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _transporter;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFrom(): string {
|
||||||
|
return (
|
||||||
|
process.env.SMTP_FROM ||
|
||||||
|
`PieCed <${process.env.SMTP_USER}>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendApprovalEmail(
|
||||||
|
to: string,
|
||||||
|
contactName: string,
|
||||||
|
companyName: string
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await getTransporter().sendMail({
|
||||||
|
from: getFrom(),
|
||||||
|
to,
|
||||||
|
subject: `Your PieCed AI assistant is being set up — ${companyName}`,
|
||||||
|
text: [
|
||||||
|
`Hello ${contactName},`,
|
||||||
|
"",
|
||||||
|
`Great news! Your onboarding request for ${companyName} has been approved.`,
|
||||||
|
"",
|
||||||
|
"Your AI assistant instance is now being provisioned. This usually takes a few minutes.",
|
||||||
|
"You can check the status in your dashboard at https://app.pieced.ch",
|
||||||
|
"",
|
||||||
|
"Once your instance is ready, you'll see it on your dashboard and can start configuring it.",
|
||||||
|
"",
|
||||||
|
"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 is being set up</h2>
|
||||||
|
<p>Hello ${contactName},</p>
|
||||||
|
<p>Great news! Your onboarding request for <strong>${companyName}</strong> has been approved.</p>
|
||||||
|
<p>Your AI assistant instance is now being provisioned. This usually takes 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>
|
||||||
|
<p style="color: #888; font-size: 13px; margin-top: 24px;">
|
||||||
|
Once your instance is ready, you'll see it on your dashboard and can start configuring it.
|
||||||
|
</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 approval email:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendRejectionEmail(
|
||||||
|
to: string,
|
||||||
|
contactName: string,
|
||||||
|
companyName: string,
|
||||||
|
adminNotes?: string
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const notesBlock = adminNotes
|
||||||
|
? `\nNote from our team:\n${adminNotes}\n`
|
||||||
|
: "";
|
||||||
|
const notesHtml = adminNotes
|
||||||
|
? `<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;">${adminNotes}</p>
|
||||||
|
</div>`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
await getTransporter().sendMail({
|
||||||
|
from: getFrom(),
|
||||||
|
to,
|
||||||
|
subject: `Update on your PieCed onboarding request — ${companyName}`,
|
||||||
|
text: [
|
||||||
|
`Hello ${contactName},`,
|
||||||
|
"",
|
||||||
|
`Thank you for your interest in PieCed IT. Unfortunately, we were unable to approve your onboarding request for ${companyName} at this time.`,
|
||||||
|
notesBlock,
|
||||||
|
"If you have questions or would like to discuss this further, 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 onboarding request</h2>
|
||||||
|
<p>Hello ${contactName},</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>
|
||||||
|
${notesHtml}
|
||||||
|
<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;" />
|
||||||
|
<p style="color: #666; font-size: 12px;">PieCed IT — Hosted on-premises in Switzerland</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to send rejection email:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendAdminNotificationEmail(
|
||||||
|
companyName: string,
|
||||||
|
contactName: string,
|
||||||
|
contactEmail: string
|
||||||
|
): Promise<void> {
|
||||||
|
const adminEmail = process.env.ADMIN_NOTIFICATION_EMAIL;
|
||||||
|
if (!adminEmail) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await getTransporter().sendMail({
|
||||||
|
from: getFrom(),
|
||||||
|
to: adminEmail,
|
||||||
|
subject: `New onboarding request: ${companyName}`,
|
||||||
|
text: [
|
||||||
|
`A new onboarding request has been submitted.`,
|
||||||
|
"",
|
||||||
|
`Company: ${companyName}`,
|
||||||
|
`Contact: ${contactName} (${contactEmail})`,
|
||||||
|
"",
|
||||||
|
`Review it at https://app.pieced.ch/admin`,
|
||||||
|
].join("\n"),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to send admin notification email:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -90,7 +90,6 @@
|
|||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
"title": "Plattform-Admin",
|
"title": "Plattform-Admin",
|
||||||
"subtitle": "Alle Tenants der Plattform",
|
|
||||||
"allTenants": "Tenants",
|
"allTenants": "Tenants",
|
||||||
"noTenants": "Noch keine Tenants bereitgestellt.",
|
"noTenants": "Noch keine Tenants bereitgestellt.",
|
||||||
"noAccess": "Unzureichende Berechtigungen für diese Ansicht.",
|
"noAccess": "Unzureichende Berechtigungen für diese Ansicht.",
|
||||||
@@ -100,7 +99,6 @@
|
|||||||
"packages": "Pakete",
|
"packages": "Pakete",
|
||||||
"created": "Erstellt",
|
"created": "Erstellt",
|
||||||
"manage": "Verwalten",
|
"manage": "Verwalten",
|
||||||
"requests": "Onboarding-Anträge",
|
|
||||||
"pendingRequests": "Offene Anträge",
|
"pendingRequests": "Offene Anträge",
|
||||||
"approve": "Genehmigen",
|
"approve": "Genehmigen",
|
||||||
"reject": "Ablehnen",
|
"reject": "Ablehnen",
|
||||||
@@ -108,9 +106,30 @@
|
|||||||
"contact": "Kontakt",
|
"contact": "Kontakt",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"submitted": "Eingereicht",
|
"submitted": "Eingereicht",
|
||||||
"noRequests": "Keine offenen Anträge.",
|
|
||||||
"approveConfirm": "Diesen Antrag genehmigen und Bereitstellung starten?",
|
"approveConfirm": "Diesen Antrag genehmigen und Bereitstellung starten?",
|
||||||
"rejectConfirm": "Diesen Antrag ablehnen?"
|
"rejectConfirm": "Diesen Antrag ablehnen?",
|
||||||
|
"subtitle": "Onboarding-Anfragen und Mandanten-Lebenszyklus verwalten",
|
||||||
|
"requests": "Anfragen",
|
||||||
|
"reApprove": "Erneut genehmigen",
|
||||||
|
"agentName": "Agent",
|
||||||
|
"actions": "Aktionen",
|
||||||
|
"noRequests": "Keine Anfragen gefunden.",
|
||||||
|
"loadingRequests": "Anfragen werden geladen…",
|
||||||
|
"rejectTitle": "Anfrage ablehnen",
|
||||||
|
"adminNotesLabel": "Notizen (optional)",
|
||||||
|
"adminNotesPlaceholder": "Grund der Ablehnung…",
|
||||||
|
"cancelAction": "Abbrechen",
|
||||||
|
"confirmReject": "Ablehnen",
|
||||||
|
"viewTenant": "Anzeigen",
|
||||||
|
"filter_all": "Alle",
|
||||||
|
"filter_pending": "Ausstehend",
|
||||||
|
"filter_provisioning": "Bereitstellung",
|
||||||
|
"filter_approved": "Genehmigt",
|
||||||
|
"filter_rejected": "Abgelehnt",
|
||||||
|
"totalTenants": "Gesamt",
|
||||||
|
"running": "Aktiv",
|
||||||
|
"provisioning": "Bereitstellung",
|
||||||
|
"errors": "Fehler"
|
||||||
},
|
},
|
||||||
"tenantDetail": {
|
"tenantDetail": {
|
||||||
"agent": "Agent",
|
"agent": "Agent",
|
||||||
|
|||||||
@@ -90,7 +90,7 @@
|
|||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
"title": "Platform Admin",
|
"title": "Platform Admin",
|
||||||
"subtitle": "All tenants across the platform",
|
"subtitle": "Manage onboarding requests and tenant lifecycle",
|
||||||
"allTenants": "Tenants",
|
"allTenants": "Tenants",
|
||||||
"noTenants": "No tenants provisioned yet.",
|
"noTenants": "No tenants provisioned yet.",
|
||||||
"noAccess": "Insufficient permissions for this view.",
|
"noAccess": "Insufficient permissions for this view.",
|
||||||
@@ -100,17 +100,36 @@
|
|||||||
"packages": "Packages",
|
"packages": "Packages",
|
||||||
"created": "Created",
|
"created": "Created",
|
||||||
"manage": "Manage",
|
"manage": "Manage",
|
||||||
"requests": "Onboarding Requests",
|
"requests": "Requests",
|
||||||
"pendingRequests": "Pending Requests",
|
"pendingRequests": "Pending Requests",
|
||||||
"approve": "Approve",
|
"approve": "Approve",
|
||||||
"reject": "Reject",
|
"reject": "Reject",
|
||||||
|
"reApprove": "Re-approve",
|
||||||
"company": "Company",
|
"company": "Company",
|
||||||
"contact": "Contact",
|
"contact": "Contact",
|
||||||
|
"agentName": "Agent",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"submitted": "Submitted",
|
"submitted": "Submitted",
|
||||||
"noRequests": "No pending requests.",
|
"actions": "Actions",
|
||||||
|
"noRequests": "No requests found.",
|
||||||
|
"loadingRequests": "Loading requests…",
|
||||||
"approveConfirm": "Approve this request and start provisioning?",
|
"approveConfirm": "Approve this request and start provisioning?",
|
||||||
"rejectConfirm": "Reject this request?"
|
"rejectConfirm": "Reject this request?",
|
||||||
|
"rejectTitle": "Reject request",
|
||||||
|
"adminNotesLabel": "Notes (optional)",
|
||||||
|
"adminNotesPlaceholder": "Reason for rejection…",
|
||||||
|
"cancelAction": "Cancel",
|
||||||
|
"confirmReject": "Reject",
|
||||||
|
"viewTenant": "View",
|
||||||
|
"filter_all": "All",
|
||||||
|
"filter_pending": "Pending",
|
||||||
|
"filter_provisioning": "Provisioning",
|
||||||
|
"filter_approved": "Approved",
|
||||||
|
"filter_rejected": "Rejected",
|
||||||
|
"totalTenants": "Total",
|
||||||
|
"running": "Running",
|
||||||
|
"provisioning": "Provisioning",
|
||||||
|
"errors": "Errors"
|
||||||
},
|
},
|
||||||
"tenantDetail": {
|
"tenantDetail": {
|
||||||
"agent": "Agent",
|
"agent": "Agent",
|
||||||
|
|||||||
@@ -90,7 +90,6 @@
|
|||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
"title": "Admin plateforme",
|
"title": "Admin plateforme",
|
||||||
"subtitle": "Tous les tenants de la plateforme",
|
|
||||||
"allTenants": "Tenants",
|
"allTenants": "Tenants",
|
||||||
"noTenants": "Aucun tenant provisionné.",
|
"noTenants": "Aucun tenant provisionné.",
|
||||||
"noAccess": "Permissions insuffisantes pour cette vue.",
|
"noAccess": "Permissions insuffisantes pour cette vue.",
|
||||||
@@ -100,7 +99,6 @@
|
|||||||
"packages": "Paquets",
|
"packages": "Paquets",
|
||||||
"created": "Créé",
|
"created": "Créé",
|
||||||
"manage": "Gérer",
|
"manage": "Gérer",
|
||||||
"requests": "Demandes d'intégration",
|
|
||||||
"pendingRequests": "Demandes en attente",
|
"pendingRequests": "Demandes en attente",
|
||||||
"approve": "Approuver",
|
"approve": "Approuver",
|
||||||
"reject": "Refuser",
|
"reject": "Refuser",
|
||||||
@@ -108,9 +106,30 @@
|
|||||||
"contact": "Contact",
|
"contact": "Contact",
|
||||||
"status": "Statut",
|
"status": "Statut",
|
||||||
"submitted": "Envoyé",
|
"submitted": "Envoyé",
|
||||||
"noRequests": "Aucune demande en attente.",
|
|
||||||
"approveConfirm": "Approuver cette demande et lancer la mise en service ?",
|
"approveConfirm": "Approuver cette demande et lancer la mise en service ?",
|
||||||
"rejectConfirm": "Refuser cette demande ?"
|
"rejectConfirm": "Refuser cette demande ?",
|
||||||
|
"subtitle": "Gérer les demandes d'intégration et le cycle de vie des locataires",
|
||||||
|
"requests": "Demandes",
|
||||||
|
"reApprove": "Ré-approuver",
|
||||||
|
"agentName": "Agent",
|
||||||
|
"actions": "Actions",
|
||||||
|
"noRequests": "Aucune demande trouvée.",
|
||||||
|
"loadingRequests": "Chargement des demandes…",
|
||||||
|
"rejectTitle": "Rejeter la demande",
|
||||||
|
"adminNotesLabel": "Notes (optionnel)",
|
||||||
|
"adminNotesPlaceholder": "Raison du rejet…",
|
||||||
|
"cancelAction": "Annuler",
|
||||||
|
"confirmReject": "Rejeter",
|
||||||
|
"viewTenant": "Voir",
|
||||||
|
"filter_all": "Tous",
|
||||||
|
"filter_pending": "En attente",
|
||||||
|
"filter_provisioning": "Provisionnement",
|
||||||
|
"filter_approved": "Approuvé",
|
||||||
|
"filter_rejected": "Rejeté",
|
||||||
|
"totalTenants": "Total",
|
||||||
|
"running": "Actif",
|
||||||
|
"provisioning": "Provisionnement",
|
||||||
|
"errors": "Erreurs"
|
||||||
},
|
},
|
||||||
"tenantDetail": {
|
"tenantDetail": {
|
||||||
"agent": "Agent",
|
"agent": "Agent",
|
||||||
|
|||||||
@@ -90,7 +90,6 @@
|
|||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
"title": "Admin piattaforma",
|
"title": "Admin piattaforma",
|
||||||
"subtitle": "Tutti i tenant della piattaforma",
|
|
||||||
"allTenants": "Tenant",
|
"allTenants": "Tenant",
|
||||||
"noTenants": "Nessun tenant ancora attivato.",
|
"noTenants": "Nessun tenant ancora attivato.",
|
||||||
"noAccess": "Permessi insufficienti per questa vista.",
|
"noAccess": "Permessi insufficienti per questa vista.",
|
||||||
@@ -100,7 +99,6 @@
|
|||||||
"packages": "Pacchetti",
|
"packages": "Pacchetti",
|
||||||
"created": "Creato",
|
"created": "Creato",
|
||||||
"manage": "Gestisci",
|
"manage": "Gestisci",
|
||||||
"requests": "Richieste di attivazione",
|
|
||||||
"pendingRequests": "Richieste in sospeso",
|
"pendingRequests": "Richieste in sospeso",
|
||||||
"approve": "Approva",
|
"approve": "Approva",
|
||||||
"reject": "Rifiuta",
|
"reject": "Rifiuta",
|
||||||
@@ -108,9 +106,30 @@
|
|||||||
"contact": "Contatto",
|
"contact": "Contatto",
|
||||||
"status": "Stato",
|
"status": "Stato",
|
||||||
"submitted": "Inviato",
|
"submitted": "Inviato",
|
||||||
"noRequests": "Nessuna richiesta in sospeso.",
|
|
||||||
"approveConfirm": "Approvare questa richiesta e avviare l'attivazione?",
|
"approveConfirm": "Approvare questa richiesta e avviare l'attivazione?",
|
||||||
"rejectConfirm": "Rifiutare questa richiesta?"
|
"rejectConfirm": "Rifiutare questa richiesta?",
|
||||||
|
"subtitle": "Gestire le richieste di onboarding e il ciclo di vita dei tenant",
|
||||||
|
"requests": "Richieste",
|
||||||
|
"reApprove": "Ri-approva",
|
||||||
|
"agentName": "Agente",
|
||||||
|
"actions": "Azioni",
|
||||||
|
"noRequests": "Nessuna richiesta trovata.",
|
||||||
|
"loadingRequests": "Caricamento richieste…",
|
||||||
|
"rejectTitle": "Rifiuta richiesta",
|
||||||
|
"adminNotesLabel": "Note (opzionale)",
|
||||||
|
"adminNotesPlaceholder": "Motivo del rifiuto…",
|
||||||
|
"cancelAction": "Annulla",
|
||||||
|
"confirmReject": "Rifiuta",
|
||||||
|
"viewTenant": "Vedi",
|
||||||
|
"filter_all": "Tutti",
|
||||||
|
"filter_pending": "In attesa",
|
||||||
|
"filter_provisioning": "Provisioning",
|
||||||
|
"filter_approved": "Approvato",
|
||||||
|
"filter_rejected": "Rifiutato",
|
||||||
|
"totalTenants": "Totale",
|
||||||
|
"running": "Attivo",
|
||||||
|
"provisioning": "Provisioning",
|
||||||
|
"errors": "Errori"
|
||||||
},
|
},
|
||||||
"tenantDetail": {
|
"tenantDetail": {
|
||||||
"agent": "Agente",
|
"agent": "Agente",
|
||||||
|
|||||||
Reference in New Issue
Block a user