Phase2.5: Skill SetUp Process
This commit is contained in:
@@ -2,6 +2,7 @@ import { getSessionUser } from "@/lib/session";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { listTenants } from "@/lib/k8s";
|
||||
import { countPendingSkillActivationRequests } from "@/lib/db";
|
||||
import { AdminPanel } from "@/components/admin/admin-panel";
|
||||
|
||||
export default async function AdminPage() {
|
||||
@@ -19,6 +20,12 @@ export default async function AdminPage() {
|
||||
}
|
||||
|
||||
const tenants = await listTenants();
|
||||
// Phase 2.5: badge counter for the skill-activation admin queue.
|
||||
// Cheap COUNT(*) on a partial-indexed status='pending' column —
|
||||
// bounded by request volume and never expected to be high.
|
||||
const pendingSkillCount = await countPendingSkillActivationRequests().catch(
|
||||
() => 0
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -33,6 +40,21 @@ export default async function AdminPage() {
|
||||
than nav-shell entries — these are platform-team utilities,
|
||||
not main navigation. */}
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href="/admin/skills/pending"
|
||||
className={`text-sm px-4 py-2 rounded-lg border transition-colors flex items-center gap-2 ${
|
||||
pendingSkillCount > 0
|
||||
? "border-warning text-warning hover:bg-warning/10"
|
||||
: "border-border text-text-secondary hover:text-text-primary hover:border-text-secondary"
|
||||
}`}
|
||||
>
|
||||
<span>{t("skillsQueueTool")}</span>
|
||||
{pendingSkillCount > 0 && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-warning text-surface-0 font-semibold">
|
||||
{pendingSkillCount}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
<a
|
||||
href="/admin/billing"
|
||||
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors"
|
||||
|
||||
59
src/app/[locale]/admin/skills/pending/page.tsx
Normal file
59
src/app/[locale]/admin/skills/pending/page.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { listPendingSkillActivationRequests, getOrgBilling } from "@/lib/db";
|
||||
import { getPackageDef } from "@/lib/packages";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { PendingSkillRequests } from "@/components/admin/skills/pending-skill-requests";
|
||||
|
||||
/**
|
||||
* /admin/skills/pending — admin queue for manual-setup skill
|
||||
* activation requests. Each row shows tenant, skill, requester
|
||||
* info, and offers Approve / Reject actions.
|
||||
*
|
||||
* Server-renders the initial list. Approval/rejection trigger a
|
||||
* client-side fetch + router.refresh() so the row disappears and
|
||||
* the count updates without a hard reload.
|
||||
*/
|
||||
export default async function AdminPendingSkillRequestsPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (!user.isPlatform) redirect("/dashboard");
|
||||
const t = await getTranslations("adminSkills");
|
||||
|
||||
const pending = await listPendingSkillActivationRequests();
|
||||
|
||||
// Hydrate display fields: skill name from catalog, org company name
|
||||
// from billing. Skill name fallback to skillId for off-catalog
|
||||
// entries (shouldn't happen but defensive). Company name is
|
||||
// looked up lazily per row; dedup'd via a Map so we don't issue
|
||||
// duplicate getOrgBilling calls for the same org.
|
||||
const seenOrg = new Map<string, string | null>();
|
||||
const rows = await Promise.all(
|
||||
pending.map(async (r) => {
|
||||
if (!seenOrg.has(r.zitadelOrgId)) {
|
||||
const billing = await getOrgBilling(r.zitadelOrgId).catch(() => null);
|
||||
seenOrg.set(r.zitadelOrgId, billing?.companyName ?? null);
|
||||
}
|
||||
const def = getPackageDef(r.skillId);
|
||||
return {
|
||||
...r,
|
||||
skillName: def?.name ?? r.skillId,
|
||||
companyName: seenOrg.get(r.zitadelOrgId) ?? null,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||
<BackLink href="/admin" label={t("backToAdmin")} />
|
||||
<div className="mb-8 animate-in">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">{t("subtitle")}</p>
|
||||
</div>
|
||||
<PendingSkillRequests initialRows={rows} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,11 @@ import { getTranslations, getFormatter } from "next-intl/server";
|
||||
import { redirect, notFound } from "next/navigation";
|
||||
import { getTenant } from "@/lib/k8s";
|
||||
import { canUserSeeTenant } from "@/lib/visibility";
|
||||
import { getPendingResumeRequestForTenant } from "@/lib/db";
|
||||
import {
|
||||
getPendingResumeRequestForTenant,
|
||||
listSkillActivationRequestsForTenant,
|
||||
listSkillPricing,
|
||||
} from "@/lib/db";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { WarningBadge } from "@/components/ui/warning-badge";
|
||||
import { UsageDisplay } from "@/components/dashboard/usage-display";
|
||||
@@ -82,6 +86,17 @@ export default async function TenantDetailPage({
|
||||
);
|
||||
const channelUsers = tenant.spec.channelUsers || {};
|
||||
|
||||
// Phase 2.5: surface pending and most-recently-rejected skill
|
||||
// activation requests so PackageCard can render the inline
|
||||
// "Manual review pending" / "Activation rejected" states.
|
||||
// Pricing drives the cost-disclosure dialog before enable.
|
||||
// Both fetches are best-effort — an empty list is the safe
|
||||
// fallback if the DB call fails (cards just show normal toggles).
|
||||
const [activationRequests, skillPricing] = await Promise.all([
|
||||
listSkillActivationRequestsForTenant(name).catch(() => []),
|
||||
listSkillPricing().catch(() => []),
|
||||
]);
|
||||
|
||||
// Bug 19 fix: every viewer (customer or admin) passes the tenant
|
||||
// name to UsageDisplay. The /api/usage route resolves team+alias
|
||||
// from the tenant CR's status and applies the visibility check, so
|
||||
@@ -219,6 +234,8 @@ export default async function TenantDetailPage({
|
||||
enabledPackages={enabledPackages}
|
||||
conditions={tenant.status?.conditions}
|
||||
canEdit={canEdit}
|
||||
activationRequests={activationRequests}
|
||||
skillPricing={skillPricing}
|
||||
/>
|
||||
</section>
|
||||
|
||||
|
||||
155
src/app/api/admin/skills/pending/[id]/approve/route.ts
Normal file
155
src/app/api/admin/skills/pending/[id]/approve/route.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser, requirePlatformRole } from "@/lib/session";
|
||||
import {
|
||||
getSkillActivationRequestById,
|
||||
recordSkillEvents,
|
||||
updateSkillActivationRequestStatus,
|
||||
} from "@/lib/db";
|
||||
import { getTenant, patchTenantSpec } from "@/lib/k8s";
|
||||
import { getPackageDef } from "@/lib/packages";
|
||||
import { listOrgUsers } from "@/lib/zitadel";
|
||||
import { sendSkillActivationApprovalEmail } from "@/lib/email";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* POST /api/admin/skills/pending/[id]/approve
|
||||
*
|
||||
* Atomic-ish approval. Ordering:
|
||||
* 1. Load + sanity-check the request (must be pending).
|
||||
* 2. Patch the tenant CR to include the skill in spec.packages.
|
||||
* 3. Record the skill_event (kind=enabled) for billing.
|
||||
* 4. Flip the request row to 'approved'.
|
||||
* 5. Best-effort approval email to the requester.
|
||||
*
|
||||
* Step 2 is the irreversible one — if it succeeds but step 4 fails
|
||||
* we end up with a skill enabled in K8s but a still-pending request
|
||||
* row. That's a manual cleanup task; we log loudly so admin notices
|
||||
* via the queue page (the request would reappear there).
|
||||
*
|
||||
* The request must be in 'pending' status. Approving an already-
|
||||
* approved/rejected request returns 409.
|
||||
*
|
||||
* Body (optional): { adminNotes?: string }
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
let admin;
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
admin = await getSessionUser();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
if (!admin) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const { id } = await params;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const adminNotes =
|
||||
typeof body.adminNotes === "string" && body.adminNotes.length <= 1000
|
||||
? body.adminNotes
|
||||
: null;
|
||||
|
||||
// 1. Load + sanity-check.
|
||||
const req = await getSkillActivationRequestById(id);
|
||||
if (!req) {
|
||||
return NextResponse.json({ error: "Request not found" }, { status: 404 });
|
||||
}
|
||||
if (req.status !== "pending") {
|
||||
return NextResponse.json(
|
||||
{ error: `Request is already ${req.status}` },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Patch the tenant CR — add the skill if not already present.
|
||||
// Defensive: if the tenant was deleted or the skill was somehow
|
||||
// added by another path, we still proceed without duplicate.
|
||||
let tenant;
|
||||
try {
|
||||
tenant = await getTenant(req.tenantName);
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: `Tenant ${req.tenantName} not found: ${safeError(e, "")}` },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
if (!tenant) {
|
||||
return NextResponse.json(
|
||||
{ error: `Tenant ${req.tenantName} not found` },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const currentPackages = new Set<string>(tenant.spec.packages ?? []);
|
||||
const alreadyEnabled = currentPackages.has(req.skillId);
|
||||
if (!alreadyEnabled) {
|
||||
currentPackages.add(req.skillId);
|
||||
try {
|
||||
await patchTenantSpec(req.tenantName, {
|
||||
packages: [...currentPackages],
|
||||
});
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to enable skill on tenant: ${safeError(e, "")}` },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Record skill event (only if we actually added it — re-adding
|
||||
// would skew the day-count). Best-effort.
|
||||
if (!alreadyEnabled) {
|
||||
try {
|
||||
await recordSkillEvents(req.tenantName, req.zitadelOrgId, [req.skillId], []);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Failed to record skill_event after approve (request ${id}):`,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Flip request to approved.
|
||||
const updated = await updateSkillActivationRequestStatus(id, "approved", {
|
||||
reviewedBy: admin.id,
|
||||
adminNotes,
|
||||
});
|
||||
if (!updated) {
|
||||
// Race: another admin tab flipped it between our read and now.
|
||||
// The K8s patch already happened so we don't roll back; log so
|
||||
// the human notices.
|
||||
console.error(
|
||||
`Request ${id} was no longer pending when we tried to mark approved; K8s patch already applied.`
|
||||
);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Request status changed during approval; the skill may have been enabled. Check the queue.",
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Email the requester (best-effort). Look up their email via
|
||||
// ZITADEL since we only stored the userId on the request.
|
||||
try {
|
||||
const orgUsers = await listOrgUsers(req.zitadelOrgId);
|
||||
const requester = orgUsers.find((u) => u.userId === req.zitadelUserId);
|
||||
if (requester?.email) {
|
||||
const def = getPackageDef(req.skillId);
|
||||
await sendSkillActivationApprovalEmail({
|
||||
to: requester.email,
|
||||
contactName: requester.displayName || requester.email,
|
||||
skillName: def?.name ?? req.skillId,
|
||||
tenantName: req.tenantName,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Failed to send approval email for request ${id}:`, e);
|
||||
}
|
||||
|
||||
return NextResponse.json(updated);
|
||||
}
|
||||
101
src/app/api/admin/skills/pending/[id]/reject/route.ts
Normal file
101
src/app/api/admin/skills/pending/[id]/reject/route.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getSessionUser, requirePlatformRole } from "@/lib/session";
|
||||
import {
|
||||
getSkillActivationRequestById,
|
||||
updateSkillActivationRequestStatus,
|
||||
} from "@/lib/db";
|
||||
import { getPackageDef } from "@/lib/packages";
|
||||
import { listOrgUsers } from "@/lib/zitadel";
|
||||
import { sendSkillActivationRejectionEmail } from "@/lib/email";
|
||||
|
||||
/**
|
||||
* POST /api/admin/skills/pending/[id]/reject
|
||||
*
|
||||
* Reject a pending activation request with a required reason that
|
||||
* is shown to the customer (mirroring the tenant-request rejection
|
||||
* flow). The skill is NOT added to the tenant spec — it was never
|
||||
* there in the first place — so the customer's enable attempt is
|
||||
* effectively cancelled. They can try again from their tenant
|
||||
* settings after seeing the reason (a new pending row will be
|
||||
* created by their next toggle).
|
||||
*
|
||||
* Body:
|
||||
* {
|
||||
* reason: string (1..1000 chars, required),
|
||||
* adminNotes?: string (optional, not shown to customer)
|
||||
* }
|
||||
*/
|
||||
|
||||
const bodySchema = z.object({
|
||||
reason: z.string().min(1).max(1000),
|
||||
adminNotes: z.string().max(1000).optional(),
|
||||
});
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
let admin;
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
admin = await getSessionUser();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
if (!admin) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const { id } = await params;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = bodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const req = await getSkillActivationRequestById(id);
|
||||
if (!req) {
|
||||
return NextResponse.json({ error: "Request not found" }, { status: 404 });
|
||||
}
|
||||
if (req.status !== "pending") {
|
||||
return NextResponse.json(
|
||||
{ error: `Request is already ${req.status}` },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const updated = await updateSkillActivationRequestStatus(id, "rejected", {
|
||||
reviewedBy: admin.id,
|
||||
rejectionReason: parsed.data.reason,
|
||||
adminNotes: parsed.data.adminNotes ?? null,
|
||||
});
|
||||
if (!updated) {
|
||||
return NextResponse.json(
|
||||
{ error: "Request status changed during rejection." },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// Email the requester with the reason — best-effort.
|
||||
try {
|
||||
const orgUsers = await listOrgUsers(req.zitadelOrgId);
|
||||
const requester = orgUsers.find((u) => u.userId === req.zitadelUserId);
|
||||
if (requester?.email) {
|
||||
const def = getPackageDef(req.skillId);
|
||||
await sendSkillActivationRejectionEmail({
|
||||
to: requester.email,
|
||||
contactName: requester.displayName || requester.email,
|
||||
skillName: def?.name ?? req.skillId,
|
||||
tenantName: req.tenantName,
|
||||
reason: parsed.data.reason,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Failed to send rejection email for request ${id}:`, e);
|
||||
}
|
||||
|
||||
return NextResponse.json(updated);
|
||||
}
|
||||
22
src/app/api/admin/skills/pending/route.ts
Normal file
22
src/app/api/admin/skills/pending/route.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requirePlatformRole } from "@/lib/session";
|
||||
import { listPendingSkillActivationRequests } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* GET /api/admin/skills/pending
|
||||
*
|
||||
* List all pending skill-activation requests across all tenants
|
||||
* and orgs. Powers the admin queue at /admin/skills/pending.
|
||||
*
|
||||
* Platform-role only. Returns up to 500 rows oldest-first so the
|
||||
* queue UI shows the oldest requests at the top (FIFO).
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
const rows = await listPendingSkillActivationRequests();
|
||||
return NextResponse.json(rows);
|
||||
}
|
||||
23
src/app/api/skills/pricing/route.ts
Normal file
23
src/app/api/skills/pricing/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { listSkillPricing } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* GET /api/skills/pricing
|
||||
*
|
||||
* Returns the platform-wide skill pricing (daily price + setup fee
|
||||
* per skill) for display in the customer's cost-disclosure dialog
|
||||
* before they enable a priced skill. Any logged-in user can read
|
||||
* this — pricing isn't org-specific and is effectively public
|
||||
* information for anyone who'd be considering activation.
|
||||
*
|
||||
* Empty array means no skill is currently priced.
|
||||
*/
|
||||
export async function GET() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const rows = await listSkillPricing();
|
||||
return NextResponse.json(rows);
|
||||
}
|
||||
54
src/app/api/skills/requests/[id]/withdraw/route.ts
Normal file
54
src/app/api/skills/requests/[id]/withdraw/route.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import {
|
||||
getSkillActivationRequestById,
|
||||
updateSkillActivationRequestStatus,
|
||||
} from "@/lib/db";
|
||||
|
||||
/**
|
||||
* POST /api/skills/requests/[id]/withdraw
|
||||
*
|
||||
* The owner of a pending activation request can cancel it. This
|
||||
* doesn't touch K8s (the skill was never enabled) — it just flips
|
||||
* the row to 'withdrawn' so the user's UI clears the pending
|
||||
* state and they can try a different skill or retry later.
|
||||
*
|
||||
* Authorization: only the original requester OR a platform admin
|
||||
* can withdraw a request. We deliberately don't allow other org
|
||||
* members to cancel each other's requests in v1 — the partial
|
||||
* unique index would let one user repeatedly cancel another's
|
||||
* pending request.
|
||||
*/
|
||||
export async function POST(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const { id } = await params;
|
||||
const req = await getSkillActivationRequestById(id);
|
||||
if (!req) {
|
||||
return NextResponse.json({ error: "Request not found" }, { status: 404 });
|
||||
}
|
||||
if (!user.isPlatform && req.zitadelUserId !== user.id) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
if (req.status !== "pending") {
|
||||
return NextResponse.json(
|
||||
{ error: `Request is already ${req.status}` },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
const updated = await updateSkillActivationRequestStatus(id, "withdrawn", {
|
||||
reviewedBy: user.id,
|
||||
});
|
||||
if (!updated) {
|
||||
return NextResponse.json(
|
||||
{ error: "Request status changed during withdraw." },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json(updated);
|
||||
}
|
||||
40
src/app/api/skills/requests/route.ts
Normal file
40
src/app/api/skills/requests/route.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { listSkillActivationRequestsForTenant } from "@/lib/db";
|
||||
import { canUserSeeTenant } from "@/lib/visibility";
|
||||
import { getTenant } from "@/lib/k8s";
|
||||
|
||||
/**
|
||||
* GET /api/skills/requests?tenant=<name>
|
||||
*
|
||||
* Returns pending and most-recent-rejected skill activation
|
||||
* requests for the named tenant. Used by the tenant settings page
|
||||
* to render the "Manual review pending" or "Activation rejected"
|
||||
* inline states on PackageCard.
|
||||
*
|
||||
* Authorization: the caller must be able to see the tenant (owner
|
||||
* of its org, assigned user, or platform admin).
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const { searchParams } = new URL(request.url);
|
||||
const tenantName = searchParams.get("tenant");
|
||||
if (!tenantName) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing tenant parameter" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const tenant = await getTenant(tenantName).catch(() => null);
|
||||
if (!tenant) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
if (!canUserSeeTenant(user, tenant)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
const requests = await listSkillActivationRequestsForTenant(tenantName);
|
||||
return NextResponse.json(requests);
|
||||
}
|
||||
@@ -3,7 +3,12 @@ import { getSessionUser, canMutate } from "@/lib/session";
|
||||
import { canUserSeeTenant } from "@/lib/visibility";
|
||||
import { getTenant, patchTenantSpec } from "@/lib/k8s";
|
||||
import { getPackageDef } from "@/lib/packages";
|
||||
import { recordSkillEvents } from "@/lib/db";
|
||||
import {
|
||||
createSkillActivationRequest,
|
||||
getOrgBilling,
|
||||
recordSkillEvents,
|
||||
} from "@/lib/db";
|
||||
import { sendSkillActivationAdminNotification } from "@/lib/email";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
const ALLOWED_WORKSPACE_FILES = ["SOUL.md", "AGENTS.md", "TOOLS.md"];
|
||||
@@ -69,6 +74,17 @@ export async function PATCH(
|
||||
|
||||
const specPatch: Record<string, any> = {};
|
||||
|
||||
// Track manual-setup gate activations created during this PATCH.
|
||||
// We push to the K8s spec only the non-gated skills; the gated
|
||||
// ones live in skill_activation_requests until admin approves
|
||||
// and adds them via the admin endpoint. Platform admins bypass
|
||||
// the gate (direct enable from /admin still applies immediately).
|
||||
let gatedRequests: Array<{
|
||||
skillId: string;
|
||||
requestId: string;
|
||||
skillName: string;
|
||||
}> = [];
|
||||
|
||||
// ── Validate packages against catalog ──
|
||||
if (body.packages !== undefined) {
|
||||
if (!Array.isArray(body.packages) || body.packages.length > 10) {
|
||||
@@ -85,7 +101,63 @@ export async function PATCH(
|
||||
);
|
||||
}
|
||||
}
|
||||
specPatch.packages = body.packages;
|
||||
// Compute the to-be-added set against the existing spec.
|
||||
const existingPackages = new Set<string>(existing.spec.packages ?? []);
|
||||
const desiredPackages: string[] = body.packages;
|
||||
const newlyAdded = desiredPackages.filter(
|
||||
(p) => !existingPackages.has(p)
|
||||
);
|
||||
// Manual-setup gate. Customer adds get routed to the queue;
|
||||
// platform admins go straight through.
|
||||
if (!user.isPlatform && newlyAdded.length > 0) {
|
||||
const orgIdForGate =
|
||||
existing.metadata.labels?.["pieced.ch/zitadel-org-id"];
|
||||
if (!orgIdForGate) {
|
||||
// Defensive: every customer-visible tenant should have the
|
||||
// org label. Without it we can't attribute the request.
|
||||
return NextResponse.json(
|
||||
{ error: "Tenant missing org binding; contact support." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
const gatedSet = new Set<string>();
|
||||
for (const skillId of newlyAdded) {
|
||||
const def = getPackageDef(skillId);
|
||||
if (!def?.requiresManualSetup) continue;
|
||||
gatedSet.add(skillId);
|
||||
try {
|
||||
const req = await createSkillActivationRequest({
|
||||
tenantName: name,
|
||||
zitadelOrgId: orgIdForGate,
|
||||
zitadelUserId: user.id,
|
||||
skillId,
|
||||
});
|
||||
gatedRequests.push({
|
||||
skillId,
|
||||
requestId: req.id,
|
||||
skillName: def.name,
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (e?.code === "REQUEST_ALREADY_PENDING") {
|
||||
// Idempotent: a pending row already exists; just keep
|
||||
// the skill out of the K8s spec and surface it as
|
||||
// gated without creating a duplicate.
|
||||
gatedRequests.push({
|
||||
skillId,
|
||||
requestId: "",
|
||||
skillName: def.name,
|
||||
});
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Strip gated skills from the desired spec — they must not
|
||||
// reach K8s until approved.
|
||||
specPatch.packages = desiredPackages.filter((p) => !gatedSet.has(p));
|
||||
} else {
|
||||
specPatch.packages = desiredPackages;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Validate workspaceFiles ──
|
||||
@@ -232,7 +304,49 @@ export async function PATCH(
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(updated);
|
||||
// Phase 2.5: notify admin of newly created activation requests.
|
||||
// Best-effort — email failure must not poison the PATCH response.
|
||||
// requestId === "" means an existing-pending row was reused, so
|
||||
// skip the email in that case (admin already knows).
|
||||
if (gatedRequests.length > 0) {
|
||||
const orgIdForEmail =
|
||||
existing.metadata.labels?.["pieced.ch/zitadel-org-id"] ?? null;
|
||||
const companyName = orgIdForEmail
|
||||
? await getOrgBilling(orgIdForEmail)
|
||||
.then((b) => b?.companyName ?? null)
|
||||
.catch(() => null)
|
||||
: null;
|
||||
for (const g of gatedRequests) {
|
||||
if (!g.requestId) continue;
|
||||
try {
|
||||
await sendSkillActivationAdminNotification({
|
||||
tenantName: name,
|
||||
skillId: g.skillId,
|
||||
skillName: g.skillName,
|
||||
requesterEmail: user.email,
|
||||
requesterName: user.name,
|
||||
companyName,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Failed to send admin notification for skill activation request:`,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
...updated,
|
||||
// Phase 2.5: tells the client which requested-to-enable skills
|
||||
// didn't actually land in the spec because they're awaiting
|
||||
// admin approval. UI uses this to render the "pending review"
|
||||
// state on those skill cards.
|
||||
pendingActivationRequests: gatedRequests.map((g) => ({
|
||||
skillId: g.skillId,
|
||||
skillName: g.skillName,
|
||||
})),
|
||||
});
|
||||
} catch (e: any) {
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to update tenant") },
|
||||
|
||||
204
src/components/admin/skills/pending-skill-requests.tsx
Normal file
204
src/components/admin/skills/pending-skill-requests.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import { useState, Fragment } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import type { SkillActivationRequest } from "@/types";
|
||||
|
||||
interface RowData extends SkillActivationRequest {
|
||||
skillName: string;
|
||||
companyName: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
initialRows: RowData[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin queue table. Each row has Approve and Reject buttons.
|
||||
* Reject opens an inline reason input that must be filled before
|
||||
* the call goes through (the API also enforces this — empty
|
||||
* reasons are 400'd server-side).
|
||||
*
|
||||
* Actions hit the admin API endpoints, then router.refresh() to
|
||||
* re-render the server component with the new state (the row
|
||||
* disappears once flipped to approved/rejected).
|
||||
*/
|
||||
export function PendingSkillRequests({ initialRows }: Props) {
|
||||
const t = useTranslations("adminSkills");
|
||||
const router = useRouter();
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
// Per-row open-reject-input state. Key = request id.
|
||||
const [rejectingId, setRejectingId] = useState<string | null>(null);
|
||||
const [reasonText, setReasonText] = useState("");
|
||||
|
||||
const approve = async (id: string) => {
|
||||
setError("");
|
||||
setBusyId(id);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/skills/pending/${id}/approve`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const j = await res.json().catch(() => ({}));
|
||||
throw new Error(j.error || `HTTP ${res.status}`);
|
||||
}
|
||||
router.refresh();
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const reject = async (id: string) => {
|
||||
if (!reasonText.trim()) {
|
||||
setError(t("reasonRequired"));
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
setBusyId(id);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/skills/pending/${id}/reject`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ reason: reasonText }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const j = await res.json().catch(() => ({}));
|
||||
throw new Error(j.error || `HTTP ${res.status}`);
|
||||
}
|
||||
setRejectingId(null);
|
||||
setReasonText("");
|
||||
router.refresh();
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (initialRows.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<p className="text-sm text-text-muted italic text-center py-6">
|
||||
{t("emptyQueue")}
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
{error && (
|
||||
<div className="mb-3 p-3 rounded-md border border-error bg-error/10 text-sm text-error">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
<th className="pb-2">{t("requestedAtCol")}</th>
|
||||
<th className="pb-2">{t("skillCol")}</th>
|
||||
<th className="pb-2">{t("tenantCol")}</th>
|
||||
<th className="pb-2">{t("orgCol")}</th>
|
||||
<th className="pb-2 text-right">{t("actionsCol")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{initialRows.map((row) => (
|
||||
<Fragment key={row.id}>
|
||||
<tr className="border-t border-border align-top">
|
||||
<td className="py-2 text-xs text-text-muted font-mono">
|
||||
{row.requestedAt.slice(0, 16).replace("T", " ")}
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<div className="font-medium">{row.skillName}</div>
|
||||
<div className="text-xs text-text-muted font-mono">
|
||||
{row.skillId}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 font-mono text-xs">{row.tenantName}</td>
|
||||
<td className="py-2">
|
||||
<div className="text-xs">{row.companyName ?? "—"}</div>
|
||||
<div className="text-xs text-text-muted font-mono">
|
||||
{row.zitadelOrgId.slice(0, 16)}…
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
{rejectingId !== row.id && (
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setRejectingId(row.id);
|
||||
setReasonText("");
|
||||
setError("");
|
||||
}}
|
||||
disabled={busyId !== null}
|
||||
className="text-xs px-3 py-1.5 rounded-md border border-error text-error hover:bg-error/10 disabled:opacity-50"
|
||||
>
|
||||
{t("rejectBtn")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => approve(row.id)}
|
||||
disabled={busyId !== null}
|
||||
className="text-xs px-3 py-1.5 rounded-md bg-accent text-white disabled:opacity-50"
|
||||
>
|
||||
{busyId === row.id ? t("working") : t("approveBtn")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{rejectingId === row.id && (
|
||||
<tr className="border-t border-border bg-surface-2">
|
||||
<td colSpan={5} className="py-3 px-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs text-text-muted">
|
||||
{t("reasonLabel")}
|
||||
</label>
|
||||
<textarea
|
||||
value={reasonText}
|
||||
onChange={(e) => setReasonText(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={1000}
|
||||
placeholder={t("reasonPlaceholder")}
|
||||
className="w-full px-3 py-2 rounded-md border border-border bg-surface-1 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setRejectingId(null);
|
||||
setReasonText("");
|
||||
}}
|
||||
disabled={busyId !== null}
|
||||
className="text-xs px-3 py-1.5 rounded-md border border-border disabled:opacity-50"
|
||||
>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => reject(row.id)}
|
||||
disabled={busyId !== null || !reasonText.trim()}
|
||||
className="text-xs px-3 py-1.5 rounded-md bg-error text-white disabled:opacity-50"
|
||||
>
|
||||
{busyId === row.id
|
||||
? t("working")
|
||||
: t("confirmRejectBtn")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import type { PackageDef } from "@/lib/packages";
|
||||
import type {
|
||||
SkillActivationRequest,
|
||||
SkillPricing,
|
||||
} from "@/types";
|
||||
import { SkillCostDialog } from "./skill-cost-dialog";
|
||||
|
||||
interface Props {
|
||||
pkg: PackageDef;
|
||||
@@ -12,6 +18,18 @@ interface Props {
|
||||
onToggled: () => void;
|
||||
/** Slice 5: when false, the enable/disable button is hidden. */
|
||||
canEdit?: boolean;
|
||||
/**
|
||||
* Phase 2.5 — most recent non-terminal activation request for this
|
||||
* skill on this tenant, if any. Drives the "Manual review pending"
|
||||
* and "Activation rejected" inline states. Approved/withdrawn rows
|
||||
* never reach the client side.
|
||||
*/
|
||||
activationRequest?: SkillActivationRequest | null;
|
||||
/**
|
||||
* Phase 2.5 — pricing for this skill if it has any. Triggers the
|
||||
* cost-disclosure dialog before enable.
|
||||
*/
|
||||
pricing?: SkillPricing | null;
|
||||
}
|
||||
|
||||
export function PackageCard({
|
||||
@@ -21,15 +39,33 @@ export function PackageCard({
|
||||
tenantName,
|
||||
onToggled,
|
||||
canEdit = true,
|
||||
activationRequest = null,
|
||||
pricing = null,
|
||||
}: Props) {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [secrets, setSecrets] = useState<Record<string, string>>({});
|
||||
const [accepted, setAccepted] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Phase 2.5: cost-disclosure flow + activation-request flow.
|
||||
const [showCostDialog, setShowCostDialog] = useState(false);
|
||||
const isPriced =
|
||||
(pricing?.dailyPriceChf ?? 0) > 0 || (pricing?.setupFeeChf ?? 0) > 0;
|
||||
|
||||
async function handleEnable() {
|
||||
function handleEnable() {
|
||||
// Phase 2.5: gate priced skills behind the cost-disclosure dialog.
|
||||
// Confirm → proceedWithEnable. Cancel → bail.
|
||||
if (isPriced) {
|
||||
setError(null);
|
||||
setShowCostDialog(true);
|
||||
return;
|
||||
}
|
||||
void proceedWithEnable();
|
||||
}
|
||||
|
||||
async function proceedWithEnable() {
|
||||
if (pkg.customProvisioning) {
|
||||
// Platform-side provisioning, then add to packages list.
|
||||
setSaving(true);
|
||||
@@ -112,6 +148,39 @@ export function PackageCard({
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2.5: withdraw a still-pending activation request. The
|
||||
// request row flips to 'withdrawn' (server-side); router.refresh()
|
||||
// re-renders the tenant page without the pending state, leaving
|
||||
// the toggle re-enabled if the user wants to retry.
|
||||
async function withdrawRequest() {
|
||||
if (!activationRequest || activationRequest.status !== "pending") return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/skills/requests/${activationRequest.id}/withdraw`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error || `HTTP ${res.status}`);
|
||||
}
|
||||
router.refresh();
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2.5: retry after a rejection. Same flow as a fresh
|
||||
// enable; the rejected row stays in the DB as audit trail but a
|
||||
// new pending row will be created by the PATCH.
|
||||
function tryAgainAfterRejection() {
|
||||
setError(null);
|
||||
handleEnable();
|
||||
}
|
||||
|
||||
async function handleSubmitSecrets() {
|
||||
if (pkg.disclaimerKey && !accepted) return;
|
||||
|
||||
@@ -170,7 +239,38 @@ export function PackageCard({
|
||||
{pkg.requiresSecrets && (
|
||||
<span className="text-[10px] text-text-muted">{t("packages.requiresApiKey")}</span>
|
||||
)}
|
||||
{canEdit ? (
|
||||
{/* Phase 2.5: pending or rejected request takes precedence
|
||||
over the toggle. Approved/withdrawn never reach here. */}
|
||||
{canEdit && activationRequest?.status === "pending" ? (
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<span className="text-[10px] text-warning italic">
|
||||
{t("packages.manualReviewPending")}
|
||||
</span>
|
||||
<button
|
||||
onClick={withdrawRequest}
|
||||
disabled={saving}
|
||||
className="rounded-lg px-3 py-1.5 text-xs font-medium text-text-secondary hover:text-text-primary bg-surface-3 hover:bg-surface-2 disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{saving ? "…" : t("packages.withdraw")}
|
||||
</button>
|
||||
</div>
|
||||
) : canEdit && activationRequest?.status === "rejected" ? (
|
||||
<div className="ml-auto flex flex-col items-end gap-1">
|
||||
<span
|
||||
className="text-[10px] text-error italic max-w-[220px] truncate"
|
||||
title={activationRequest.rejectionReason ?? ""}
|
||||
>
|
||||
{t("packages.activationRejected")}: {activationRequest.rejectionReason}
|
||||
</span>
|
||||
<button
|
||||
onClick={tryAgainAfterRejection}
|
||||
disabled={saving}
|
||||
className="rounded-lg px-3 py-1.5 text-xs font-medium bg-accent text-surface-0 hover:bg-accent-dim disabled:opacity-50 cursor-pointer shadow-lg shadow-accent/20"
|
||||
>
|
||||
{saving ? "…" : t("packages.tryAgain")}
|
||||
</button>
|
||||
</div>
|
||||
) : canEdit ? (
|
||||
<button
|
||||
onClick={enabled ? handleDisable : handleEnable}
|
||||
disabled={saving}
|
||||
@@ -194,6 +294,20 @@ export function PackageCard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phase 2.5: cost-disclosure modal for priced skills. */}
|
||||
<SkillCostDialog
|
||||
open={showCostDialog}
|
||||
onClose={() => setShowCostDialog(false)}
|
||||
onConfirm={() => {
|
||||
setShowCostDialog(false);
|
||||
void proceedWithEnable();
|
||||
}}
|
||||
skillName={pkg.name}
|
||||
dailyPriceChf={pricing?.dailyPriceChf ?? 0}
|
||||
setupFeeChf={pricing?.setupFeeChf ?? 0}
|
||||
busy={saving}
|
||||
/>
|
||||
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||
<div className="w-full max-w-md bg-surface-1 border border-border rounded-2xl p-6 space-y-4 shadow-2xl shadow-black/40">
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { PACKAGE_CATALOG } from "@/lib/packages";
|
||||
import type {
|
||||
SkillActivationRequest,
|
||||
SkillPricing,
|
||||
} from "@/types";
|
||||
import { PackageCard } from "./package-card";
|
||||
|
||||
interface Props {
|
||||
@@ -12,6 +16,17 @@ interface Props {
|
||||
onRefresh?: () => void;
|
||||
/** Slice 5: when false, package toggles and edit affordances are hidden. */
|
||||
canEdit?: boolean;
|
||||
/**
|
||||
* Phase 2.5 — non-terminal activation requests for this tenant.
|
||||
* Each PackageCard looks up its skill in this array to render the
|
||||
* pending/rejected inline state. Most recent first.
|
||||
*/
|
||||
activationRequests?: SkillActivationRequest[];
|
||||
/**
|
||||
* Phase 2.5 — skill pricing keyed by skillId. Drives the cost
|
||||
* disclosure dialog.
|
||||
*/
|
||||
skillPricing?: SkillPricing[];
|
||||
}
|
||||
|
||||
const CATEGORIES = [
|
||||
@@ -39,11 +54,29 @@ export function PackageList({
|
||||
conditions,
|
||||
onRefresh,
|
||||
canEdit = true,
|
||||
activationRequests = [],
|
||||
skillPricing = [],
|
||||
}: Props) {
|
||||
const t = useTranslations("packages");
|
||||
const router = useRouter();
|
||||
const handleRefresh = onRefresh || (() => router.refresh());
|
||||
|
||||
// Build per-skill lookups once so each card render is O(1) rather
|
||||
// than O(N) over the requests array. `activationRequests` already
|
||||
// arrives filtered to non-terminal rows (most-recent per
|
||||
// (skill, status) pair from the server).
|
||||
const requestBySkill = new Map<string, SkillActivationRequest>();
|
||||
for (const req of activationRequests) {
|
||||
// Pending takes precedence over rejected — if both exist for
|
||||
// the same skill (race or after-rejection-retry), show pending.
|
||||
const existing = requestBySkill.get(req.skillId);
|
||||
if (!existing || (existing.status === "rejected" && req.status === "pending")) {
|
||||
requestBySkill.set(req.skillId, req);
|
||||
}
|
||||
}
|
||||
const pricingBySkill = new Map<string, SkillPricing>();
|
||||
for (const p of skillPricing) pricingBySkill.set(p.skillId, p);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{CATEGORIES.map(({ key, labelKey }) => {
|
||||
@@ -65,6 +98,8 @@ export function PackageList({
|
||||
tenantName={tenantName}
|
||||
onToggled={handleRefresh}
|
||||
canEdit={canEdit}
|
||||
activationRequest={requestBySkill.get(pkg.id) ?? null}
|
||||
pricing={pricingBySkill.get(pkg.id) ?? null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
108
src/components/packages/skill-cost-dialog.tsx
Normal file
108
src/components/packages/skill-cost-dialog.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
skillName: string;
|
||||
dailyPriceChf: number;
|
||||
setupFeeChf: number;
|
||||
busy?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cost-disclosure modal shown before activating a priced skill.
|
||||
*
|
||||
* Shows the daily rate and setup fee (each only if > 0) and
|
||||
* requires an explicit Confirm before the activation request goes
|
||||
* through. Rendered every time the user toggles on a priced skill,
|
||||
* not once-and-remember — this is recurring-charge consent, not a
|
||||
* one-time terms agreement.
|
||||
*
|
||||
* The setup fee is always shown when configured, with a note
|
||||
* clarifying it's "one-time, charged on first activation". The
|
||||
* backend (billing.ts tenantSkillHasBeenBilled) is the authority
|
||||
* on whether the fee actually fires — we don't second-guess from
|
||||
* the client. If you've previously activated this skill on this
|
||||
* tenant, the fee won't appear on the next invoice even though
|
||||
* the dialog mentions it.
|
||||
*/
|
||||
export function SkillCostDialog({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
skillName,
|
||||
dailyPriceChf,
|
||||
setupFeeChf,
|
||||
busy = false,
|
||||
}: Props) {
|
||||
const t = useTranslations("skillCostDialog");
|
||||
const showSetupFee = setupFeeChf > 0;
|
||||
const showDaily = dailyPriceChf > 0;
|
||||
// Nothing to disclose? Bail to confirm immediately — shouldn't
|
||||
// normally be shown in this case but guard anyway.
|
||||
if (!showSetupFee && !showDaily) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} ariaLabel={t("title")}>
|
||||
<div className="bg-surface-1 rounded-lg border border-border p-6 max-w-md w-full">
|
||||
<h2 className="text-lg font-semibold mb-2">{t("title")}</h2>
|
||||
<p className="text-sm text-text-secondary mb-4">
|
||||
{t("intro", { skill: skillName })}
|
||||
</p>
|
||||
|
||||
<div className="rounded-md bg-surface-2 border border-border p-4 mb-4 space-y-2">
|
||||
{showSetupFee && (
|
||||
<div className="flex justify-between items-baseline">
|
||||
<div>
|
||||
<div className="text-sm">{t("setupFeeLabel")}</div>
|
||||
<div className="text-xs text-text-muted">
|
||||
{t("setupFeeNote")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm font-mono">
|
||||
CHF {setupFeeChf.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showDaily && (
|
||||
<div className="flex justify-between items-baseline">
|
||||
<div>
|
||||
<div className="text-sm">{t("dailyPriceLabel")}</div>
|
||||
<div className="text-xs text-text-muted">
|
||||
{t("dailyPriceNote")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm font-mono">
|
||||
CHF {dailyPriceChf.toFixed(2)} / {t("dayUnit")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-text-muted mb-4">{t("disclaimer")}</p>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
className="px-4 py-2 rounded-md border border-border text-sm disabled:opacity-50"
|
||||
>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={busy}
|
||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
||||
>
|
||||
{busy ? t("confirming") : t("confirm")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
203
src/lib/db.ts
203
src/lib/db.ts
@@ -530,6 +530,39 @@ const MIGRATION_SQL = `
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_invoice_reminders_invoice
|
||||
ON invoice_reminders(invoice_id, level);
|
||||
|
||||
-- Phase 2.5: queue for skills flagged with requiresManualSetup in
|
||||
-- the package catalog. A user-side enable on a flagged skill
|
||||
-- creates a pending row here instead of mutating tenant.spec.packages;
|
||||
-- the operator never sees the skill until admin approves and adds
|
||||
-- it to the spec. Disable is always direct — there's no gate on
|
||||
-- turning a skill off, even one that previously required setup.
|
||||
CREATE TABLE IF NOT EXISTS skill_activation_requests (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_name TEXT NOT NULL,
|
||||
zitadel_org_id TEXT NOT NULL,
|
||||
zitadel_user_id TEXT NOT NULL,
|
||||
skill_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected', 'withdrawn')) DEFAULT 'pending',
|
||||
requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
reviewed_at TIMESTAMPTZ,
|
||||
reviewed_by TEXT,
|
||||
rejection_reason TEXT,
|
||||
admin_notes TEXT
|
||||
);
|
||||
-- Only one in-flight request per (tenant, skill). Rejected and
|
||||
-- approved rows don't block new requests; user can retry after a
|
||||
-- rejection by toggling the skill again.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uniq_skill_act_one_pending
|
||||
ON skill_activation_requests (tenant_name, skill_id)
|
||||
WHERE status = 'pending';
|
||||
-- Admin queue lookup — partial index keeps it tiny.
|
||||
CREATE INDEX IF NOT EXISTS idx_skill_act_pending_status
|
||||
ON skill_activation_requests (requested_at DESC)
|
||||
WHERE status = 'pending';
|
||||
-- Per-tenant lookup for the customer UI's pending+rejected display.
|
||||
CREATE INDEX IF NOT EXISTS idx_skill_act_tenant
|
||||
ON skill_activation_requests (tenant_name, requested_at DESC);
|
||||
`;
|
||||
|
||||
let migrated = false;
|
||||
@@ -2590,3 +2623,173 @@ export async function updateInvoicePdf(
|
||||
[invoiceId, pdfBuffer, filename]
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skill activation requests — Phase 2.5
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import type {
|
||||
SkillActivationRequest,
|
||||
SkillActivationStatus,
|
||||
} from "@/types";
|
||||
|
||||
function rowToSkillActivationRequest(row: any): SkillActivationRequest {
|
||||
return {
|
||||
id: row.id,
|
||||
tenantName: row.tenant_name,
|
||||
zitadelOrgId: row.zitadel_org_id,
|
||||
zitadelUserId: row.zitadel_user_id,
|
||||
skillId: row.skill_id,
|
||||
status: row.status as SkillActivationStatus,
|
||||
requestedAt: row.requested_at?.toISOString?.() ?? row.requested_at,
|
||||
reviewedAt: row.reviewed_at?.toISOString?.() ?? row.reviewed_at ?? null,
|
||||
reviewedBy: row.reviewed_by ?? null,
|
||||
rejectionReason: row.rejection_reason ?? null,
|
||||
adminNotes: row.admin_notes ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a pending activation request. Throws a tagged error if a
|
||||
* pending row already exists for the (tenant, skill) — the partial
|
||||
* unique index enforces this. The caller surfaces "request already
|
||||
* pending" to the user rather than letting it 500.
|
||||
*/
|
||||
export async function createSkillActivationRequest(params: {
|
||||
tenantName: string;
|
||||
zitadelOrgId: string;
|
||||
zitadelUserId: string;
|
||||
skillId: string;
|
||||
}): Promise<SkillActivationRequest> {
|
||||
await ensureSchema();
|
||||
try {
|
||||
const result = await getPool().query(
|
||||
`INSERT INTO skill_activation_requests
|
||||
(tenant_name, zitadel_org_id, zitadel_user_id, skill_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING *`,
|
||||
[
|
||||
params.tenantName,
|
||||
params.zitadelOrgId,
|
||||
params.zitadelUserId,
|
||||
params.skillId,
|
||||
]
|
||||
);
|
||||
return rowToSkillActivationRequest(result.rows[0]);
|
||||
} catch (e: any) {
|
||||
if (e?.code === "23505") {
|
||||
const err = new Error(
|
||||
`A pending activation request already exists for ${params.skillId} on ${params.tenantName}.`
|
||||
);
|
||||
(err as any).code = "REQUEST_ALREADY_PENDING";
|
||||
throw err;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSkillActivationRequestById(
|
||||
id: string
|
||||
): Promise<SkillActivationRequest | null> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
"SELECT * FROM skill_activation_requests WHERE id = $1",
|
||||
[id]
|
||||
);
|
||||
return result.rows.length > 0
|
||||
? rowToSkillActivationRequest(result.rows[0])
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* All pending requests across all tenants — feeds the admin queue
|
||||
* page. Capped to 500 rows for safety; unlikely to ever be hit but
|
||||
* keeps the page bounded.
|
||||
*/
|
||||
export async function listPendingSkillActivationRequests(): Promise<
|
||||
SkillActivationRequest[]
|
||||
> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`SELECT * FROM skill_activation_requests
|
||||
WHERE status = 'pending'
|
||||
ORDER BY requested_at ASC
|
||||
LIMIT 500`
|
||||
);
|
||||
return result.rows.map(rowToSkillActivationRequest);
|
||||
}
|
||||
|
||||
export async function countPendingSkillActivationRequests(): Promise<number> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
"SELECT COUNT(*)::int AS c FROM skill_activation_requests WHERE status = 'pending'"
|
||||
);
|
||||
return result.rows[0]?.c ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests visible to a customer for one tenant. Returns:
|
||||
* - pending: rows the user might want to withdraw
|
||||
* - rejected: the most recent rejection per skill, so the user
|
||||
* sees why and can retry
|
||||
* Approved and withdrawn rows are excluded (terminal states with
|
||||
* no user-visible UI effect after the fact).
|
||||
*/
|
||||
export async function listSkillActivationRequestsForTenant(
|
||||
tenantName: string
|
||||
): Promise<SkillActivationRequest[]> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`WITH ranked AS (
|
||||
SELECT *,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY skill_id, status
|
||||
ORDER BY requested_at DESC
|
||||
) AS rn
|
||||
FROM skill_activation_requests
|
||||
WHERE tenant_name = $1
|
||||
AND status IN ('pending', 'rejected')
|
||||
)
|
||||
SELECT * FROM ranked WHERE rn = 1
|
||||
ORDER BY requested_at DESC`,
|
||||
[tenantName]
|
||||
);
|
||||
return result.rows.map(rowToSkillActivationRequest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition a request's status. The caller is responsible for the
|
||||
* side effects (K8s patch on approve, email send) — this function
|
||||
* only touches the row.
|
||||
*
|
||||
* Returns null when the row doesn't exist or isn't in 'pending'
|
||||
* status. That null is meaningful: it tells the caller the
|
||||
* transition didn't happen (already approved/rejected by another
|
||||
* admin tab, etc.) and downstream actions should be skipped.
|
||||
*/
|
||||
export async function updateSkillActivationRequestStatus(
|
||||
id: string,
|
||||
newStatus: Exclude<SkillActivationStatus, "pending">,
|
||||
opts: {
|
||||
reviewedBy: string;
|
||||
rejectionReason?: string | null;
|
||||
adminNotes?: string | null;
|
||||
}
|
||||
): Promise<SkillActivationRequest | null> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`UPDATE skill_activation_requests
|
||||
SET status = $2,
|
||||
reviewed_at = now(),
|
||||
reviewed_by = $3,
|
||||
rejection_reason = $4,
|
||||
admin_notes = COALESCE($5, admin_notes)
|
||||
WHERE id = $1
|
||||
AND status = 'pending'
|
||||
RETURNING *`,
|
||||
[id, newStatus, opts.reviewedBy, opts.rejectionReason ?? null, opts.adminNotes ?? null]
|
||||
);
|
||||
return result.rows.length > 0
|
||||
? rowToSkillActivationRequest(result.rows[0])
|
||||
: null;
|
||||
}
|
||||
|
||||
177
src/lib/email.ts
177
src/lib/email.ts
@@ -723,3 +723,180 @@ export async function sendSupportAdminNotificationEmail(params: {
|
||||
console.error("Failed to send admin support notification:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skill activation requests — Phase 2.5
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Three notifications:
|
||||
//
|
||||
// sendSkillActivationAdminNotification — to ADMIN_NOTIFICATION_EMAIL
|
||||
// when a customer requests a
|
||||
// flagged skill.
|
||||
//
|
||||
// sendSkillActivationApprovalEmail — to the customer, on approve.
|
||||
//
|
||||
// sendSkillActivationRejectionEmail — to the customer, on reject,
|
||||
// including the admin's reason.
|
||||
//
|
||||
// All three follow the existing patterns in this file (HTML + plaintext,
|
||||
// escaped vars, best-effort with errors logged not thrown).
|
||||
|
||||
/**
|
||||
* Notify admin (ADMIN_NOTIFICATION_EMAIL) that a customer has
|
||||
* requested activation of a manual-setup skill. The skill name +
|
||||
* tenant + requester are all included so admin can act without
|
||||
* loading the portal.
|
||||
*/
|
||||
export async function sendSkillActivationAdminNotification(params: {
|
||||
tenantName: string;
|
||||
skillId: string;
|
||||
skillName: string;
|
||||
requesterEmail: string;
|
||||
requesterName: string;
|
||||
companyName: string | null;
|
||||
}): Promise<void> {
|
||||
const adminEmail = process.env.ADMIN_NOTIFICATION_EMAIL;
|
||||
if (!adminEmail) return;
|
||||
const safeTenant = escapeHtml(params.tenantName);
|
||||
const safeSkillId = escapeHtml(params.skillId);
|
||||
const safeSkillName = escapeHtml(params.skillName);
|
||||
const safeRequester = escapeHtml(params.requesterName);
|
||||
const safeRequesterEmail = escapeHtml(params.requesterEmail);
|
||||
const safeCompany = params.companyName
|
||||
? escapeHtml(params.companyName)
|
||||
: "—";
|
||||
try {
|
||||
await getTransporter().sendMail({
|
||||
from: getFrom(),
|
||||
to: adminEmail,
|
||||
subject: `[PieCed] Skill activation requested — ${params.skillName} on ${params.tenantName}`,
|
||||
text: [
|
||||
"A customer has requested activation of a manual-setup skill.",
|
||||
"",
|
||||
`Skill: ${params.skillName} (${params.skillId})`,
|
||||
`Tenant: ${params.tenantName}`,
|
||||
`Organization:${params.companyName ?? "—"}`,
|
||||
`Requested by:${params.requesterName} <${params.requesterEmail}>`,
|
||||
"",
|
||||
"Review and act in the admin queue:",
|
||||
"https://app.pieced.ch/admin/skills/pending",
|
||||
].join("\n"),
|
||||
html: `
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width: 560px; padding: 24px; background: #1a1a1a; color: #e5e5e5;">
|
||||
<h2 style="margin: 0 0 16px; color: #10B981;">Skill activation requested</h2>
|
||||
<p>A customer has requested activation of a manual-setup skill.</p>
|
||||
<table style="width:100%; border-collapse: collapse; margin: 12px 0;">
|
||||
<tr><td style="color:#888; padding:4px 0;">Skill</td><td>${safeSkillName} (<code>${safeSkillId}</code>)</td></tr>
|
||||
<tr><td style="color:#888; padding:4px 0;">Tenant</td><td><code>${safeTenant}</code></td></tr>
|
||||
<tr><td style="color:#888; padding:4px 0;">Organization</td><td>${safeCompany}</td></tr>
|
||||
<tr><td style="color:#888; padding:4px 0;">Requested by</td><td>${safeRequester} <${safeRequesterEmail}></td></tr>
|
||||
</table>
|
||||
<p>
|
||||
<a href="https://app.pieced.ch/admin/skills/pending" style="display:inline-block; padding:10px 24px; background:#10B981; color:#fff; text-decoration:none; border-radius:8px; font-weight:500;">
|
||||
Open admin queue
|
||||
</a>
|
||||
</p>
|
||||
<hr style="border:none; border-top:1px solid #333; margin:24px 0;" />
|
||||
<p style="color:#666; font-size:12px;">PieCed IT — Admin notification</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to send skill activation admin notification:", err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendSkillActivationApprovalEmail(params: {
|
||||
to: string;
|
||||
contactName: string;
|
||||
skillName: string;
|
||||
tenantName: string;
|
||||
}): Promise<void> {
|
||||
const safeName = escapeHtml(params.contactName);
|
||||
const safeSkill = escapeHtml(params.skillName);
|
||||
const safeTenant = escapeHtml(params.tenantName);
|
||||
try {
|
||||
await getTransporter().sendMail({
|
||||
from: getFrom(),
|
||||
to: params.to,
|
||||
subject: `Your skill activation has been approved — ${params.skillName}`,
|
||||
text: [
|
||||
`Hello ${params.contactName},`,
|
||||
"",
|
||||
`Good news — your request to activate "${params.skillName}" on tenant ${params.tenantName} has been approved and the skill is now live.`,
|
||||
"",
|
||||
"You can manage it from your tenant settings.",
|
||||
"",
|
||||
"Best regards,",
|
||||
"PieCed IT",
|
||||
].join("\n"),
|
||||
html: `
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width:560px; padding:24px; background:#1a1a1a; color:#e5e5e5;">
|
||||
<h2 style="margin:0 0 16px; color:#10B981;">Skill approved & activated</h2>
|
||||
<p>Hello ${safeName},</p>
|
||||
<p>Your request to activate <strong>${safeSkill}</strong> on tenant <code>${safeTenant}</code> has been approved and the skill is now live.</p>
|
||||
<p>You can manage it from your tenant settings.</p>
|
||||
<p>
|
||||
<a href="https://app.pieced.ch/tenants/${encodeURIComponent(params.tenantName)}" style="display:inline-block; padding:10px 24px; background:#10B981; color:#fff; text-decoration:none; border-radius:8px; font-weight:500;">
|
||||
Open tenant
|
||||
</a>
|
||||
</p>
|
||||
<hr style="border:none; border-top:1px solid #333; margin:24px 0;" />
|
||||
<p style="color:#666; font-size:12px;">PieCed IT</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to send skill activation approval email:", err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendSkillActivationRejectionEmail(params: {
|
||||
to: string;
|
||||
contactName: string;
|
||||
skillName: string;
|
||||
tenantName: string;
|
||||
reason: string;
|
||||
}): Promise<void> {
|
||||
const safeName = escapeHtml(params.contactName);
|
||||
const safeSkill = escapeHtml(params.skillName);
|
||||
const safeTenant = escapeHtml(params.tenantName);
|
||||
const safeReason = escapeHtml(params.reason);
|
||||
try {
|
||||
await getTransporter().sendMail({
|
||||
from: getFrom(),
|
||||
to: params.to,
|
||||
subject: `Update on your skill activation request — ${params.skillName}`,
|
||||
text: [
|
||||
`Hello ${params.contactName},`,
|
||||
"",
|
||||
`We were unable to approve your request to activate "${params.skillName}" on tenant ${params.tenantName}.`,
|
||||
"",
|
||||
"Reason from our team:",
|
||||
params.reason,
|
||||
"",
|
||||
"You can try again from your tenant settings once the matter is resolved.",
|
||||
"",
|
||||
"Best regards,",
|
||||
"PieCed IT",
|
||||
].join("\n"),
|
||||
html: `
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width:560px; padding:24px; background:#1a1a1a; color:#e5e5e5;">
|
||||
<h2 style="margin:0 0 16px; color:#ef4444;">Activation request not approved</h2>
|
||||
<p>Hello ${safeName},</p>
|
||||
<p>We were unable to approve your request to activate <strong>${safeSkill}</strong> on tenant <code>${safeTenant}</code>.</p>
|
||||
<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>Reason from our team:</strong></p>
|
||||
<p style="color:#aaa; font-size:13px; margin:8px 0 0 0; white-space:pre-wrap;">${safeReason}</p>
|
||||
</div>
|
||||
<p>You can try again from your tenant settings once the matter is resolved.</p>
|
||||
<hr style="border:none; border-top:1px solid #333; margin:24px 0;" />
|
||||
<p style="color:#666; font-size:12px;">PieCed IT</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to send skill activation rejection email:", err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,25 @@ export interface PackageDef {
|
||||
* that the customer is not aware of.
|
||||
*/
|
||||
customProvisioning?: boolean;
|
||||
/**
|
||||
* When true, customer-initiated enable requests are routed through
|
||||
* an admin approval queue (skill_activation_requests) instead of
|
||||
* being applied immediately. Platform-side manual work (hardware
|
||||
* provisioning, third-party account setup, DNS, etc.) happens
|
||||
* between request and approval, so we keep the tenant out of the
|
||||
* spec until that work is done and the operator would otherwise
|
||||
* fail to reconcile.
|
||||
*
|
||||
* Platform admins bypass the gate (direct PATCH from /admin still
|
||||
* applies immediately). Disable is always direct — there's no
|
||||
* gate on turning a skill off.
|
||||
*
|
||||
* Orthogonal to `requiresSecrets` and `customProvisioning`. A skill
|
||||
* can have all three: customer provides credentials, the secrets
|
||||
* are stored, the activation request lands in the admin queue,
|
||||
* admin does the manual work, then approves.
|
||||
*/
|
||||
requiresManualSetup?: boolean;
|
||||
}
|
||||
|
||||
export const PACKAGE_CATALOG: PackageDef[] = [
|
||||
|
||||
@@ -311,7 +311,11 @@
|
||||
"description": "Senden und empfangen Sie Nachrichten über Threema. Jede eingehende und ausgehende Nachricht läuft über den gemeinsamen PieCed-Messaging-Dienst und verursacht eine Gebühr pro Nachricht bei Threema — eine Drittanbieter-Kostenposition, unabhängig von Ihrem PieCed-Abonnement.",
|
||||
"instructions": "1. Aktivieren Sie dieses Paket.\n2. Öffnen Sie Threema auf Ihrem Telefon, scannen Sie den QR-Code unter Autorisierte Benutzer → threema und akzeptieren Sie den Kontakt.\n3. Tragen Sie Ihre eigene Threema-ID unter Autorisierte Benutzer → threema ein, damit der Assistent Ihre Nachrichten erkennt.\n4. Schreiben Sie eine Nachricht aus Threema, um das Gespräch zu beginnen.",
|
||||
"disclaimer": "Nachrichten zwischen Threema und PieCed werden Ende-zu-Ende verschlüsselt bis zum PieCed-Messaging-Dienst, wo sie entschlüsselt und an Ihren Assistenten weitergeleitet werden. Jede gesendete oder empfangene Nachricht wird gemäss Threema-Tarif pro Nachricht abgerechnet — die aktuellen Preise finden Sie in Ihrem Plan."
|
||||
}
|
||||
},
|
||||
"manualReviewPending": "Manuelle Prüfung ausstehend",
|
||||
"withdraw": "Zurückziehen",
|
||||
"activationRejected": "Abgelehnt",
|
||||
"tryAgain": "Erneut versuchen"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Plattform-Admin",
|
||||
@@ -385,7 +389,8 @@
|
||||
"resumeRequestBadge": "Wieder",
|
||||
"resumeRequestTooltip": "Reaktivierungsanfrage für einen bestehenden Tenant. Bei Genehmigung wird der Tenant wieder aktiviert; keine Provisionierung läuft.",
|
||||
"openclawTool": "OpenClaw-Versionen",
|
||||
"billingTool": "Abrechnung →"
|
||||
"billingTool": "Abrechnung →",
|
||||
"skillsQueueTool": "Skill-Warteschlange"
|
||||
},
|
||||
"channelUsers": {
|
||||
"title": "Autorisierte Benutzer",
|
||||
@@ -656,5 +661,37 @@
|
||||
"billToSnapshotTitle": "Rechnungsempfänger",
|
||||
"setupFeeCol": "Einrichtungsgebühr",
|
||||
"skillSetupFeeLabel": "Einrichtungsgebühr"
|
||||
},
|
||||
"skillCostDialog": {
|
||||
"title": "Aktivierungskosten bestätigen",
|
||||
"intro": "Die Aktivierung von {skill} verursacht folgende Kosten:",
|
||||
"setupFeeLabel": "Einrichtungsgebühr",
|
||||
"setupFeeNote": "Einmalig, nur bei erster Aktivierung",
|
||||
"dailyPriceLabel": "Tagespreis",
|
||||
"dailyPriceNote": "Pro Kalendertag (UTC) berechnet, an dem der Skill aktiviert ist",
|
||||
"dayUnit": "Tag",
|
||||
"disclaimer": "Diese Kosten erscheinen auf Ihrer nächsten Monatsrechnung. Mit der Bestätigung stimmen Sie ihnen zu.",
|
||||
"cancel": "Abbrechen",
|
||||
"confirm": "Bestätigen & aktivieren",
|
||||
"confirming": "Aktiviere…"
|
||||
},
|
||||
"adminSkills": {
|
||||
"title": "Skill-Aktivierungs-Warteschlange",
|
||||
"subtitle": "Kundenanfragen für Skills, die manuelle plattformseitige Einrichtung benötigen. Genehmigen, sobald die Konfiguration steht; ablehnen mit Grund, wenn die Aktivierung nicht möglich ist.",
|
||||
"backToAdmin": "Zurück zur Verwaltung",
|
||||
"emptyQueue": "Keine ausstehenden Skill-Aktivierungsanfragen.",
|
||||
"requestedAtCol": "Angefragt",
|
||||
"skillCol": "Skill",
|
||||
"tenantCol": "Tenant",
|
||||
"orgCol": "Organisation",
|
||||
"actionsCol": "",
|
||||
"approveBtn": "Genehmigen",
|
||||
"rejectBtn": "Ablehnen",
|
||||
"confirmRejectBtn": "Ablehnung bestätigen",
|
||||
"working": "Arbeite…",
|
||||
"cancel": "Abbrechen",
|
||||
"reasonLabel": "Grund (wird dem Kunden angezeigt)",
|
||||
"reasonPlaceholder": "Erklären Sie, warum die Aktivierung nicht erfolgen kann — z. B. fehlende Kundendaten, Hardware nicht verfügbar usw.",
|
||||
"reasonRequired": "Ein Grund ist für die Ablehnung erforderlich."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,7 +311,11 @@
|
||||
"description": "Send and receive messages through Threema. Each inbound and outbound message uses the shared PieCed messaging service and incurs a per-message charge from Threema — a third-party cost, separate from your PieCed subscription.",
|
||||
"instructions": "1. Enable this package.\n2. Open Threema on your phone, scan the QR code shown under Authorized Users → threema, and accept the contact.\n3. Add your own Threema ID under Authorized Users → threema so the assistant recognises your messages.\n4. Send a message from Threema to start chatting with the assistant.",
|
||||
"disclaimer": "Messages between Threema and PieCed are end-to-end encrypted up to PieCed's messaging service, where they are decrypted to be routed to your assistant. Each message sent or received is counted toward Threema's per-message billing — see your plan for current rates."
|
||||
}
|
||||
},
|
||||
"manualReviewPending": "Manual review pending",
|
||||
"withdraw": "Withdraw",
|
||||
"activationRejected": "Rejected",
|
||||
"tryAgain": "Try again"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Platform Admin",
|
||||
@@ -385,7 +389,8 @@
|
||||
"resumeRequestBadge": "Resume",
|
||||
"resumeRequestTooltip": "Reactivation request for an existing tenant. Approving will un-suspend the tenant; no provisioning runs.",
|
||||
"openclawTool": "OpenClaw versions",
|
||||
"billingTool": "Billing →"
|
||||
"billingTool": "Billing →",
|
||||
"skillsQueueTool": "Skills Queue"
|
||||
},
|
||||
"channelUsers": {
|
||||
"title": "Authorized Users",
|
||||
@@ -656,5 +661,37 @@
|
||||
"billToSnapshotTitle": "Billed to",
|
||||
"setupFeeCol": "Setup fee",
|
||||
"skillSetupFeeLabel": "Setup fee"
|
||||
},
|
||||
"skillCostDialog": {
|
||||
"title": "Confirm activation cost",
|
||||
"intro": "Activating {skill} will incur the following charges:",
|
||||
"setupFeeLabel": "Setup fee",
|
||||
"setupFeeNote": "One-time, charged on first activation only",
|
||||
"dailyPriceLabel": "Daily price",
|
||||
"dailyPriceNote": "Charged for each calendar day (UTC) the skill is enabled",
|
||||
"dayUnit": "day",
|
||||
"disclaimer": "These charges appear on your next monthly invoice. By confirming you agree to incur them.",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm & activate",
|
||||
"confirming": "Activating…"
|
||||
},
|
||||
"adminSkills": {
|
||||
"title": "Skill activation queue",
|
||||
"subtitle": "Customer requests to activate skills that need manual platform-side setup. Approve once configuration is in place; reject with a reason if the activation can't proceed.",
|
||||
"backToAdmin": "Back to Admin",
|
||||
"emptyQueue": "No pending skill activation requests.",
|
||||
"requestedAtCol": "Requested",
|
||||
"skillCol": "Skill",
|
||||
"tenantCol": "Tenant",
|
||||
"orgCol": "Organization",
|
||||
"actionsCol": "",
|
||||
"approveBtn": "Approve",
|
||||
"rejectBtn": "Reject",
|
||||
"confirmRejectBtn": "Confirm rejection",
|
||||
"working": "Working…",
|
||||
"cancel": "Cancel",
|
||||
"reasonLabel": "Reason (shown to the customer)",
|
||||
"reasonPlaceholder": "Explain why this can't be activated — e.g. missing customer data, hardware unavailable, etc.",
|
||||
"reasonRequired": "A reason is required to reject."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,7 +311,11 @@
|
||||
"description": "Envoyez et recevez des messages via Threema. Chaque message entrant ou sortant transite par le service de messagerie PieCed partagé et entraîne des frais par message facturés par Threema — un coût tiers, distinct de votre abonnement PieCed.",
|
||||
"instructions": "1. Activez ce package.\n2. Ouvrez Threema sur votre téléphone, scannez le QR code affiché dans Utilisateurs autorisés → threema, puis acceptez le contact.\n3. Ajoutez votre propre identifiant Threema sous Utilisateurs autorisés → threema afin que l'assistant reconnaisse vos messages.\n4. Envoyez un message depuis Threema pour commencer la conversation.",
|
||||
"disclaimer": "Les messages entre Threema et PieCed sont chiffrés de bout en bout jusqu'au service de messagerie PieCed, où ils sont déchiffrés pour être acheminés vers votre assistant. Chaque message envoyé ou reçu est facturé par Threema selon son tarif par message — consultez votre plan pour les tarifs en vigueur."
|
||||
}
|
||||
},
|
||||
"manualReviewPending": "Revue manuelle en attente",
|
||||
"withdraw": "Retirer",
|
||||
"activationRejected": "Refusée",
|
||||
"tryAgain": "Réessayer"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Admin plateforme",
|
||||
@@ -385,7 +389,8 @@
|
||||
"resumeRequestBadge": "Reprise",
|
||||
"resumeRequestTooltip": "Demande de réactivation d'un locataire existant. L'approbation le réactivera ; aucun provisionnement ne s'exécute.",
|
||||
"openclawTool": "Versions OpenClaw",
|
||||
"billingTool": "Facturation →"
|
||||
"billingTool": "Facturation →",
|
||||
"skillsQueueTool": "File des skills"
|
||||
},
|
||||
"channelUsers": {
|
||||
"title": "Utilisateurs autorisés",
|
||||
@@ -656,5 +661,37 @@
|
||||
"billToSnapshotTitle": "Destinataire",
|
||||
"setupFeeCol": "Frais de configuration",
|
||||
"skillSetupFeeLabel": "Frais de configuration"
|
||||
},
|
||||
"skillCostDialog": {
|
||||
"title": "Confirmer le coût d'activation",
|
||||
"intro": "L'activation de {skill} entraînera les frais suivants :",
|
||||
"setupFeeLabel": "Frais de configuration",
|
||||
"setupFeeNote": "Unique, facturé uniquement à la première activation",
|
||||
"dailyPriceLabel": "Prix journalier",
|
||||
"dailyPriceNote": "Facturé pour chaque jour calendaire (UTC) où le skill est activé",
|
||||
"dayUnit": "jour",
|
||||
"disclaimer": "Ces frais figureront sur votre prochaine facture mensuelle. En confirmant, vous acceptez de les engager.",
|
||||
"cancel": "Annuler",
|
||||
"confirm": "Confirmer & activer",
|
||||
"confirming": "Activation…"
|
||||
},
|
||||
"adminSkills": {
|
||||
"title": "File d'activation des skills",
|
||||
"subtitle": "Demandes clients d'activation de skills nécessitant une configuration manuelle côté plateforme. Approuver une fois la configuration en place ; refuser avec un motif si l'activation est impossible.",
|
||||
"backToAdmin": "Retour à l'administration",
|
||||
"emptyQueue": "Aucune demande d'activation en attente.",
|
||||
"requestedAtCol": "Demandée le",
|
||||
"skillCol": "Skill",
|
||||
"tenantCol": "Tenant",
|
||||
"orgCol": "Organisation",
|
||||
"actionsCol": "",
|
||||
"approveBtn": "Approuver",
|
||||
"rejectBtn": "Refuser",
|
||||
"confirmRejectBtn": "Confirmer le refus",
|
||||
"working": "En cours…",
|
||||
"cancel": "Annuler",
|
||||
"reasonLabel": "Motif (visible par le client)",
|
||||
"reasonPlaceholder": "Expliquez pourquoi l'activation ne peut pas se faire — ex. données client manquantes, matériel indisponible, etc.",
|
||||
"reasonRequired": "Un motif est requis pour refuser."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,7 +311,11 @@
|
||||
"description": "Invia e ricevi messaggi tramite Threema. Ogni messaggio in entrata e in uscita passa attraverso il servizio di messaggistica condiviso di PieCed e comporta un addebito per messaggio da parte di Threema — un costo di terzi, separato dall'abbonamento PieCed.",
|
||||
"instructions": "1. Attiva questo pacchetto.\n2. Apri Threema sul tuo telefono, scansiona il QR code mostrato in Utenti autorizzati → threema e accetta il contatto.\n3. Aggiungi il tuo ID Threema sotto Utenti autorizzati → threema affinché l'assistente riconosca i tuoi messaggi.\n4. Invia un messaggio da Threema per iniziare la conversazione.",
|
||||
"disclaimer": "I messaggi tra Threema e PieCed sono cifrati end-to-end fino al servizio di messaggistica PieCed, dove vengono decifrati per essere inoltrati al tuo assistente. Ogni messaggio inviato o ricevuto viene addebitato da Threema secondo la sua tariffa per messaggio — consulta il tuo piano per i prezzi attuali."
|
||||
}
|
||||
},
|
||||
"manualReviewPending": "Revisione manuale in attesa",
|
||||
"withdraw": "Ritira",
|
||||
"activationRejected": "Rifiutata",
|
||||
"tryAgain": "Riprova"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Admin piattaforma",
|
||||
@@ -385,7 +389,8 @@
|
||||
"resumeRequestBadge": "Ripresa",
|
||||
"resumeRequestTooltip": "Richiesta di riattivazione di un tenant esistente. L'approvazione lo riattiverà; non viene eseguito alcun provisioning.",
|
||||
"openclawTool": "Versioni OpenClaw",
|
||||
"billingTool": "Fatturazione →"
|
||||
"billingTool": "Fatturazione →",
|
||||
"skillsQueueTool": "Coda skill"
|
||||
},
|
||||
"channelUsers": {
|
||||
"title": "Utenti autorizzati",
|
||||
@@ -656,5 +661,37 @@
|
||||
"billToSnapshotTitle": "Destinatario",
|
||||
"setupFeeCol": "Spese di attivazione",
|
||||
"skillSetupFeeLabel": "Spese di attivazione"
|
||||
},
|
||||
"skillCostDialog": {
|
||||
"title": "Conferma costi di attivazione",
|
||||
"intro": "L'attivazione di {skill} comporterà i seguenti costi:",
|
||||
"setupFeeLabel": "Spese di attivazione",
|
||||
"setupFeeNote": "Una tantum, addebitate solo alla prima attivazione",
|
||||
"dailyPriceLabel": "Prezzo giornaliero",
|
||||
"dailyPriceNote": "Addebitato per ogni giorno di calendario (UTC) in cui lo skill è attivato",
|
||||
"dayUnit": "giorno",
|
||||
"disclaimer": "Questi costi appariranno sulla prossima fattura mensile. Confermando accetti di sostenerli.",
|
||||
"cancel": "Annulla",
|
||||
"confirm": "Conferma & attiva",
|
||||
"confirming": "Attivazione…"
|
||||
},
|
||||
"adminSkills": {
|
||||
"title": "Coda di attivazione skill",
|
||||
"subtitle": "Richieste dei clienti per attivare skill che richiedono configurazione manuale lato piattaforma. Approva quando la configurazione è pronta; rifiuta con motivazione se l'attivazione non è possibile.",
|
||||
"backToAdmin": "Torna ad amministrazione",
|
||||
"emptyQueue": "Nessuna richiesta di attivazione skill in attesa.",
|
||||
"requestedAtCol": "Richiesta",
|
||||
"skillCol": "Skill",
|
||||
"tenantCol": "Tenant",
|
||||
"orgCol": "Organizzazione",
|
||||
"actionsCol": "",
|
||||
"approveBtn": "Approva",
|
||||
"rejectBtn": "Rifiuta",
|
||||
"confirmRejectBtn": "Conferma rifiuto",
|
||||
"working": "In corso…",
|
||||
"cancel": "Annulla",
|
||||
"reasonLabel": "Motivo (mostrato al cliente)",
|
||||
"reasonPlaceholder": "Spiega perché l'attivazione non può procedere — es. dati cliente mancanti, hardware non disponibile, ecc.",
|
||||
"reasonRequired": "Un motivo è necessario per rifiutare."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -655,3 +655,40 @@ export interface InvoiceDraft {
|
||||
*/
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skill activation requests — manual provisioning queue
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SkillActivationStatus =
|
||||
| "pending"
|
||||
| "approved"
|
||||
| "rejected"
|
||||
| "withdrawn";
|
||||
|
||||
/**
|
||||
* A customer-initiated request to enable a flagged-as-manual-setup
|
||||
* skill on a specific tenant. Lifecycle:
|
||||
*
|
||||
* pending → approved (admin clicks Approve; skill added to spec)
|
||||
* pending → rejected (admin clicks Reject with reason)
|
||||
* pending → withdrawn (owner cancels their own request)
|
||||
*
|
||||
* Approved and withdrawn rows are kept for audit but don't block
|
||||
* new pending requests on the same (tenant, skill). The unique
|
||||
* partial index allows at most one row in 'pending' status per
|
||||
* (tenant_name, skill_id).
|
||||
*/
|
||||
export interface SkillActivationRequest {
|
||||
id: string;
|
||||
tenantName: string;
|
||||
zitadelOrgId: string;
|
||||
zitadelUserId: string;
|
||||
skillId: string;
|
||||
status: SkillActivationStatus;
|
||||
requestedAt: string;
|
||||
reviewedAt: string | null;
|
||||
reviewedBy: string | null;
|
||||
rejectionReason: string | null;
|
||||
adminNotes: string | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user