"use client"; import { useTranslations } from "next-intl"; import { useState } from "react"; import type { PackageDef } from "@/lib/packages"; interface Props { pkg: PackageDef; enabled: boolean; status?: "pending" | "active" | "error"; tenantName: string; onToggled: () => void; /** Slice 5: when false, the enable/disable button is hidden. */ canEdit?: boolean; } export function PackageCard({ pkg, enabled, status, tenantName, onToggled, canEdit = true, }: Props) { const t = useTranslations(); const [showModal, setShowModal] = useState(false); const [secrets, setSecrets] = useState>({}); const [accepted, setAccepted] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); async function handleEnable() { if (pkg.customProvisioning) { // Platform-side provisioning, then add to packages list. setSaving(true); setError(null); try { const provRes = await fetch(`/api/tenants/${tenantName}/${pkg.id}`, { method: "POST", }); if (!provRes.ok) { const err = await provRes.json().catch(() => ({})); throw new Error(err.error || `Provisioning failed (HTTP ${provRes.status})`); } await togglePackage(true); } catch (e: any) { setError(e.message); } finally { setSaving(false); } return; } if (pkg.requiresSecrets) { setShowModal(true); setSecrets({}); setAccepted(false); setError(null); return; } await togglePackage(true); } async function handleDisable() { setSaving(true); setError(null); try { if (pkg.customProvisioning) { // Revoke platform-side credentials FIRST so the relay drops // routes before the operator removes the channel from the // OpenClaw config. Partial-success (token revoked, OpenBao // delete failed) returns 503 with partial=true and we surface // the error rather than continuing — the secret may still be // valid in OpenBao and rolling back the relay revoke isn't // possible (it cascaded to routes). const deprovRes = await fetch(`/api/tenants/${tenantName}/${pkg.id}`, { method: "DELETE", }); if (!deprovRes.ok) { const err = await deprovRes.json().catch(() => ({})); throw new Error(err.error || `Deprovisioning failed (HTTP ${deprovRes.status})`); } } await togglePackage(false); } catch (e: any) { setError(e.message); } finally { setSaving(false); } } async function togglePackage(enable: boolean) { setSaving(true); try { const res = await fetch(`/api/tenants/${tenantName}`); const tenant = await res.json(); const current: string[] = tenant.spec?.packages || []; const next = enable ? [...current, pkg.id] : current.filter((p: string) => p !== pkg.id); const patchRes = await fetch(`/api/tenants/${tenantName}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ packages: next }), }); if (!patchRes.ok) throw new Error("Failed to update packages"); onToggled(); } catch (e: any) { setError(e.message); } finally { setSaving(false); } } async function handleSubmitSecrets() { if (pkg.disclaimerKey && !accepted) return; const required = (pkg.secrets || []).map((s) => s.key); const missing = required.filter((k) => !secrets[k]?.trim()); if (missing.length) { setError(t("packages.missingFields")); return; } setSaving(true); setError(null); try { const secretRes = await fetch(`/api/tenants/${tenantName}/secrets`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ packageId: pkg.id, secrets }), }); if (!secretRes.ok) { const err = await secretRes.json(); throw new Error(err.error || "Failed to store secrets"); } await togglePackage(true); setShowModal(false); } catch (e: any) { setError(e.message); } finally { setSaving(false); } } const statusColors: Record = { pending: "text-warning", active: "text-success", error: "text-error", }; return ( <>
{pkg.name} {pkg.category}

{t(pkg.descriptionKey)}

{enabled && status && ( {t(`packages.status.${status}`)} )}
{pkg.requiresSecrets && ( {t("packages.requiresApiKey")} )} {canEdit ? ( ) : ( // Slice 5: read-only viewers see a static badge instead of a // toggle. The status badge above the divider already conveys // "active/pending/error"; this just clarifies "you can't change // it" without duplicating the status colour. {enabled ? t("packages.statusEnabled") : t("packages.statusDisabled")} )}
{showModal && (

{t("packages.configure")} {pkg.name}

{pkg.instructionsKey && (
{t(pkg.instructionsKey)}
)}
{(pkg.secrets || []).map((s) => ( ))}
{pkg.disclaimerKey && ( )} {error &&

{error}

}
)} ); }