386 lines
14 KiB
TypeScript
386 lines
14 KiB
TypeScript
"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;
|
|
enabled: boolean;
|
|
status?: "pending" | "active" | "error";
|
|
tenantName: string;
|
|
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({
|
|
pkg,
|
|
enabled,
|
|
status,
|
|
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;
|
|
|
|
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);
|
|
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);
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
|
|
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<string, string> = {
|
|
pending: "text-warning",
|
|
active: "text-success",
|
|
error: "text-error",
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="bg-surface-1 border border-border rounded-xl p-5 flex flex-col gap-3">
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-medium text-text-primary">{pkg.name}</span>
|
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-text-muted bg-surface-3 px-1.5 py-0.5 rounded">
|
|
{pkg.category}
|
|
</span>
|
|
</div>
|
|
<p className="text-xs text-text-secondary mt-1">{t(pkg.descriptionKey)}</p>
|
|
</div>
|
|
{enabled && status && (
|
|
<span className={`text-xs font-medium ${statusColors[status] || ""}`}>
|
|
{t(`packages.status.${status}`)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between mt-auto pt-3 border-t border-border">
|
|
{pkg.requiresSecrets && (
|
|
<span className="text-[10px] text-text-muted">{t("packages.requiresApiKey")}</span>
|
|
)}
|
|
{/* Phase 2.5: pending or rejected request takes precedence
|
|
over the toggle. Approved/withdrawn never reach here.
|
|
For packages that needed secrets, surface that they're
|
|
safely stored — the user might otherwise worry the
|
|
credentials they typed got lost when the activation
|
|
was deferred. */}
|
|
{canEdit && activationRequest?.status === "pending" ? (
|
|
<div className="ml-auto flex flex-col items-end gap-1">
|
|
<span
|
|
className="text-[10px] text-warning italic"
|
|
title={pkg.requiresSecrets ? t("packages.credentialsSavedTip") : undefined}
|
|
>
|
|
{t("packages.manualReviewPending")}
|
|
{pkg.requiresSecrets && (
|
|
<span className="text-text-muted ml-1 not-italic">
|
|
· {t("packages.credentialsSaved")}
|
|
</span>
|
|
)}
|
|
</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}
|
|
className={`ml-auto rounded-lg px-3 py-1.5 text-xs font-medium transition-all cursor-pointer ${
|
|
enabled
|
|
? "bg-surface-3 text-text-secondary hover:text-text-primary hover:bg-surface-2"
|
|
: "bg-accent text-surface-0 hover:bg-accent-dim shadow-lg shadow-accent/20"
|
|
} disabled:opacity-50`}
|
|
>
|
|
{saving ? "…" : enabled ? t("packages.disable") : t("packages.enable")}
|
|
</button>
|
|
) : (
|
|
// 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.
|
|
<span className="ml-auto text-[10px] text-text-muted italic">
|
|
{enabled ? t("packages.statusEnabled") : t("packages.statusDisabled")}
|
|
</span>
|
|
)}
|
|
</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">
|
|
<h3 className="font-display text-base font-semibold text-text-primary">
|
|
{t("packages.configure")} {pkg.name}
|
|
</h3>
|
|
|
|
{pkg.instructionsKey && (
|
|
<div className="bg-surface-2 border border-border rounded-lg p-3 text-xs text-text-secondary leading-relaxed whitespace-pre-line">
|
|
{t(pkg.instructionsKey)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-3">
|
|
{(pkg.secrets || []).map((s) => (
|
|
<label key={s.key} className="block">
|
|
<span className="text-xs text-text-secondary mb-1 block">{t(s.labelKey)}</span>
|
|
<input
|
|
type="password"
|
|
placeholder={t(s.placeholderKey)}
|
|
value={secrets[s.key] || ""}
|
|
onChange={(e) => setSecrets((p) => ({ ...p, [s.key]: e.target.value }))}
|
|
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-accent focus:outline-none"
|
|
/>
|
|
</label>
|
|
))}
|
|
</div>
|
|
|
|
{pkg.disclaimerKey && (
|
|
<label className="flex items-start gap-2 text-xs text-text-secondary">
|
|
<input
|
|
type="checkbox"
|
|
checked={accepted}
|
|
onChange={(e) => setAccepted(e.target.checked)}
|
|
className="mt-0.5 accent-accent"
|
|
/>
|
|
<span>{t(pkg.disclaimerKey)}</span>
|
|
</label>
|
|
)}
|
|
|
|
{error && <p className="text-xs text-error">{error}</p>}
|
|
|
|
<div className="flex justify-end gap-2 pt-2">
|
|
<button
|
|
onClick={() => setShowModal(false)}
|
|
className="rounded-lg px-3 py-1.5 text-xs text-text-secondary hover:text-text-primary cursor-pointer"
|
|
>
|
|
{t("common.cancel")}
|
|
</button>
|
|
<button
|
|
onClick={handleSubmitSecrets}
|
|
disabled={saving || (!!pkg.disclaimerKey && !accepted)}
|
|
className="rounded-lg bg-accent px-4 py-1.5 text-xs font-medium text-surface-0 hover:bg-accent-dim disabled:opacity-50 cursor-pointer shadow-lg shadow-accent/20"
|
|
>
|
|
{saving ? "…" : t("packages.enableAndSave")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|