diff --git a/src/app/[locale]/admin/page.tsx b/src/app/[locale]/admin/page.tsx index 96ab7b5..63322ca 100644 --- a/src/app/[locale]/admin/page.tsx +++ b/src/app/[locale]/admin/page.tsx @@ -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 (
@@ -33,6 +40,21 @@ export default async function AdminPage() { than nav-shell entries — these are platform-team utilities, not main navigation. */}
+ 0 + ? "border-warning text-warning hover:bg-warning/10" + : "border-border text-text-secondary hover:text-text-primary hover:border-text-secondary" + }`} + > + {t("skillsQueueTool")} + {pendingSkillCount > 0 && ( + + {pendingSkillCount} + + )} + (); + 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 ( +
+ +
+

+ {t("title")} +

+

{t("subtitle")}

+
+ +
+ ); +} diff --git a/src/app/[locale]/tenants/[name]/page.tsx b/src/app/[locale]/tenants/[name]/page.tsx index 66e0357..e0444c1 100644 --- a/src/app/[locale]/tenants/[name]/page.tsx +++ b/src/app/[locale]/tenants/[name]/page.tsx @@ -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} /> diff --git a/src/app/api/admin/skills/pending/[id]/approve/route.ts b/src/app/api/admin/skills/pending/[id]/approve/route.ts new file mode 100644 index 0000000..b5edfae --- /dev/null +++ b/src/app/api/admin/skills/pending/[id]/approve/route.ts @@ -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(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); +} diff --git a/src/app/api/admin/skills/pending/[id]/reject/route.ts b/src/app/api/admin/skills/pending/[id]/reject/route.ts new file mode 100644 index 0000000..35216a7 --- /dev/null +++ b/src/app/api/admin/skills/pending/[id]/reject/route.ts @@ -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); +} diff --git a/src/app/api/admin/skills/pending/route.ts b/src/app/api/admin/skills/pending/route.ts new file mode 100644 index 0000000..7f3d0c8 --- /dev/null +++ b/src/app/api/admin/skills/pending/route.ts @@ -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); +} diff --git a/src/app/api/skills/pricing/route.ts b/src/app/api/skills/pricing/route.ts new file mode 100644 index 0000000..1fc577c --- /dev/null +++ b/src/app/api/skills/pricing/route.ts @@ -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); +} diff --git a/src/app/api/skills/requests/[id]/withdraw/route.ts b/src/app/api/skills/requests/[id]/withdraw/route.ts new file mode 100644 index 0000000..6c3e828 --- /dev/null +++ b/src/app/api/skills/requests/[id]/withdraw/route.ts @@ -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); +} diff --git a/src/app/api/skills/requests/route.ts b/src/app/api/skills/requests/route.ts new file mode 100644 index 0000000..63e4031 --- /dev/null +++ b/src/app/api/skills/requests/route.ts @@ -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= + * + * 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); +} diff --git a/src/app/api/tenants/[name]/route.ts b/src/app/api/tenants/[name]/route.ts index 3199ca5..1fd731e 100644 --- a/src/app/api/tenants/[name]/route.ts +++ b/src/app/api/tenants/[name]/route.ts @@ -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 = {}; + // 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(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(); + 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") }, diff --git a/src/components/admin/skills/pending-skill-requests.tsx b/src/components/admin/skills/pending-skill-requests.tsx new file mode 100644 index 0000000..c7f25a3 --- /dev/null +++ b/src/components/admin/skills/pending-skill-requests.tsx @@ -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(null); + const [error, setError] = useState(""); + // Per-row open-reject-input state. Key = request id. + const [rejectingId, setRejectingId] = useState(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 ( + +

+ {t("emptyQueue")} +

+
+ ); + } + + return ( + + {error && ( +
+ {error} +
+ )} + + + + + + + + + + + + {initialRows.map((row) => ( + + + + + + + + + {rejectingId === row.id && ( + +
{t("requestedAtCol")}{t("skillCol")}{t("tenantCol")}{t("orgCol")}{t("actionsCol")}
+ {row.requestedAt.slice(0, 16).replace("T", " ")} + +
{row.skillName}
+
+ {row.skillId} +
+
{row.tenantName} +
{row.companyName ?? "—"}
+
+ {row.zitadelOrgId.slice(0, 16)}… +
+
+ {rejectingId !== row.id && ( +
+ + +
+ )} +
+
+ +