mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: consent check, developer mode file installs, install counts and issue links for plugins
This commit is contained in:
1 parent
26a474bb3d
commit
08db1fcdde
28 files changed
+2248
-72
No files matched your search
@@ -284,6 +284,9 @@ export interface RegistryPluginEntry {
|
||||
pinnedVersion: string | null;
|
||||
autoUpdate: boolean;
|
||||
bundled: boolean;
|
||||
/** Active installs when known, release downloads otherwise. */
|
||||
installCount: number | null;
|
||||
installCountSource: string | null;
|
||||
}
|
||||
|
||||
export interface RegistryListing {
|
||||
@@ -338,14 +341,16 @@ export async function getPluginRegistry(
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** `capabilities` is exactly what the consent prompt showed. */
|
||||
export async function installPlugin(
|
||||
pluginId: string,
|
||||
version?: string,
|
||||
options: { version?: string; capabilities: string[] },
|
||||
): Promise<{ id: string; version: string; state: string }> {
|
||||
try {
|
||||
const response = await rbacApi.post(`${pluginPath(pluginId)}/install`, {
|
||||
version,
|
||||
});
|
||||
const response = await rbacApi.post(
|
||||
`${pluginPath(pluginId)}/install`,
|
||||
options,
|
||||
);
|
||||
return response.data;
|
||||
} finally {
|
||||
announcePluginsChanged();
|
||||
@@ -354,7 +359,11 @@ export async function installPlugin(
|
||||
|
||||
export async function updatePlugin(
|
||||
pluginId: string,
|
||||
options: { version?: string; acceptCapabilities?: boolean } = {},
|
||||
options: {
|
||||
version?: string;
|
||||
acceptCapabilities?: boolean;
|
||||
capabilities?: string[];
|
||||
} = {},
|
||||
): Promise<{ id: string; version: string; state: string }> {
|
||||
try {
|
||||
const response = await rbacApi.post(
|
||||
@@ -443,3 +452,57 @@ export async function deletePluginData(pluginId: string): Promise<void> {
|
||||
announcePluginsChanged();
|
||||
}
|
||||
}
|
||||
|
||||
export interface DeveloperModeState {
|
||||
enabled: boolean;
|
||||
/** TERMIX_REQUIRE_SIGNED_PLUGINS blocks file installs even in developer mode. */
|
||||
signedOnly: boolean;
|
||||
}
|
||||
|
||||
export async function getDeveloperMode(): Promise<DeveloperModeState> {
|
||||
const response = await rbacApi.get("/plugins/developer-mode");
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** Fired after developer mode changes; detail is the new value. */
|
||||
export const DEVELOPER_MODE_CHANGED_EVENT = "termix:plugin-developer-mode";
|
||||
|
||||
export async function setDeveloperMode(enabled: boolean): Promise<void> {
|
||||
await rbacApi.put("/plugins/developer-mode", { enabled });
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(DEVELOPER_MODE_CHANGED_EVENT, { detail: enabled }),
|
||||
);
|
||||
}
|
||||
|
||||
export interface PluginUploadPreview {
|
||||
token: string;
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
author: string;
|
||||
capabilities: string[];
|
||||
replaces: string | null;
|
||||
}
|
||||
|
||||
export async function uploadPlugin(file: Blob): Promise<PluginUploadPreview> {
|
||||
const response = await rbacApi.post("/plugins/upload", file, {
|
||||
headers: { "Content-Type": "application/octet-stream" },
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function installUploadedPlugin(
|
||||
token: string,
|
||||
capabilities: string[],
|
||||
): Promise<{ id: string; version: string; state: string }> {
|
||||
try {
|
||||
const response = await rbacApi.post(
|
||||
`/plugins/upload/${encodeURIComponent(token)}/install`,
|
||||
{ capabilities },
|
||||
);
|
||||
return response.data;
|
||||
} finally {
|
||||
announcePluginsChanged();
|
||||
}
|
||||
}
|
||||
+31
-3
@@ -1976,6 +1976,7 @@
|
||||
"notRunning": "Not running"
|
||||
},
|
||||
"sort": {
|
||||
"popular": "Most popular",
|
||||
"name": "Name",
|
||||
"updated": "Recently updated",
|
||||
"category": "Category"
|
||||
@@ -2017,7 +2018,9 @@
|
||||
"updateExplain": "This version asks for something the one you have does not, so it will not install on its own.",
|
||||
"newInVersion": "New in this version",
|
||||
"minor": "It also adds its own screens and stores its own data, kept separate from other plugins.",
|
||||
"none": "It asks for no special access."
|
||||
"none": "It asks for no special access.",
|
||||
"uploadTitle": "Install {{name}} from a file",
|
||||
"uploadWarning": "This file was not checked or signed by the Termix registry. Only install it if you built it yourself or trust whoever did."
|
||||
},
|
||||
"confirm": {
|
||||
"uninstallTitle": "Uninstall {{name}}?",
|
||||
@@ -2054,7 +2057,10 @@
|
||||
"deleteData": "Could not delete data for {{name}}: {{error}}",
|
||||
"managedByServer": "This desktop is linked to a server, which decides its plugins.",
|
||||
"hasDependents": "{{name}} is needed by {{list}}. Turn those off or uninstall them first.",
|
||||
"missingDependencies": "{{name}} needs {{list}}, which is not installed."
|
||||
"missingDependencies": "{{name}} needs {{list}}, which is not installed.",
|
||||
"consentChanged": "What this plugin asks for changed since you looked. Check the list again.",
|
||||
"upload": "Could not read {{name}}: {{error}}",
|
||||
"developerMode": "Could not change developer mode: {{error}}"
|
||||
},
|
||||
"data": {
|
||||
"title": "Stored data",
|
||||
@@ -2122,7 +2128,29 @@
|
||||
"registryUnreachable": "Could not reach the plugin registry ({{error}}). Installed plugins still work.",
|
||||
"managedByServer": "This desktop is linked to a server, which decides its plugins. Changes are made on the server.",
|
||||
"browsePlugins": "Browse plugins",
|
||||
"loading": "Loading"
|
||||
"loading": "Loading",
|
||||
"developer": {
|
||||
"title": "Plugin developer mode",
|
||||
"hint": "Lets admins install plugins from a .tmxplug file in the Plugins tab, without the registry checks. Use it to build and test your own plugins.",
|
||||
"enableTitle": "Turn on developer mode?",
|
||||
"enableBody": "Admins will be able to install plugins from files that Termix has not checked. A plugin can do anything its capabilities allow, so only turn this on while you are building or testing plugins.",
|
||||
"enable": "Turn on",
|
||||
"installFromFile": "Install from file",
|
||||
"signedOnly": "TERMIX_REQUIRE_SIGNED_PLUGINS is set, so installing from a file stays blocked."
|
||||
},
|
||||
"count": {
|
||||
"active_one": "{{formatted}} active install",
|
||||
"active_other": "{{formatted}} active installs",
|
||||
"downloads_one": "{{formatted}} download",
|
||||
"downloads_other": "{{formatted}} downloads",
|
||||
"activeHint": "Instances that reported this plugin turned on in the last week. Instances with usage statistics turned off are not counted.",
|
||||
"downloadsHint": "How many times a release was downloaded. This is not the number of installs."
|
||||
},
|
||||
"unverified": "Unverified",
|
||||
"unverifiedHint": "Installed from a file. Termix did not check or sign it.",
|
||||
"reportIssue": "Report an issue",
|
||||
"reportTo": "Report to {{author}}",
|
||||
"openSettings": "Open its settings"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TriangleAlert } from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { Facts } from "@/components/panel-layout";
|
||||
import { PanePrompt } from "@/components/surface/surface-scope";
|
||||
@@ -7,10 +8,12 @@ import { capabilityRisk, orderByRisk, type PluginEntry } from "./plugin-model";
|
||||
|
||||
export interface ConsentRequest {
|
||||
plugin: PluginEntry;
|
||||
mode: "install" | "update";
|
||||
mode: "install" | "update" | "upload";
|
||||
version: string;
|
||||
/** What the prompt lists: all of them to install, only the new ones to update. */
|
||||
capabilities: string[];
|
||||
/** Set for a file uploaded in developer mode. */
|
||||
uploadToken?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,13 +47,15 @@ export function PluginConsentPrompt({
|
||||
title={
|
||||
mode === "update"
|
||||
? t("plugins.manager.consent.updateTitle", { name: plugin.name })
|
||||
: t("plugins.manager.consent.installTitle", { name: plugin.name })
|
||||
: mode === "upload"
|
||||
? t("plugins.manager.consent.uploadTitle", { name: plugin.name })
|
||||
: t("plugins.manager.consent.installTitle", { name: plugin.name })
|
||||
}
|
||||
description={
|
||||
<Facts>
|
||||
{plugin.author && <span className="truncate">{plugin.author}</span>}
|
||||
<span className="shrink-0">
|
||||
{mode === "update" && plugin.version
|
||||
{mode !== "install" && plugin.version
|
||||
? t("plugins.manager.versionChange", {
|
||||
from: plugin.version,
|
||||
to: version,
|
||||
@@ -78,6 +83,14 @@ export function PluginConsentPrompt({
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
{mode === "upload" && (
|
||||
<div className="flex items-start gap-2 border border-warning/40 bg-warning/10 px-3 py-2">
|
||||
<TriangleAlert className="mt-px size-3.5 shrink-0 text-warning" />
|
||||
<span className="text-[11px] leading-snug text-foreground">
|
||||
{t("plugins.manager.consent.uploadWarning")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{mode === "update" && (
|
||||
<p className="text-xs leading-relaxed text-foreground">
|
||||
{t("plugins.manager.consent.updateExplain")}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Bug,
|
||||
Database,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Pin,
|
||||
RotateCcw,
|
||||
Settings,
|
||||
Trash2,
|
||||
TriangleAlert,
|
||||
} from "lucide-react";
|
||||
@@ -21,15 +23,25 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/select";
|
||||
import { getPluginData, type PluginDataSummary } from "@/api/plugins-api";
|
||||
import { CapabilityRow, PluginIconBox } from "./plugin-bits";
|
||||
import {
|
||||
CapabilityRow,
|
||||
InstallCountFact,
|
||||
PluginIconBox,
|
||||
UnverifiedBadge,
|
||||
} from "./plugin-bits";
|
||||
import {
|
||||
describeContributions,
|
||||
formatBytes,
|
||||
orderByRisk,
|
||||
reportIssueUrl,
|
||||
type PluginEntry,
|
||||
} from "./plugin-model";
|
||||
import { openPluginSettings } from "./open-plugins";
|
||||
import type { PluginsManager } from "./use-plugins-manager";
|
||||
|
||||
const LINK_ROW =
|
||||
"flex items-center gap-2 border border-border bg-card px-3 py-2.5 text-left text-[11px] text-muted-foreground transition-colors hover:border-accent-brand/40 hover:text-foreground";
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
@@ -60,6 +72,15 @@ export function PluginDetail({
|
||||
}, [plugin.version, plugin.latestVersion]);
|
||||
|
||||
const contributions = describeContributions(plugin.contributes);
|
||||
const settings = plugin.contributes?.settings as
|
||||
{ admin?: unknown[]; user?: unknown[] } | undefined;
|
||||
const hasSettings =
|
||||
plugin.installed &&
|
||||
((settings?.admin?.length ?? 0) > 0 || (settings?.user?.length ?? 0) > 0);
|
||||
const issueUrl = reportIssueUrl(
|
||||
plugin,
|
||||
import.meta.env.VITE_APP_VERSION || undefined,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -77,6 +98,7 @@ export function PluginDetail({
|
||||
{plugin.description}
|
||||
</span>
|
||||
<Facts className="text-[11px] text-muted-foreground/70">
|
||||
{plugin.unverified && <UnverifiedBadge />}
|
||||
{plugin.author && <span>{plugin.author}</span>}
|
||||
{plugin.version ? (
|
||||
<span>v{plugin.version}</span>
|
||||
@@ -91,6 +113,10 @@ export function PluginDetail({
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
<InstallCountFact
|
||||
count={plugin.installCount}
|
||||
source={plugin.installCountSource}
|
||||
/>
|
||||
</Facts>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col gap-1.5">
|
||||
@@ -305,6 +331,7 @@ export function PluginDetail({
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8"
|
||||
disabled={
|
||||
busy || locked || !picked || picked === plugin.version
|
||||
}
|
||||
@@ -367,12 +394,43 @@ export function PluginDetail({
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{hasSettings && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openPluginSettings(plugin.id)}
|
||||
className={LINK_ROW}
|
||||
>
|
||||
<Settings className="size-3.5 shrink-0" />
|
||||
<span className="truncate">
|
||||
{t("plugins.manager.openSettings")}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{issueUrl && (
|
||||
<a
|
||||
href={issueUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={LINK_ROW}
|
||||
>
|
||||
<Bug className="size-3.5 shrink-0" />
|
||||
<span className="truncate">
|
||||
{plugin.source === "official" ||
|
||||
plugin.source === "bundled" ||
|
||||
!plugin.author
|
||||
? t("plugins.manager.reportIssue")
|
||||
: t("plugins.manager.reportTo", { author: plugin.author })}
|
||||
</span>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{plugin.repository && (
|
||||
<a
|
||||
href={plugin.repository}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-2 border border-border bg-card px-3 py-2.5 text-[11px] text-muted-foreground transition-colors hover:border-accent-brand/40 hover:text-foreground"
|
||||
className={LINK_ROW}
|
||||
>
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
<span className="truncate">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ArrowDownWideNarrow,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Search,
|
||||
Trash2,
|
||||
TriangleAlert,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { Card } from "@/components/card";
|
||||
@@ -33,7 +34,11 @@ import {
|
||||
} from "@/components/panel-layout";
|
||||
import { InlineView, SurfaceScope } from "@/components/surface/surface-scope";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PluginIconBox } from "./plugin-bits";
|
||||
import {
|
||||
InstallCountFact,
|
||||
PluginIconBox,
|
||||
UnverifiedBadge,
|
||||
} from "./plugin-bits";
|
||||
import { PluginConsentPrompt } from "./PluginConsentPrompt";
|
||||
import { PluginDetail } from "./PluginDetail";
|
||||
import {
|
||||
@@ -210,7 +215,7 @@ function SortSelect({
|
||||
onChange: (next: SortKey) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const keys: SortKey[] = ["name", "updated", "category"];
|
||||
const keys: SortKey[] = ["popular", "name", "updated", "category"];
|
||||
return (
|
||||
<Select value={value} onValueChange={(v) => onChange(v as SortKey)}>
|
||||
<SelectTrigger size="sm" className="w-auto gap-1.5 text-xs">
|
||||
@@ -306,6 +311,9 @@ function InstalledSection({
|
||||
>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<SortSelect value={sort} onChange={setSort} />
|
||||
{manager.developerMode && !manager.signedOnly && (
|
||||
<UploadButton manager={manager} />
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-1.5"
|
||||
@@ -369,6 +377,35 @@ function InstalledSection({
|
||||
);
|
||||
}
|
||||
|
||||
function UploadButton({ manager }: { manager: PluginsManager }) {
|
||||
const { t } = useTranslation();
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={input}
|
||||
type="file"
|
||||
accept=".tmxplug"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (file) void manager.requestUpload(file);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-1.5"
|
||||
disabled={manager.busy.size > 0 || manager.managedByServer}
|
||||
onClick={() => input.current?.click()}
|
||||
>
|
||||
<Upload className="size-3.5" />
|
||||
{t("plugins.manager.developer.installFromFile")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHead({
|
||||
plugin,
|
||||
muted,
|
||||
@@ -431,6 +468,7 @@ function InstalledCard({
|
||||
onOpen={onOpen}
|
||||
facts={
|
||||
<>
|
||||
{plugin.unverified && <UnverifiedBadge />}
|
||||
{plugin.author && <span>{plugin.author}</span>}
|
||||
<span>v{plugin.version}</span>
|
||||
{adds.length > 0 && <span>{adds.join(", ")}</span>}
|
||||
@@ -540,7 +578,7 @@ function BrowseSection({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [category, setCategory] = useState<string | null>(null);
|
||||
const [sort, setSort] = useState<SortKey>("name");
|
||||
const [sort, setSort] = useState<SortKey>("popular");
|
||||
const categories = useMemo(() => categoriesOf(plugins), [plugins]);
|
||||
|
||||
const rows = sortPlugins(
|
||||
@@ -633,6 +671,10 @@ function BrowseCard({
|
||||
{plugin.author && <span>{plugin.author}</span>}
|
||||
{plugin.latestVersion && <span>v{plugin.latestVersion}</span>}
|
||||
{plugin.category && <span>{plugin.category}</span>}
|
||||
<InstallCountFact
|
||||
count={plugin.installCount}
|
||||
source={plugin.installCountSource}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,2 +1,11 @@
|
||||
/** Asks the shell to open the Plugins tab. */
|
||||
export const OPEN_PLUGINS_EVENT = "termix:open-plugins";
|
||||
|
||||
/** Asks the shell to open Settings at a plugin's page; detail is the plugin id. */
|
||||
export const OPEN_PLUGIN_SETTINGS_EVENT = "termix:open-plugin-settings";
|
||||
|
||||
export function openPluginSettings(pluginId: string): void {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(OPEN_PLUGIN_SETTINGS_EVENT, { detail: pluginId }),
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PluginIcon } from "@/lib/plugin-icon";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { capabilityRisk, isSevere } from "./plugin-model";
|
||||
import { capabilityRisk, formatCount, isSevere } from "./plugin-model";
|
||||
|
||||
export function PluginIconBox({
|
||||
name,
|
||||
@@ -74,3 +74,45 @@ export function CapabilityRow({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Permanent mark on anything installed without a registry signature. */
|
||||
export function UnverifiedBadge() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span
|
||||
className="shrink-0 border border-warning/40 bg-warning/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-warning"
|
||||
title={t("plugins.manager.unverifiedHint")}
|
||||
>
|
||||
{t("plugins.manager.unverified")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Active installs when the registry has them, release downloads otherwise. */
|
||||
export function InstallCountFact({
|
||||
count,
|
||||
source,
|
||||
}: {
|
||||
count: number | null;
|
||||
source: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (count === null || count <= 0) return null;
|
||||
const active = source === "aggregate-telemetry";
|
||||
return (
|
||||
<span
|
||||
title={
|
||||
active
|
||||
? t("plugins.manager.count.activeHint")
|
||||
: t("plugins.manager.count.downloadsHint")
|
||||
}
|
||||
>
|
||||
{t(
|
||||
active
|
||||
? "plugins.manager.count.active"
|
||||
: "plugins.manager.count.downloads",
|
||||
{ count, formatted: formatCount(count) },
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from "@termix-ssh/plugin-sdk/capabilities";
|
||||
import type {
|
||||
PluginSummary,
|
||||
PluginUploadPreview,
|
||||
RegistryPluginEntry,
|
||||
RegistryPluginVersion,
|
||||
} from "@/api/plugins-api";
|
||||
@@ -36,6 +37,22 @@ export interface PluginEntry {
|
||||
inRegistry: boolean;
|
||||
contributes: Record<string, unknown> | null;
|
||||
publishedAt: string | null;
|
||||
installCount: number | null;
|
||||
installCountSource: string | null;
|
||||
source: PluginSource;
|
||||
/** Installed from a file or folder with no registry signature behind it. */
|
||||
unverified: boolean;
|
||||
}
|
||||
|
||||
export type PluginSource = "bundled" | "official" | "unverified";
|
||||
|
||||
/** Where an installed copy came from, as the issue report names it. */
|
||||
export function pluginSource(summary: PluginSummary): PluginSource {
|
||||
if (summary.tier === "unverified") return "unverified";
|
||||
if (summary.source === "bundled" || summary.tier === "bundled") {
|
||||
return "bundled";
|
||||
}
|
||||
return summary.signedBy ? "official" : "unverified";
|
||||
}
|
||||
|
||||
export function pluginStatus(summary: PluginSummary): PluginStatus {
|
||||
@@ -79,6 +96,10 @@ export function mergePlugins(
|
||||
inRegistry: Boolean(entry),
|
||||
contributes: (summary.contributes as Record<string, unknown>) ?? null,
|
||||
publishedAt: latestPublished(entry?.versions ?? []),
|
||||
installCount: entry?.installCount ?? null,
|
||||
installCountSource: entry?.installCountSource ?? null,
|
||||
source: pluginSource(summary),
|
||||
unverified: pluginSource(summary) === "unverified",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -110,12 +131,47 @@ export function mergePlugins(
|
||||
inRegistry: true,
|
||||
contributes: null,
|
||||
publishedAt: latestPublished(entry.versions),
|
||||
installCount: entry.installCount ?? null,
|
||||
installCountSource: entry.installCountSource ?? null,
|
||||
source: "official",
|
||||
unverified: false,
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** A row for a file that was uploaded but not installed yet. */
|
||||
export function uploadEntry(preview: PluginUploadPreview): PluginEntry {
|
||||
return {
|
||||
id: preview.id,
|
||||
name: preview.name,
|
||||
description: preview.description,
|
||||
author: preview.author,
|
||||
category: "",
|
||||
installed: preview.replaces !== null,
|
||||
version: preview.replaces,
|
||||
latestVersion: preview.version,
|
||||
updateAvailable: false,
|
||||
addedCapabilities: [],
|
||||
versions: [],
|
||||
capabilities: preview.capabilities,
|
||||
status: null,
|
||||
enabled: false,
|
||||
lastError: null,
|
||||
autoUpdate: false,
|
||||
pinnedVersion: null,
|
||||
bundled: false,
|
||||
inRegistry: false,
|
||||
contributes: null,
|
||||
publishedAt: null,
|
||||
installCount: null,
|
||||
installCountSource: null,
|
||||
source: "unverified",
|
||||
unverified: true,
|
||||
};
|
||||
}
|
||||
|
||||
function latestPublished(versions: RegistryPluginVersion[]): string | null {
|
||||
const dates = versions
|
||||
.map((v) => v.publishedAt)
|
||||
@@ -124,11 +180,17 @@ function latestPublished(versions: RegistryPluginVersion[]): string | null {
|
||||
return dates.at(-1) ?? null;
|
||||
}
|
||||
|
||||
export type SortKey = "name" | "updated" | "category";
|
||||
export type SortKey = "popular" | "name" | "updated" | "category";
|
||||
|
||||
export function sortPlugins(rows: PluginEntry[], key: SortKey): PluginEntry[] {
|
||||
const sorted = [...rows];
|
||||
if (key === "updated") {
|
||||
if (key === "popular") {
|
||||
sorted.sort(
|
||||
(a, b) =>
|
||||
(b.installCount ?? -1) - (a.installCount ?? -1) ||
|
||||
a.name.localeCompare(b.name),
|
||||
);
|
||||
} else if (key === "updated") {
|
||||
sorted.sort((a, b) =>
|
||||
(b.publishedAt ?? "").localeCompare(a.publishedAt ?? ""),
|
||||
);
|
||||
@@ -230,3 +292,49 @@ export function formatBytes(bytes: number): string {
|
||||
}
|
||||
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
/** 1234 reads as 1.2k, so the number stays a rough ranking. */
|
||||
export function formatCount(count: number): string {
|
||||
if (count < 1000) return String(count);
|
||||
if (count < 1_000_000) {
|
||||
return `${(count / 1000).toFixed(count < 10_000 ? 1 : 0)}k`;
|
||||
}
|
||||
return `${(count / 1_000_000).toFixed(1)}M`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A new GitHub issue on the plugin's own repo with what a maintainer asks
|
||||
* first already filled in. Null when the repository is not on GitHub.
|
||||
*/
|
||||
export function reportIssueUrl(
|
||||
plugin: Pick<
|
||||
PluginEntry,
|
||||
"id" | "repository" | "version" | "latestVersion" | "status" | "source"
|
||||
>,
|
||||
termixVersion?: string,
|
||||
): string | null {
|
||||
const repo = plugin.repository
|
||||
?.trim()
|
||||
.replace(/\.git$/, "")
|
||||
.replace(/\/+$/, "");
|
||||
if (!repo || !/^https:\/\/github\.com\/[^/]+\/[^/]+$/.test(repo)) {
|
||||
return null;
|
||||
}
|
||||
const lines = [
|
||||
`**Plugin:** ${plugin.id}`,
|
||||
`**Version:** ${plugin.version ?? plugin.latestVersion ?? "not installed"}`,
|
||||
`**Source:** ${plugin.source}`,
|
||||
`**State:** ${plugin.status ?? "not installed"}`,
|
||||
...(termixVersion ? [`**Termix:** ${termixVersion}`] : []),
|
||||
"",
|
||||
"**What happened:**",
|
||||
"",
|
||||
"**What you expected:**",
|
||||
"",
|
||||
];
|
||||
const params = new URLSearchParams({
|
||||
title: `[${plugin.id}] `,
|
||||
body: lines.join("\n"),
|
||||
});
|
||||
return `${repo}/issues/new?${params.toString()}`;
|
||||
}
|
||||
@@ -3,10 +3,13 @@ import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import semver from "semver";
|
||||
import {
|
||||
DEVELOPER_MODE_CHANGED_EVENT,
|
||||
deletePluginData,
|
||||
getDeveloperMode,
|
||||
getPluginRegistry,
|
||||
getPlugins,
|
||||
installPlugin,
|
||||
installUploadedPlugin,
|
||||
previewPluginState,
|
||||
retryPlugin,
|
||||
setPluginOptions,
|
||||
@@ -14,11 +17,13 @@ import {
|
||||
uninstallPlugin,
|
||||
updateAllPlugins,
|
||||
updatePlugin,
|
||||
uploadPlugin,
|
||||
type DeveloperModeState,
|
||||
type PluginSummary,
|
||||
type RegistryListing,
|
||||
} from "@/api/plugins-api";
|
||||
import { useConfirm } from "@/components/surface/surface-scope";
|
||||
import { mergePlugins, type PluginEntry } from "./plugin-model";
|
||||
import { mergePlugins, uploadEntry, type PluginEntry } from "./plugin-model";
|
||||
import type { ConsentRequest } from "./PluginConsentPrompt";
|
||||
|
||||
interface ApiError {
|
||||
@@ -53,6 +58,10 @@ export function usePluginsManager() {
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [busy, setBusy] = useState<Set<string>>(new Set());
|
||||
const [consent, setConsent] = useState<ConsentRequest | null>(null);
|
||||
const [developer, setDeveloper] = useState<DeveloperModeState>({
|
||||
enabled: false,
|
||||
signedOnly: false,
|
||||
});
|
||||
const mounted = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -64,12 +73,14 @@ export function usePluginsManager() {
|
||||
|
||||
const load = useCallback(async (refresh = false) => {
|
||||
if (refresh) setRefreshing(true);
|
||||
const [plugins, listing] = await Promise.allSettled([
|
||||
const [plugins, listing, devMode] = await Promise.allSettled([
|
||||
getPlugins(),
|
||||
getPluginRegistry(refresh),
|
||||
getDeveloperMode(),
|
||||
]);
|
||||
if (!mounted.current) return;
|
||||
if (plugins.status === "fulfilled") setInstalled(plugins.value);
|
||||
if (devMode.status === "fulfilled") setDeveloper(devMode.value);
|
||||
if (listing.status === "fulfilled") {
|
||||
setRegistry(listing.value);
|
||||
setRegistryError(listing.value.registry.error);
|
||||
@@ -84,6 +95,18 @@ export function usePluginsManager() {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
// The switch lives in admin settings, which may change while this is open.
|
||||
useEffect(() => {
|
||||
const onChange = (event: Event) => {
|
||||
const enabled = (event as CustomEvent<boolean>).detail;
|
||||
if (typeof enabled !== "boolean") return;
|
||||
setDeveloper((current) => ({ ...current, enabled }));
|
||||
};
|
||||
window.addEventListener(DEVELOPER_MODE_CHANGED_EVENT, onChange);
|
||||
return () =>
|
||||
window.removeEventListener(DEVELOPER_MODE_CHANGED_EVENT, onChange);
|
||||
}, []);
|
||||
|
||||
const plugins = useMemo(
|
||||
() => mergePlugins(installed, registry?.plugins ?? null),
|
||||
[installed, registry],
|
||||
@@ -152,13 +175,13 @@ export function usePluginsManager() {
|
||||
);
|
||||
|
||||
const doInstall = useCallback(
|
||||
(plugin: PluginEntry, version: string) =>
|
||||
(plugin: PluginEntry, version: string, capabilities: string[]) =>
|
||||
run(plugin.id, async () => {
|
||||
try {
|
||||
const result = await installPlugin(
|
||||
plugin.id,
|
||||
version === plugin.latestVersion ? undefined : version,
|
||||
);
|
||||
const result = await installPlugin(plugin.id, {
|
||||
version: version === plugin.latestVersion ? undefined : version,
|
||||
capabilities,
|
||||
});
|
||||
if (result.state === "failed" || result.state === "blocked") {
|
||||
toast.error(
|
||||
t("plugins.manager.toast.installedNotRunning", {
|
||||
@@ -171,6 +194,17 @@ export function usePluginsManager() {
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const info = readError(error, "");
|
||||
if (info.code === "CONSENT_MISMATCH") {
|
||||
toast.error(t("plugins.manager.errors.consentChanged"));
|
||||
setConsent({
|
||||
plugin,
|
||||
mode: "install",
|
||||
version,
|
||||
capabilities: (info.data.capabilities as string[]) ?? [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
failToast(error, "plugins.manager.errors.install", plugin.name);
|
||||
}
|
||||
}),
|
||||
@@ -178,12 +212,13 @@ export function usePluginsManager() {
|
||||
);
|
||||
|
||||
const doUpdate = useCallback(
|
||||
(plugin: PluginEntry, version: string, acceptCapabilities: boolean) =>
|
||||
(plugin: PluginEntry, version: string, accepted: string[] | null) =>
|
||||
run(plugin.id, async () => {
|
||||
try {
|
||||
await updatePlugin(plugin.id, {
|
||||
version: version === plugin.latestVersion ? undefined : version,
|
||||
acceptCapabilities,
|
||||
acceptCapabilities: accepted !== null,
|
||||
capabilities: accepted ?? undefined,
|
||||
});
|
||||
toast.success(
|
||||
t("plugins.manager.toast.updated", {
|
||||
@@ -193,7 +228,13 @@ export function usePluginsManager() {
|
||||
);
|
||||
} catch (error) {
|
||||
const info = readError(error, "");
|
||||
if (info.code === "CAPABILITIES_ADDED") {
|
||||
if (
|
||||
info.code === "CAPABILITIES_ADDED" ||
|
||||
info.code === "CONSENT_MISMATCH"
|
||||
) {
|
||||
if (info.code === "CONSENT_MISMATCH") {
|
||||
toast.error(t("plugins.manager.errors.consentChanged"));
|
||||
}
|
||||
setConsent({
|
||||
plugin,
|
||||
mode: "update",
|
||||
@@ -242,21 +283,74 @@ export function usePluginsManager() {
|
||||
});
|
||||
return;
|
||||
}
|
||||
await doUpdate(plugin, target, false);
|
||||
await doUpdate(plugin, target, null);
|
||||
},
|
||||
[confirm, t, doUpdate],
|
||||
);
|
||||
|
||||
const doInstallUpload = useCallback(
|
||||
(request: ConsentRequest) =>
|
||||
run(request.plugin.id, async () => {
|
||||
try {
|
||||
const result = await installUploadedPlugin(
|
||||
request.uploadToken!,
|
||||
request.capabilities,
|
||||
);
|
||||
if (result.state === "failed" || result.state === "blocked") {
|
||||
toast.error(
|
||||
t("plugins.manager.toast.installedNotRunning", {
|
||||
name: request.plugin.name,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
toast.success(
|
||||
t("plugins.manager.toast.installed", {
|
||||
name: request.plugin.name,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
failToast(
|
||||
error,
|
||||
"plugins.manager.errors.install",
|
||||
request.plugin.name,
|
||||
);
|
||||
}
|
||||
}),
|
||||
[run, t, failToast],
|
||||
);
|
||||
|
||||
/** Reads a picked .tmxplug and asks for consent before anything loads. */
|
||||
const requestUpload = useCallback(
|
||||
async (file: File) => {
|
||||
try {
|
||||
const preview = await uploadPlugin(file);
|
||||
setConsent({
|
||||
plugin: uploadEntry(preview),
|
||||
mode: "upload",
|
||||
version: preview.version,
|
||||
capabilities: preview.capabilities,
|
||||
uploadToken: preview.token,
|
||||
});
|
||||
} catch (error) {
|
||||
failToast(error, "plugins.manager.errors.upload", file.name);
|
||||
}
|
||||
},
|
||||
[failToast],
|
||||
);
|
||||
|
||||
const confirmConsent = useCallback(async () => {
|
||||
if (!consent) return;
|
||||
const request = consent;
|
||||
setConsent(null);
|
||||
if (request.mode === "install") {
|
||||
await doInstall(request.plugin, request.version);
|
||||
if (request.mode === "upload" && request.uploadToken) {
|
||||
await doInstallUpload(request);
|
||||
} else if (request.mode === "install") {
|
||||
await doInstall(request.plugin, request.version, request.capabilities);
|
||||
} else {
|
||||
await doUpdate(request.plugin, request.version, true);
|
||||
await doUpdate(request.plugin, request.version, request.capabilities);
|
||||
}
|
||||
}, [consent, doInstall, doUpdate]);
|
||||
}, [consent, doInstall, doUpdate, doInstallUpload]);
|
||||
|
||||
const nameOf = useCallback(
|
||||
(id: string) => plugins.find((p) => p.id === id)?.name ?? id,
|
||||
@@ -439,6 +533,9 @@ export function usePluginsManager() {
|
||||
busy,
|
||||
consent,
|
||||
setConsent,
|
||||
developerMode: developer.enabled,
|
||||
signedOnly: developer.signedOnly,
|
||||
requestUpload,
|
||||
refresh: () => load(true),
|
||||
requestInstall,
|
||||
requestUpdate,
|
||||
|
||||
@@ -132,6 +132,13 @@ export const SETTINGS_SEARCH_INDEX: Record<string, string[]> = {
|
||||
"admin.sessionTimeoutRange",
|
||||
"admin.statusCheck",
|
||||
"common.save",
|
||||
"plugins.manager.developer.enable",
|
||||
"plugins.manager.developer.enableBody",
|
||||
"plugins.manager.developer.enableTitle",
|
||||
"plugins.manager.developer.hint",
|
||||
"plugins.manager.developer.signedOnly",
|
||||
"plugins.manager.developer.title",
|
||||
"plugins.manager.errors.developerMode",
|
||||
],
|
||||
"admin-host-defaults": [
|
||||
"admin.hostTags",
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { SettingRow } from "@/components/section-card";
|
||||
import { useConfirm } from "@/components/surface/surface-scope";
|
||||
import { getDeveloperMode, setDeveloperMode } from "@/api/plugins-api";
|
||||
import { AdminToggle } from "./AdminSettingsShared";
|
||||
|
||||
/** Lets admins install plugins from a file. Asks before turning it on. */
|
||||
export function AdminPluginDeveloperMode() {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [signedOnly, setSignedOnly] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getDeveloperMode()
|
||||
.then((state) => {
|
||||
if (cancelled) return;
|
||||
setEnabled(state.enabled);
|
||||
setSignedOnly(state.signedOnly);
|
||||
setLoaded(true);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggle = async () => {
|
||||
const next = !enabled;
|
||||
if (next) {
|
||||
const ok = await confirm({
|
||||
title: t("plugins.manager.developer.enableTitle"),
|
||||
description: t("plugins.manager.developer.enableBody"),
|
||||
confirmLabel: t("plugins.manager.developer.enable"),
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await setDeveloperMode(next);
|
||||
setEnabled(next);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t("plugins.manager.errors.developerMode", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingRow
|
||||
label={t("plugins.manager.developer.title")}
|
||||
description={t("plugins.manager.developer.hint")}
|
||||
>
|
||||
<AdminToggle
|
||||
on={enabled}
|
||||
onToggle={() => void toggle()}
|
||||
disabled={!loaded || saving}
|
||||
/>
|
||||
</SettingRow>
|
||||
{enabled && signedOnly && (
|
||||
<p className="text-[10px] text-destructive">
|
||||
{t("plugins.manager.developer.signedOnly")}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { SettingRow } from "@/components/section-card";
|
||||
import { Database, Lock, RefreshCw, Server, Settings } from "lucide-react";
|
||||
import { AccordionSection, AdminToggle } from "./AdminSettingsShared";
|
||||
import { AdminHostTags } from "./AdminHostTags";
|
||||
import { AdminPluginDeveloperMode } from "./AdminPluginDeveloperMode";
|
||||
import type { TlsStatus } from "@/api/tls-api";
|
||||
|
||||
type GeneralSettingsSectionProps = {
|
||||
@@ -142,6 +143,7 @@ export function AdminGeneralSettingsSection({
|
||||
onToggle={handleTogglePasswordReset}
|
||||
/>
|
||||
</SettingRow>
|
||||
<AdminPluginDeveloperMode />
|
||||
<div className="flex flex-col gap-2 pt-3 mt-2">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("admin.sessionTimeout")}
|
||||
|
||||
@@ -28,6 +28,8 @@ const [plugin] = mergePlugins(
|
||||
pinnedVersion: null,
|
||||
autoUpdate: false,
|
||||
bundled: false,
|
||||
installCount: null,
|
||||
installCountSource: null,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
@@ -4,11 +4,15 @@ import {
|
||||
categoriesOf,
|
||||
describeContributions,
|
||||
formatBytes,
|
||||
formatCount,
|
||||
matchesQuery,
|
||||
mergePlugins,
|
||||
orderByRisk,
|
||||
pluginSource,
|
||||
pluginStatus,
|
||||
reportIssueUrl,
|
||||
sortPlugins,
|
||||
uploadEntry,
|
||||
} from "@/plugins/plugin-model";
|
||||
|
||||
function summary(overrides: Partial<PluginSummary> = {}): PluginSummary {
|
||||
@@ -50,6 +54,8 @@ function entry(
|
||||
pinnedVersion: null,
|
||||
autoUpdate: false,
|
||||
bundled: true,
|
||||
installCount: null,
|
||||
installCountSource: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -172,3 +178,108 @@ describe("helpers", () => {
|
||||
expect(formatBytes(50 * 1024 * 1024)).toBe("50 MB");
|
||||
});
|
||||
});
|
||||
|
||||
describe("popularity", () => {
|
||||
it("sorts by install count, unknown counts last", () => {
|
||||
const rows = mergePlugins(
|
||||
[],
|
||||
[
|
||||
entry({ id: "a", name: "A", installed: false, installCount: 5 }),
|
||||
entry({ id: "b", name: "B", installed: false, installCount: null }),
|
||||
entry({ id: "c", name: "C", installed: false, installCount: 90 }),
|
||||
],
|
||||
);
|
||||
expect(sortPlugins(rows, "popular").map((r) => r.id)).toEqual([
|
||||
"c",
|
||||
"a",
|
||||
"b",
|
||||
]);
|
||||
});
|
||||
|
||||
it("formats counts roughly", () => {
|
||||
expect(formatCount(950)).toBe("950");
|
||||
expect(formatCount(1234)).toBe("1.2k");
|
||||
expect(formatCount(45_000)).toBe("45k");
|
||||
expect(formatCount(2_500_000)).toBe("2.5M");
|
||||
});
|
||||
});
|
||||
|
||||
describe("source and trust", () => {
|
||||
it("tells bundled, signed and unverified copies apart", () => {
|
||||
expect(pluginSource(summary({ source: "bundled", tier: "bundled" }))).toBe(
|
||||
"bundled",
|
||||
);
|
||||
expect(
|
||||
pluginSource(
|
||||
summary({ source: "user", tier: "official", signedBy: "k" }),
|
||||
),
|
||||
).toBe("official");
|
||||
expect(pluginSource(summary({ source: "user", tier: "community" }))).toBe(
|
||||
"unverified",
|
||||
);
|
||||
expect(
|
||||
pluginSource(
|
||||
summary({ source: "user", tier: "unverified", signedBy: "k" }),
|
||||
),
|
||||
).toBe("unverified");
|
||||
});
|
||||
|
||||
it("marks an upload as unverified", () => {
|
||||
const row = uploadEntry({
|
||||
token: "t",
|
||||
id: "notes",
|
||||
name: "Notes",
|
||||
version: "0.2.0",
|
||||
description: "",
|
||||
author: "me",
|
||||
capabilities: ["kv:own"],
|
||||
replaces: "0.1.0",
|
||||
});
|
||||
expect(row).toMatchObject({
|
||||
unverified: true,
|
||||
installed: true,
|
||||
version: "0.1.0",
|
||||
latestVersion: "0.2.0",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("reportIssueUrl", () => {
|
||||
it("prefills id, version, source and state on the plugin repo", () => {
|
||||
const url = reportIssueUrl(
|
||||
{
|
||||
id: "docker",
|
||||
repository: "https://github.com/Termix-SSH/Plugin-Docker.git",
|
||||
version: "1.0.0",
|
||||
latestVersion: "1.1.0",
|
||||
status: "failed",
|
||||
source: "official",
|
||||
},
|
||||
"26.10.0",
|
||||
)!;
|
||||
const parsed = new URL(url);
|
||||
expect(parsed.origin + parsed.pathname).toBe(
|
||||
"https://github.com/Termix-SSH/Plugin-Docker/issues/new",
|
||||
);
|
||||
expect(parsed.searchParams.get("title")).toBe("[docker] ");
|
||||
const body = parsed.searchParams.get("body")!;
|
||||
expect(body).toContain("**Plugin:** docker");
|
||||
expect(body).toContain("**Version:** 1.0.0");
|
||||
expect(body).toContain("**Source:** official");
|
||||
expect(body).toContain("**State:** failed");
|
||||
expect(body).toContain("**Termix:** 26.10.0");
|
||||
});
|
||||
|
||||
it("gives nothing for a repo that is not on GitHub", () => {
|
||||
expect(
|
||||
reportIssueUrl({
|
||||
id: "x",
|
||||
repository: "https://gitlab.com/a/b",
|
||||
version: null,
|
||||
latestVersion: null,
|
||||
status: null,
|
||||
source: "official",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user