feat: add "Anthropic — via Claude CLI" as a first-class provider
Replaces the stray useClaudeCli settings checkbox + onboarding question
with a proper provider-card UX. The card lives next to OAuth + API-key
cards in onboarding and settings, with Enable/Disable + Test actions.
Backend:
- Vendors rchern/pi-claude-cli@0.3.1 as packages/pi-claude-cli
(MIT, attribution in UPSTREAM.md). Lets us bump peer-dep on
pi-coding-agent in lockstep with Fusion (upstream pinned ^0.52.0
vs ours ^0.62.0) and fix bugs without waiting on upstream.
- Adds @fusion/pi-claude-cli as a workspace dep of @runfusion/fusion
so users don't have to `npm install -g pi-claude-cli` manually.
- serve/daemon/dashboard conditionally load the extension via
discoverAndLoadExtensions() when GlobalSettings.useClaudeCli is on;
no side-effects on user ~/.fusion/agent/settings.json.
- New GET /api/providers/claude-cli/status: claude --version probe
+ toggle state + cached extension resolution.
- New POST /api/auth/claude-cli: flips useClaudeCli, refuses if the
claude binary is missing, fires the existing skill-backfill hook.
- /api/auth/status now injects a synthetic {id:"claude-cli", type:"cli"}
provider entry so onboarding + settings see a consistent list.
Frontend:
- New ClaudeCliProviderCard component shared between ModelOnboardingModal
and SettingsModal's Authentication section.
- New AuthProvider.type = "cli" variant.
- Removed the old "Route AI calls through the Claude CLI" checkbox from
Global Models settings and the opt-in step from the onboarding wizard.
- ProviderIcon gets a composite Anthropic-mark-plus-terminal glyph for
the claude-cli provider id.
Tests:
- 8 unit tests for extension resolution (@fusion/pi-claude-cli is
workspace-linked so these run in-tree).
- 2 unit tests for the binary probe.
- Existing /auth/status tests filter out the new synthetic entry so
they keep asserting structural OAuth/API-key behavior in isolation.
- The vendored package's own 296 tests still pass unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
270
packages/dashboard/app/components/ClaudeCliProviderCard.tsx
Normal file
270
packages/dashboard/app/components/ClaudeCliProviderCard.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import {
|
||||
fetchClaudeCliStatus,
|
||||
setClaudeCliEnabled,
|
||||
type ClaudeCliStatus,
|
||||
} from "../api";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
|
||||
/**
|
||||
* "Anthropic — via Claude CLI" provider card.
|
||||
*
|
||||
* Shown alongside the OAuth + API-key provider cards in onboarding and
|
||||
* settings. Wraps three actions:
|
||||
*
|
||||
* 1. **Test** — polls GET /providers/claude-cli/status to re-probe the
|
||||
* claude binary. Surfaces the binary path, version, and any reason
|
||||
* it's unreachable.
|
||||
* 2. **Enable / Disable** — POST /auth/claude-cli to flip
|
||||
* GlobalSettings.useClaudeCli. Refused server-side if the binary is
|
||||
* missing. On transition the server fires the same hook PUT
|
||||
* /settings/global fires, so skills get backfilled into every
|
||||
* registered project immediately.
|
||||
* 3. **Surface "restart required"** — pi extension registrations can't
|
||||
* be swapped mid-process, so the model-routing change only takes
|
||||
* effect on next Fusion restart. We show that explicitly rather
|
||||
* than letting users wonder why their model picker still shows
|
||||
* non-Anthropic entries right after clicking Enable.
|
||||
*
|
||||
* The card avoids rendering as "authenticated" on its own — that state
|
||||
* comes from the AuthProvider entry in the parent component's list so
|
||||
* every consumer (onboarding, settings) shows the same truth.
|
||||
*/
|
||||
interface ClaudeCliProviderCardProps {
|
||||
/** Authenticated flag from the parent AuthProvider entry. */
|
||||
authenticated: boolean;
|
||||
/** Optional callback fired after Enable/Disable to let the parent refetch the provider list. */
|
||||
onToggled?: (nextEnabled: boolean) => void;
|
||||
}
|
||||
|
||||
export function ClaudeCliProviderCard({
|
||||
authenticated,
|
||||
onToggled,
|
||||
}: ClaudeCliProviderCardProps) {
|
||||
const [status, setStatus] = useState<ClaudeCliStatus | null>(null);
|
||||
const [busy, setBusy] = useState<"enabling" | "disabling" | "testing" | null>(
|
||||
null,
|
||||
);
|
||||
const [lastAction, setLastAction] = useState<
|
||||
| { kind: "enabled"; restartRequired: boolean }
|
||||
| { kind: "disabled"; restartRequired: boolean }
|
||||
| { kind: "error"; message: string }
|
||||
| null
|
||||
>(null);
|
||||
// Guard against state updates after unmount — React complains otherwise.
|
||||
const mountedRef = useRef(true);
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const next = await fetchClaudeCliStatus();
|
||||
if (mountedRef.current) setStatus(next);
|
||||
return next;
|
||||
} catch (err) {
|
||||
if (mountedRef.current) {
|
||||
setLastAction({
|
||||
kind: "error",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initial probe — cheap, happens once per mount.
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const handleTest = useCallback(async () => {
|
||||
setBusy("testing");
|
||||
setLastAction(null);
|
||||
await refresh();
|
||||
if (mountedRef.current) setBusy(null);
|
||||
}, [refresh]);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
async (next: boolean) => {
|
||||
setBusy(next ? "enabling" : "disabling");
|
||||
setLastAction(null);
|
||||
try {
|
||||
const result = await setClaudeCliEnabled(next);
|
||||
if (mountedRef.current) {
|
||||
setLastAction({
|
||||
kind: result.enabled ? "enabled" : "disabled",
|
||||
restartRequired: result.restartRequired,
|
||||
});
|
||||
}
|
||||
onToggled?.(result.enabled);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
if (mountedRef.current) {
|
||||
setLastAction({
|
||||
kind: "error",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (mountedRef.current) setBusy(null);
|
||||
}
|
||||
},
|
||||
[onToggled, refresh],
|
||||
);
|
||||
|
||||
const binaryAvailable = status?.binary.available ?? false;
|
||||
const currentlyEnabled = status?.enabled ?? authenticated;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`onboarding-provider-card${authenticated ? " onboarding-provider-card--connected" : ""}`}
|
||||
data-testid="claude-cli-provider-card"
|
||||
>
|
||||
<div className="onboarding-provider-card__icon">
|
||||
<ProviderIcon provider="claude-cli" size="md" />
|
||||
</div>
|
||||
<div className="onboarding-provider-card__body">
|
||||
<strong className="onboarding-provider-card__name">
|
||||
Anthropic — via Claude CLI
|
||||
</strong>
|
||||
<span className="onboarding-provider-card__description">
|
||||
Route AI calls through your locally-installed <code>claude</code> CLI.
|
||||
Uses your existing Claude subscription / quota instead of an API key.
|
||||
</span>
|
||||
<ClaudeCliStatusLine status={status} authenticated={authenticated} />
|
||||
</div>
|
||||
<div className="onboarding-provider-card__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={handleTest}
|
||||
disabled={busy !== null}
|
||||
>
|
||||
{busy === "testing" ? (
|
||||
<>
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
Testing…
|
||||
</>
|
||||
) : (
|
||||
"Test"
|
||||
)}
|
||||
</button>
|
||||
{currentlyEnabled ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => void handleToggle(false)}
|
||||
disabled={busy !== null}
|
||||
>
|
||||
{busy === "disabling" ? "Disabling…" : "Disable"}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => void handleToggle(true)}
|
||||
disabled={busy !== null || !binaryAvailable}
|
||||
title={
|
||||
!binaryAvailable
|
||||
? "`claude` binary not detected on PATH — install Claude CLI first."
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{busy === "enabling" ? "Enabling…" : "Enable"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{lastAction && (
|
||||
<ClaudeCliActionToast action={lastAction} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One-line health summary. Renders different text for "binary missing"
|
||||
* vs "binary ok but disabled" vs "fully ready" so the user can quickly
|
||||
* see why the provider is or isn't working.
|
||||
*/
|
||||
function ClaudeCliStatusLine({
|
||||
status,
|
||||
authenticated,
|
||||
}: {
|
||||
status: ClaudeCliStatus | null;
|
||||
authenticated: boolean;
|
||||
}) {
|
||||
if (!status) {
|
||||
return (
|
||||
<small className="settings-muted">
|
||||
<Loader2 size={10} className="animate-spin" /> Probing local CLI…
|
||||
</small>
|
||||
);
|
||||
}
|
||||
const { binary, enabled, extension, ready } = status;
|
||||
if (!binary.available) {
|
||||
return (
|
||||
<small className="onboarding-provider-card__status onboarding-provider-card__status--error">
|
||||
✗ {binary.reason ?? "`claude` not found on PATH"}
|
||||
</small>
|
||||
);
|
||||
}
|
||||
if (!enabled) {
|
||||
return (
|
||||
<small className="settings-muted">
|
||||
<code>claude</code> {binary.version ? `(${binary.version})` : ""} detected
|
||||
{binary.binaryPath ? ` at ${binary.binaryPath}` : ""}. Click Enable to
|
||||
route AI calls through it.
|
||||
</small>
|
||||
);
|
||||
}
|
||||
if (extension && extension.status !== "ok") {
|
||||
return (
|
||||
<small className="onboarding-provider-card__status onboarding-provider-card__status--warning">
|
||||
⚠ Extension load failed: {extension.reason ?? extension.status}
|
||||
</small>
|
||||
);
|
||||
}
|
||||
if (ready || authenticated) {
|
||||
return (
|
||||
<small className="onboarding-provider-card__status onboarding-provider-card__status--connected">
|
||||
✓ Connected{binary.version ? ` — ${binary.version}` : ""}
|
||||
</small>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<small className="settings-muted">
|
||||
Enabled. Restart Fusion to complete activation.
|
||||
</small>
|
||||
);
|
||||
}
|
||||
|
||||
function ClaudeCliActionToast({
|
||||
action,
|
||||
}: {
|
||||
action:
|
||||
| { kind: "enabled"; restartRequired: boolean }
|
||||
| { kind: "disabled"; restartRequired: boolean }
|
||||
| { kind: "error"; message: string };
|
||||
}) {
|
||||
if (action.kind === "error") {
|
||||
return (
|
||||
<p className="onboarding-helper-text" style={{ color: "var(--danger)" }}>
|
||||
{action.message}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
const verb = action.kind === "enabled" ? "Enabled" : "Disabled";
|
||||
return (
|
||||
<p className="onboarding-helper-text">
|
||||
{verb}.{" "}
|
||||
{action.restartRequired
|
||||
? "Restart Fusion to activate the routing change."
|
||||
: "No further action needed."}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
import { ClaudeCliProviderCard } from "./ClaudeCliProviderCard";
|
||||
import { appendTokenQuery } from "../auth";
|
||||
|
||||
/** Provider-specific API key setup metadata for onboarding form rendering */
|
||||
@@ -493,7 +494,6 @@ export function ModelOnboardingModal({
|
||||
const [authActionInProgress, setAuthActionInProgress] = useState<string | null>(null);
|
||||
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
|
||||
const [selectedModel, setSelectedModel] = useState<string>("");
|
||||
const [useClaudeCli, setUseClaudeCli] = useState<boolean>(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [apiKeyInputs, setApiKeyInputs] = useState<Record<string, string>>({});
|
||||
const [apiKeyErrors, setApiKeyErrors] = useState<Record<string, string>>({});
|
||||
@@ -1151,20 +1151,13 @@ export function ModelOnboardingModal({
|
||||
}
|
||||
}
|
||||
|
||||
// Only write useClaudeCli when the user explicitly opted in; leaving
|
||||
// the field undefined keeps the legacy packages-array fallback in play
|
||||
// for anyone who had pi-claude-cli installed before this toggle existed.
|
||||
if (useClaudeCli) {
|
||||
updates.useClaudeCli = true;
|
||||
}
|
||||
|
||||
await updateGlobalSettings(updates);
|
||||
// Mark onboarding as completed (preserves state for completion timestamp)
|
||||
markOnboardingCompleted();
|
||||
} catch {
|
||||
// Best-effort: continue even if save fails
|
||||
}
|
||||
}, [selectedModel, availableModels, useClaudeCli, updateGlobalSettings, markOnboardingCompleted]);
|
||||
}, [selectedModel, availableModels, updateGlobalSettings, markOnboardingCompleted]);
|
||||
|
||||
// Complete onboarding
|
||||
const handleComplete = useCallback(async () => {
|
||||
@@ -1296,6 +1289,7 @@ export function ModelOnboardingModal({
|
||||
(p) => !p.type || p.type === "oauth",
|
||||
);
|
||||
const apiKeyProviders = authProviders.filter((p) => p.type === "api_key");
|
||||
const cliProviders = authProviders.filter((p) => p.type === "cli");
|
||||
|
||||
// Filter out GitHub from AI providers list
|
||||
const aiOauthProviders = oauthProviders.filter((p) => p.id !== "github");
|
||||
@@ -1716,6 +1710,23 @@ export function ModelOnboardingModal({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Claude CLI — synthetic provider card. Rendered alongside
|
||||
OAuth + API-key cards but with its own action set
|
||||
(Enable/Disable + Test) since it's backed by a binary
|
||||
probe rather than stored credentials. */}
|
||||
{cliProviders.some((p) => p.id === "claude-cli") && (
|
||||
<ClaudeCliProviderCard
|
||||
authenticated={
|
||||
cliProviders.find((p) => p.id === "claude-cli")?.authenticated ?? false
|
||||
}
|
||||
onToggled={() => {
|
||||
// Refetch auth status so the parent provider list
|
||||
// reflects the new authenticated state.
|
||||
void loadAuthStatus();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Model Selection */}
|
||||
<div className="onboarding-model-section">
|
||||
<h3 className="onboarding-section-title">
|
||||
@@ -1761,38 +1772,6 @@ export function ModelOnboardingModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Claude CLI routing toggle.
|
||||
Opt-in: we only flip useClaudeCli=true when the user ticks
|
||||
this. The backend install hooks (fn init, dashboard project
|
||||
add, server startup) will then symlink the fusion skill into
|
||||
every project's .claude/skills/fusion so Claude Code can see
|
||||
fn_* tools. */}
|
||||
<div className="onboarding-model-section">
|
||||
<h3 className="onboarding-section-title">
|
||||
Use the Claude CLI? (Optional)
|
||||
</h3>
|
||||
<label htmlFor="onboarding-useClaudeCli" className="checkbox-label">
|
||||
<input
|
||||
id="onboarding-useClaudeCli"
|
||||
type="checkbox"
|
||||
checked={useClaudeCli}
|
||||
onChange={(e) => setUseClaudeCli(e.target.checked)}
|
||||
/>
|
||||
Route AI calls through my locally-installed Claude CLI
|
||||
</label>
|
||||
<OnboardingDisclosure summary="What does this do?">
|
||||
<p className="onboarding-helper-text">
|
||||
If you already have Claude CLI installed and an active
|
||||
Claude subscription, Fusion can send its model calls through
|
||||
the CLI instead of the Anthropic API — using your existing
|
||||
quota. Requires the <code>pi-claude-cli</code> pi extension.
|
||||
Fusion will also install its skill into each project's{" "}
|
||||
<code>.claude/skills/fusion/</code> so Claude Code can use
|
||||
Fusion's tools directly. You can toggle this later in
|
||||
Settings → Global Models.
|
||||
</p>
|
||||
</OnboardingDisclosure>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -155,11 +155,46 @@ function KimiIcon({ size, color, label = "Kimi" }: { size: number; color: string
|
||||
);
|
||||
}
|
||||
|
||||
// Anthropic "A" mark composited with a small terminal "> _" badge in the
|
||||
// bottom-right, visually signalling "Anthropic, but via the local CLI".
|
||||
function ClaudeCliIcon({ size, color, label = "Anthropic — via Claude CLI" }: { size: number; color: string; label?: string }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
data-testid="claude-cli-icon"
|
||||
aria-label={label}
|
||||
>
|
||||
{/* Anthropic "A" mark, slightly shrunk + shifted to leave room for the badge */}
|
||||
<g transform="translate(-1 -1.5) scale(0.82)">
|
||||
<path
|
||||
d="M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z"
|
||||
fill={color}
|
||||
/>
|
||||
</g>
|
||||
{/* Terminal badge — filled square with "> _" glyph */}
|
||||
<rect x="13" y="13" width="10" height="9" rx="1.5" fill={color} />
|
||||
<path
|
||||
d="M15.2 16.2l1.6 1.4-1.6 1.4M18.6 19.6h2.4"
|
||||
stroke="var(--bg-primary, #111)"
|
||||
strokeWidth="1.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const providerConfig: Record<
|
||||
string,
|
||||
{ component: typeof AnthropicIcon; color: string; label?: string }
|
||||
> = {
|
||||
anthropic: { component: AnthropicIcon, color: "#d4a27f" }, // warm tan
|
||||
"claude-cli": { component: ClaudeCliIcon, color: "#d4a27f", label: "Anthropic — via Claude CLI" },
|
||||
openai: { component: OpenAIIcon, color: "#10a37f" }, // green
|
||||
"openai-codex": { component: OpenAIIcon, color: "#10a37f", label: "OpenAI Codex" }, // green (same as openai)
|
||||
google: { component: GeminiIcon, color: "#4285f4" }, // blue
|
||||
|
||||
@@ -11,6 +11,7 @@ import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { FileEditor } from "./FileEditor";
|
||||
import { PluginManager } from "./PluginManager";
|
||||
import { PiExtensionsManager } from "./PiExtensionsManager";
|
||||
import { ClaudeCliProviderCard } from "./ClaudeCliProviderCard";
|
||||
import { PluginSlot } from "./PluginSlot";
|
||||
import { AgentPromptsManager } from "./AgentPromptsManager";
|
||||
import { applyPresetToSelection, generateUniquePresetId } from "../utils/modelPresets";
|
||||
@@ -1225,27 +1226,6 @@ export function SettingsModal({
|
||||
</small>
|
||||
</div>
|
||||
|
||||
{/* --- Claude CLI routing --- */}
|
||||
<h4 className="settings-section-heading settings-section-heading--spaced">Claude CLI</h4>
|
||||
<div className="form-group">
|
||||
<label htmlFor="useClaudeCli" className="checkbox-label">
|
||||
<input
|
||||
id="useClaudeCli"
|
||||
type="checkbox"
|
||||
checked={form.useClaudeCli === true}
|
||||
onChange={(e) => setForm((f) => ({ ...f, useClaudeCli: e.target.checked }))}
|
||||
/>
|
||||
Route AI calls through the Claude CLI (via pi-claude-cli)
|
||||
</label>
|
||||
<small>
|
||||
When enabled, Fusion sends model calls to your locally-installed Claude CLI instead
|
||||
of the direct Anthropic API — useful if you already have a Claude subscription and
|
||||
want to use its quota. Requires <code>pi-claude-cli</code> installed as a pi
|
||||
extension. Fusion will also install its skill into each project's{" "}
|
||||
<code>.claude/skills/fusion/</code> so Claude Code sessions can use the{" "}
|
||||
<code>fn_*</code> tools natively.
|
||||
</small>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3045,8 +3025,14 @@ export function SettingsModal({
|
||||
case "pi-extensions":
|
||||
return <PiExtensionsManager addToast={addToast} projectId={projectId} />;
|
||||
case "authentication":
|
||||
// CLI-backed providers (currently just claude-cli) render their own
|
||||
// compact card with Enable/Disable + Test actions — bypassing the
|
||||
// OAuth/API-key rendering below. Filter them out of the standard
|
||||
// sort and render alongside.
|
||||
const cliAuthProviders = authProviders.filter((p) => p.type === "cli");
|
||||
const nonCliProviders = authProviders.filter((p) => p.type !== "cli");
|
||||
// Sort providers: authenticated first, then unauthenticated. Within each bucket, sort alphabetically by name.
|
||||
const sortedProviders = [...authProviders].sort((a, b) => {
|
||||
const sortedProviders = [...nonCliProviders].sort((a, b) => {
|
||||
if (a.authenticated !== b.authenticated) {
|
||||
return a.authenticated ? -1 : 1;
|
||||
}
|
||||
@@ -3227,6 +3213,22 @@ export function SettingsModal({
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cliAuthProviders.some((p) => p.id === "claude-cli") && (
|
||||
<div className="auth-provider-group">
|
||||
<div className="auth-group-label">Local CLI</div>
|
||||
<ClaudeCliProviderCard
|
||||
authenticated={
|
||||
cliAuthProviders.find((p) => p.id === "claude-cli")
|
||||
?.authenticated ?? false
|
||||
}
|
||||
onToggled={() => {
|
||||
// Refetch so the provider list reflects the new state.
|
||||
void loadAuthStatus();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user