Files
kandev/apps/web/components/settings/plugins/plugin-manifest-card.tsx
T
João Salavisa d54c2effef fix(i18n): address #2249 review findings (#2279)
Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-08-05 14:39:01 +02:00

148 lines
5.0 KiB
TypeScript

"use client";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Badge } from "@kandev/ui/badge";
import { Button } from "@kandev/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
import { formatDateTime } from "@/lib/i18n/formats";
import type { PluginRecord } from "@/lib/types/plugins";
/**
* Read-only view of the plugin's manifest: identity, capabilities, declared
* webhooks, and a collapsible raw JSON dump for anything the summary
* rows don't surface.
*/
export function PluginManifestCard({ plugin }: { plugin: PluginRecord }) {
const { t } = useTranslation();
const [showRaw, setShowRaw] = useState(false);
return (
<Card data-testid="plugin-manifest-card">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base">{t("plugins:manifest")}</CardTitle>
<Button
variant="ghost"
size="sm"
className="cursor-pointer"
onClick={() => setShowRaw((v) => !v)}
>
{showRaw ? t("plugins:hideRaw") : t("plugins:viewRaw")}
</Button>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
{/* Row VALUES are manifest data — the plugin id, its version, its
author — and are never translated. Only the row labels are copy. */}
<ManifestRow label={t("plugins:manifestId")} value={plugin.id} mono />
<ManifestRow label={t("plugins:manifestVersion")} value={plugin.version} mono />
<ManifestRow label={t("plugins:manifestApiVersion")} value={String(plugin.api_version)} />
<ManifestRow label={t("plugins:manifestAuthor")} value={plugin.author || "—"} />
<ManifestRow
label={t("plugins:manifestSigned")}
value={plugin.signed ? t("plugins:yes") : t("plugins:no")}
/>
<ManifestRow
label={t("plugins:manifestInstalled")}
value={formatInstalledAt(plugin.installed_at)}
/>
</div>
<CapabilityBadges plugin={plugin} />
<DeclarationList
label={t("plugins:manifestWebhooks")}
items={(plugin.webhooks ?? []).map((w) => ({ key: w.key, text: w.key }))}
/>
{showRaw && (
<pre
data-testid="plugin-manifest-raw"
className="rounded-md border border-border/70 bg-muted/40 p-3 text-xs overflow-x-auto"
>
{JSON.stringify(manifestOnly(plugin), null, 2)}
</pre>
)}
</CardContent>
</Card>
);
}
function ManifestRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex items-baseline justify-between gap-3 sm:justify-start">
<span className="text-muted-foreground w-28 shrink-0">{label}</span>
<span className={mono ? "font-mono text-xs truncate" : "truncate"}>{value}</span>
</div>
);
}
function CapabilityBadges({ plugin }: { plugin: PluginRecord }) {
const { t } = useTranslation();
const caps = plugin.capabilities ?? {};
const badges: string[] = [
...(caps.events ?? []).map((e) => `events:${e}`),
...(caps.api_read ?? []).map((r) => `read:${r}`),
...(caps.api_write ?? []).map((w) => `write:${w}`),
...(caps.state ? ["state"] : []),
...(caps.secrets ? ["secrets"] : []),
];
if (badges.length === 0) return null;
return (
<div className="space-y-1.5">
{/* The badges are permission scopes (`events:*`, `read:*`) — the
contract the plugin declared, so they stay verbatim. */}
<div className="text-sm text-muted-foreground">{t("plugins:manifestCapabilities")}</div>
<div className="flex flex-wrap gap-1">
{badges.map((badge) => (
<Badge key={badge} variant="secondary" className="text-[11px] font-mono">
{badge}
</Badge>
))}
</div>
</div>
);
}
function DeclarationList({
label,
items,
}: {
label: string;
items: { key: string; text: string }[];
}) {
if (items.length === 0) return null;
return (
<div className="space-y-1.5">
<div className="text-sm text-muted-foreground">{label}</div>
<div className="flex flex-wrap gap-1">
{items.map((item) => (
<Badge key={item.key} variant="outline" className="text-[11px]">
{item.text}
</Badge>
))}
</div>
</div>
);
}
/** The manifest-declared fields only — runtime bookkeeping (status, install
* path, restart counter, and failure diagnostic) is shown elsewhere and just
* noise here. */
function manifestOnly(plugin: PluginRecord): Record<string, unknown> {
const {
status: _s,
install_path: _i,
restart_count: _r,
installed_at: _a,
last_error: _e,
last_error_at: _ea,
...manifest
} = plugin;
return manifest;
}
function formatInstalledAt(installedAt: string): string {
const date = new Date(installedAt);
return Number.isNaN(date.getTime()) ? installedAt : formatDateTime(date);
}