Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/lucky-labelers-admin-moderation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@emdash-cms/admin": patch
---

The registry browser now shows moderation state: blocked releases are visibly blocked with an explanation, warning-labelled releases ask for confirmation before install, and the plugin manager flags blocked or warned updates.
43 changes: 43 additions & 0 deletions packages/admin/src/components/CapabilityConsentDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,29 @@ import { describeCapability } from "../lib/api/marketplace.js";
import { cn } from "../lib/utils.js";
import { DialogError } from "./DialogError.js";

/** A moderation label, pre-resolved to localized display text by the caller. */
export interface ModerationLabelEntry {
value: string;
name: string;
description: string | null;
/** DID of the labeler that issued this label. */
issuerDid: string;
}

export interface CapabilityConsentDialogProps {
/** Dialog mode */
mode?: "install" | "update";
/** Plugin display name */
pluginName: string;
/** Capabilities the plugin requests */
capabilities: string[];
/**
* Active moderation warning labels on the release being installed/updated.
* Shown above the capabilities list whenever non-empty -- including when
* `capabilities` is empty, in which case the dialog shows only this
* section.
*/
moderationWarnings?: ModerationLabelEntry[];
/** Allowed network hosts (for network:fetch capability) */
allowedHosts?: string[];
/** New capabilities added in an update (highlighted differently) */
Expand All @@ -44,6 +60,7 @@ export function CapabilityConsentDialog({
mode,
pluginName,
capabilities,
moderationWarnings = [],
allowedHosts,
newCapabilities = [],
newlyPublicRoutes = [],
Expand Down Expand Up @@ -81,6 +98,32 @@ export function CapabilityConsentDialog({
</p>
</div>

{/* Moderation warnings */}
{moderationWarnings.length > 0 && (
<div className="px-6 pt-4">
<div
className="rounded-md border border-kumo-warning bg-kumo-warning/10 p-3"
role="status"
>
<div className="flex items-center gap-2 text-sm font-medium text-kumo-warning">
<Warning className="h-4 w-4 shrink-0" />
{t`Moderation warnings`}
</div>
<ul className="mt-2 space-y-2 text-sm">
{moderationWarnings.map((warning) => (
<li key={`${warning.value}-${warning.issuerDid}`}>
<p className="font-medium text-kumo-default">{warning.name}</p>
{warning.description ? (
<p className="text-kumo-subtle">{warning.description}</p>
) : null}
<p className="text-xs text-kumo-subtle">{t`Issued by ${warning.issuerDid}`}</p>
</li>
))}
</ul>
</div>
</div>
)}

{/* Capabilities list */}
<div className="px-6 py-4 space-y-3">
{capabilities.map((cap) => {
Expand Down
35 changes: 31 additions & 4 deletions packages/admin/src/components/PluginManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* update/uninstall for marketplace-installed plugins.
*/

import { Badge, Button, Checkbox, Switch, Toast } from "@cloudflare/kumo";
import { Badge, Button, Checkbox, Switch, Toast, Tooltip } from "@cloudflare/kumo";
import { plural } from "@lingui/core/macro";
import { useLingui } from "@lingui/react/macro";
import {
Expand Down Expand Up @@ -41,6 +41,7 @@ import {
} from "../lib/api/marketplace.js";
import {
RegistryUpdateEscalationError,
describeRegistryModerationError,
uninstallRegistryPlugin,
updateRegistryPlugin,
type RegistryUpdateOpts,
Expand Down Expand Up @@ -242,6 +243,13 @@ function PluginCard({
const isMarketplace = plugin.source === "marketplace";
const isRegistry = plugin.source === "registry";
const hasUpdate = !!updateInfo && updateInfo.installed !== updateInfo.latest;
// Never key off `updateInfo.moderation.eligibility` -- with no accepted
// labeler having passed a release, `eligibility` reads "blocked" even for
// a clean plugin. See the field's origin JSDoc on `RegistryUpdateCheck`
// in packages/core's registry handler.
const moderationBlockingLabels = updateInfo?.moderation?.blockingLabels ?? [];
const moderationWarningLabels = updateInfo?.moderation?.warningLabels ?? [];
const isUpdateModerationBlocked = moderationBlockingLabels.length > 0;

const updateMutation = useMutation({
mutationFn: (opts: RegistryUpdateOpts) =>
Expand Down Expand Up @@ -353,11 +361,17 @@ function PluginCard({
<span className="text-xs text-kumo-subtle">v{plugin.version}</span>
{!plugin.enabled && <Badge variant="secondary">{t`Disabled`}</Badge>}
{isMarketplace && <Badge variant="secondary">{t`Marketplace`}</Badge>}
{hasUpdate && (
{hasUpdate && isUpdateModerationBlocked && (
<Badge variant="error">{t`Update blocked`}</Badge>
)}
{hasUpdate && !isUpdateModerationBlocked && (
<Badge variant="outline" className="border-kumo-brand text-kumo-brand">
{t`v${updateInfo.latest} available`}
</Badge>
)}
{hasUpdate && !isUpdateModerationBlocked && moderationWarningLabels.length > 0 && (
<Badge variant="warning">{t`Update has warnings`}</Badge>
)}
</div>

{/* Description */}
Expand Down Expand Up @@ -407,7 +421,19 @@ function PluginCard({

{/* Actions */}
<div className="flex items-center gap-2">
{hasUpdate && (
{hasUpdate && isUpdateModerationBlocked && (
<Tooltip
content={t`A moderation label blocks this update. Review the plugin's registry listing for details.`}
render={
<span>
<Button variant="outline" size="sm" disabled>
{t`Update to v${updateInfo.latest}`}
</Button>
</span>
}
/>
)}
{hasUpdate && !isUpdateModerationBlocked && (
<Button
variant="outline"
size="sm"
Expand Down Expand Up @@ -564,7 +590,8 @@ function PluginCard({
error={
updateMutation.error instanceof RegistryUpdateEscalationError
? null
: getMutationError(updateMutation.error)
: (describeRegistryModerationError(updateMutation.error) ??
getMutationError(updateMutation.error))
}
onConfirm={handleUpdateConfirm}
onCancel={() => {
Expand Down
46 changes: 38 additions & 8 deletions packages/admin/src/components/RegistryBrowse.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,17 @@

import { Badge, Input } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import { MagnifyingGlass, PuzzlePiece, ShieldCheck } from "@phosphor-icons/react";
import { MagnifyingGlass, PuzzlePiece, ShieldCheck, Warning } from "@phosphor-icons/react";
import { useInfiniteQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import * as React from "react";

import {
evaluatePackageModeration,
isModerationBlocking,
resolveAcceptedPolicy,
searchRegistryPackages,
type AcceptedLabelerPolicy,
type RegistryClientConfig,
type RegistryPackageView,
} from "../lib/api/registry.js";
Expand Down Expand Up @@ -50,7 +54,7 @@ export function RegistryBrowse({ config, installedRegistryUris = new Set() }: Re

const { data, isLoading, error, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
queryKey: ["registry", "search", config.aggregatorUrl, debouncedQuery],
queryKey: ["registry", "search", config.aggregatorUrl, config.acceptLabelers, debouncedQuery],
queryFn: ({ pageParam }) =>
searchRegistryPackages(config, {
q: debouncedQuery || undefined,
Expand All @@ -61,7 +65,18 @@ export function RegistryBrowse({ config, installedRegistryUris = new Set() }: Re
getNextPageParam: (lastPage) => lastPage.cursor,
});

const packages = data?.pages.flatMap((p) => p.packages);
// Each page carries the `atproto-content-labelers` header the aggregator
// applied to THAT response, so every package in a page is evaluated
// against the accepted policy that actually produced it.
const packages = data?.pages.flatMap((page) =>
page.packages.map((pkg) => ({
pkg,
accepted: resolveAcceptedPolicy({
configuredAcceptLabelers: config.acceptLabelers,
contentLabelersHeader: page.contentLabelers,
}),
})),
);

return (
<div className="space-y-6">
Expand Down Expand Up @@ -121,10 +136,11 @@ export function RegistryBrowse({ config, installedRegistryUris = new Set() }: Re
{/* Grid */}
{packages && packages.length > 0 ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{packages.map((pkg) => (
{packages.map(({ pkg, accepted }) => (
<RegistryPackageCard
key={pkg.uri}
pkg={pkg}
accepted={accepted}
installed={installedRegistryUris.has(pkg.uri)}
/>
))}
Expand All @@ -150,10 +166,12 @@ export function RegistryBrowse({ config, installedRegistryUris = new Set() }: Re

interface RegistryPackageCardProps {
pkg: RegistryPackageView;
/** Accepted labeler policy resolved from the search response that returned `pkg`. */
accepted: AcceptedLabelerPolicy[];
installed: boolean;
}

function RegistryPackageCard({ pkg, installed }: RegistryPackageCardProps) {
function RegistryPackageCard({ pkg, accepted, installed }: RegistryPackageCardProps) {
const { t } = useLingui();
const handleResult = usePublisherHandle(pkg.did, pkg.handle);
// Always link by handle when we have one (cleaner URL), DID
Expand All @@ -166,6 +184,10 @@ function RegistryPackageCard({ pkg, installed }: RegistryPackageCardProps) {
const description = pkg.profile?.description;
const license = pkg.profile?.license;
const verified = (pkg.labels ?? []).some((l: { val?: string }) => l.val === "verified");
// Package/publisher-scope moderation only -- no release is in view yet on
// a browse card. `verified` above is unrelated: that shield is out of
// moderation scope pending its own ratification.
const blocked = isModerationBlocking(evaluatePackageModeration(pkg, accepted));

return (
<Link
Expand Down Expand Up @@ -193,9 +215,17 @@ function RegistryPackageCard({ pkg, installed }: RegistryPackageCardProps) {
<p className="mt-2 line-clamp-2 text-sm text-kumo-default">{description}</p>
) : null}
{license ? <p className="mt-2 text-xs text-kumo-subtle">{license}</p> : null}
{installed ? (
<div className="mt-3">
<Badge variant="success">{t`Installed`}</Badge>
{installed || blocked ? (
<div className="mt-3 flex flex-wrap items-center gap-2">
{installed ? <Badge variant="success">{t`Installed`}</Badge> : null}
{blocked ? (
<Badge variant="error">
<span className="flex items-center gap-1">
<Warning className="h-3 w-3" aria-hidden />
{t`Blocked`}
</span>
</Badge>
) : null}
</div>
) : null}
</div>
Expand Down
Loading
Loading