feat(cli): Agent Mode bootstrap + claim flow (Node)
Mirrors the Python implementation in TypeScript:
- New PlatformConfig fields: agentMode, createdVia, claimedAt, defaultUserId.
- agent-detect.ts: detectAgentCaller() — env-var detection covering
CLAUDECODE / CURSOR_AGENT / CODEX_CLI / CLINE / CONTINUE / AIDER /
GOOSE / WINDSURF.
- commands/agent-mode.ts: bootstrapViaBackend() + claimViaDeviceFlow().
- commands/init.ts: decision tree dispatches to bootstrap (positive agent
signal + no email/api-key) or claim (--email with existing agent-mode
config). Raw API key never leaves the device through the claim.
- index.ts: --agent and --source flags added to `mem0 init`. Skip the
preAction auto-fire for init so it can fire its own M1-M6 cli.init.
- telemetry.ts: all cli.* events now carry agent_mode based on
config.platform.agentMode (per growth-doc M4).
End-to-end verified against the sandbox:
bootstrap (CLAUDECODE=1) → config.agent_mode=true → claim via --email →
config.agent_mode=false, claimed_at set, api_key unchanged.
This commit is contained in:
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Detect which AI agent is invoking the CLI via environment variables.
|
||||||
|
*
|
||||||
|
* Used by `mem0 init` to:
|
||||||
|
* 1. Decide whether to auto-bootstrap an Agent Mode key (positive agent signal).
|
||||||
|
* 2. Tag the `agent_caller` PostHog property on the cli.init event.
|
||||||
|
*
|
||||||
|
* Returns a canonical short name or null when no agent is detected.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const AGENT_CALLER_ENV: ReadonlyArray<readonly [string, readonly string[]]> = [
|
||||||
|
["claude-code", ["CLAUDECODE", "CLAUDE_CODE"]],
|
||||||
|
["cursor", ["CURSOR_AGENT", "CURSOR_SESSION_ID"]],
|
||||||
|
["codex", ["CODEX_CLI", "OPENAI_CODEX"]],
|
||||||
|
["cline", ["CLINE_AGENT", "CLINE"]],
|
||||||
|
["continue", ["CONTINUE_AGENT", "CONTINUE_SESSION"]],
|
||||||
|
["aider", ["AIDER_SESSION"]],
|
||||||
|
["goose", ["GOOSE_AGENT"]],
|
||||||
|
["windsurf", ["WINDSURF_AGENT"]],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function detectAgentCaller(): string | null {
|
||||||
|
for (const [name, envVars] of AGENT_CALLER_ENV) {
|
||||||
|
if (envVars.some((v) => process.env[v])) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
/**
|
||||||
|
* Agent Mode commands — bootstrap (unattended signup) and claim (human upgrade).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { setTimeout as sleep } from "node:timers/promises";
|
||||||
|
import { colors, printError, printInfo, printSuccess } from "../branding.js";
|
||||||
|
import { type Mem0Config, saveConfig } from "../config.js";
|
||||||
|
|
||||||
|
const { dim } = colors;
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 2_000;
|
||||||
|
const POLL_TIMEOUT_MS = 600_000; // 10 minutes — fits within backend's 15-minute CLILoginRequest expiry.
|
||||||
|
|
||||||
|
const SOURCE_HEADERS = {
|
||||||
|
"X-Mem0-Source": "cli",
|
||||||
|
"X-Mem0-Client-Language": "node",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export interface BootstrapEnvelope {
|
||||||
|
api_key: string;
|
||||||
|
default_user_id: string;
|
||||||
|
org_id: string;
|
||||||
|
project_id: string;
|
||||||
|
mcp_url?: string;
|
||||||
|
smoke_test_url?: string;
|
||||||
|
claim_command?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bootstrapViaBackend(
|
||||||
|
config: Mem0Config,
|
||||||
|
{ source }: { source?: string | null } = {},
|
||||||
|
): Promise<void> {
|
||||||
|
const baseUrl = (config.platform.baseUrl || "https://api.mem0.ai").replace(/\/+$/, "");
|
||||||
|
const body: Record<string, unknown> = {};
|
||||||
|
if (source) body.source = source;
|
||||||
|
|
||||||
|
let resp: Response;
|
||||||
|
try {
|
||||||
|
resp = await fetch(`${baseUrl}/api/v1/auth/agent_mode/`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
...SOURCE_HEADERS,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
printError(`Network error contacting Mem0: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resp.status === 429) {
|
||||||
|
printError("Rate-limited. Try again in a few minutes.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (resp.status === 503) {
|
||||||
|
printError("Agent Mode is temporarily disabled. Try again later.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (!resp.ok) {
|
||||||
|
let detail: string = resp.statusText;
|
||||||
|
try {
|
||||||
|
const errBody = (await resp.json()) as { error?: string };
|
||||||
|
if (errBody.error) detail = errBody.error;
|
||||||
|
} catch {
|
||||||
|
/* leave detail as statusText */
|
||||||
|
}
|
||||||
|
printError(`Bootstrap failed: ${detail}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const envelope = (await resp.json()) as BootstrapEnvelope;
|
||||||
|
|
||||||
|
config.platform.apiKey = envelope.api_key;
|
||||||
|
config.platform.baseUrl = baseUrl;
|
||||||
|
config.platform.agentMode = true;
|
||||||
|
config.platform.createdVia = "agent_mode";
|
||||||
|
config.platform.claimedAt = "";
|
||||||
|
config.platform.defaultUserId = envelope.default_user_id;
|
||||||
|
// Adopt the slug-derived user_id as the default scope for memory ops.
|
||||||
|
config.defaults.userId = envelope.default_user_id;
|
||||||
|
saveConfig(config);
|
||||||
|
|
||||||
|
printSuccess(`Agent Mode active. Default user_id: ${envelope.default_user_id}`);
|
||||||
|
console.log(
|
||||||
|
` ${dim(`To claim this account later: ${envelope.claim_command ?? "mem0 init --email <your-email>"}`)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function claimViaDeviceFlow(
|
||||||
|
config: Mem0Config,
|
||||||
|
{ email }: { email: string },
|
||||||
|
): Promise<void> {
|
||||||
|
const baseUrl = (config.platform.baseUrl || "https://api.mem0.ai").replace(/\/+$/, "");
|
||||||
|
if (!config.platform.apiKey || !config.platform.agentMode) {
|
||||||
|
printError("This command requires an active Agent Mode config. Run `mem0 init` first.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cliToken = randomBytes(32).toString("base64url");
|
||||||
|
const rawKey = config.platform.apiKey;
|
||||||
|
|
||||||
|
// 1. CLI initiates with claim_for_apikey
|
||||||
|
let initResp: Response;
|
||||||
|
try {
|
||||||
|
initResp = await fetch(`${baseUrl}/api/v1/accounts/cli_login/`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
...SOURCE_HEADERS,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ token: cliToken, claim_for_apikey: rawKey }),
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
printError(`Could not initiate claim: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!initResp.ok) {
|
||||||
|
let detail: string = initResp.statusText;
|
||||||
|
try {
|
||||||
|
const errBody = (await initResp.json()) as { error?: string };
|
||||||
|
if (errBody.error) detail = errBody.error;
|
||||||
|
} catch {
|
||||||
|
/* statusText fallback */
|
||||||
|
}
|
||||||
|
printError(`Could not initiate claim: ${detail}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const initBody = (await initResp.json()) as { login_url?: string };
|
||||||
|
const loginUrl = initBody.login_url ?? "";
|
||||||
|
printInfo("Open in your browser to claim:");
|
||||||
|
console.log(` ${dim(loginUrl)}`);
|
||||||
|
// Best-effort open in the user's browser — fall back to printing the URL.
|
||||||
|
try {
|
||||||
|
const { default: open } = await import("open");
|
||||||
|
await open(loginUrl);
|
||||||
|
} catch {
|
||||||
|
/* user has the URL printed above */
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Poll for completion
|
||||||
|
const deadline = Date.now() + POLL_TIMEOUT_MS;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
await sleep(POLL_INTERVAL_MS);
|
||||||
|
|
||||||
|
let poll: Response;
|
||||||
|
try {
|
||||||
|
poll = await fetch(`${baseUrl}/api/v1/accounts/get_api_key_from_cli_token/`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
...SOURCE_HEADERS,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ token: cliToken }),
|
||||||
|
signal: AbortSignal.timeout(15_000),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
continue; // transient — keep polling
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!poll.ok) {
|
||||||
|
let err = "";
|
||||||
|
try {
|
||||||
|
const errBody = (await poll.json()) as { error?: string };
|
||||||
|
err = errBody.error ?? "";
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
if (err.toLowerCase().includes("expired")) {
|
||||||
|
printError("Claim link expired. Run `mem0 init --email <addr>` again.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await poll.json()) as { claimed?: boolean; claimed_at?: string };
|
||||||
|
if (body.claimed) {
|
||||||
|
config.platform.agentMode = false;
|
||||||
|
config.platform.claimedAt = body.claimed_at ?? new Date().toISOString();
|
||||||
|
config.platform.userEmail = email;
|
||||||
|
config.platform.createdVia = "email";
|
||||||
|
saveConfig(config);
|
||||||
|
printSuccess(`Agent claimed to ${email}. Your API key is unchanged.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
printError("Claim timed out. Run `mem0 init --email <addr>` again.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -249,14 +249,31 @@ export async function runInit(
|
|||||||
email?: string;
|
email?: string;
|
||||||
code?: string;
|
code?: string;
|
||||||
force?: boolean;
|
force?: boolean;
|
||||||
|
agent?: boolean;
|
||||||
|
source?: string;
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const { detectAgentCaller } = await import("../agent-detect.js");
|
||||||
|
const { bootstrapViaBackend, claimViaDeviceFlow } = await import("./agent-mode.js");
|
||||||
|
const { isAgentMode } = await import("../state.js");
|
||||||
|
const { captureEvent } = await import("../telemetry.js");
|
||||||
|
|
||||||
|
const fireInit = (mode: "agent" | "email" | "api_key" | "existing_key", claimed = false) => {
|
||||||
|
const props: Record<string, unknown> = { command: "init", mode };
|
||||||
|
const caller = detectAgentCaller();
|
||||||
|
if (caller) props.agent_caller = caller;
|
||||||
|
if (opts.source) props.signup_source = opts.source;
|
||||||
|
if (claimed) props.claimed_agent_mode = true;
|
||||||
|
captureEvent("cli.init", props);
|
||||||
|
};
|
||||||
|
|
||||||
const config = createDefaultConfig();
|
const config = createDefaultConfig();
|
||||||
const savedConfig = loadConfig();
|
const savedConfig = loadConfig();
|
||||||
const baseUrl =
|
const baseUrl =
|
||||||
process.env.MEM0_BASE_URL ||
|
process.env.MEM0_BASE_URL ||
|
||||||
savedConfig.platform.baseUrl ||
|
savedConfig.platform.baseUrl ||
|
||||||
DEFAULT_BASE_URL;
|
DEFAULT_BASE_URL;
|
||||||
|
config.platform.baseUrl = baseUrl;
|
||||||
|
|
||||||
// Guards
|
// Guards
|
||||||
if (opts.code && !opts.email) {
|
if (opts.code && !opts.email) {
|
||||||
@@ -268,6 +285,16 @@ export async function runInit(
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Claim flow: --email against an existing agent-mode config ───────────
|
||||||
|
if (opts.email && fs.existsSync(CONFIG_FILE) && savedConfig.platform.agentMode && savedConfig.platform.apiKey) {
|
||||||
|
const email = opts.email.trim().toLowerCase();
|
||||||
|
validateEmail(email);
|
||||||
|
printInfo(`Claiming Agent Mode account to ${email}...`);
|
||||||
|
await claimViaDeviceFlow(savedConfig, { email });
|
||||||
|
fireInit("email", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Warn if an existing config with an API key would be overwritten
|
// Warn if an existing config with an API key would be overwritten
|
||||||
if (
|
if (
|
||||||
!opts.force &&
|
!opts.force &&
|
||||||
@@ -338,6 +365,20 @@ export async function runInit(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Agent Mode auto-bootstrap (no email, no api_key flag) ───────────
|
||||||
|
// Positive agent signal required: --agent flag (local or global) OR a
|
||||||
|
// recognized agent env var. Pure "no TTY" alone is NOT enough — pipe
|
||||||
|
// users would get surprised by a silent shadow signup.
|
||||||
|
const agentCtx =
|
||||||
|
opts.agent === true ||
|
||||||
|
isAgentMode() ||
|
||||||
|
detectAgentCaller() !== null;
|
||||||
|
if (!opts.apiKey && !opts.email && agentCtx) {
|
||||||
|
await bootstrapViaBackend(config, { source: opts.source ?? null });
|
||||||
|
fireInit("agent");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// ── API key flow ──────────────────────────────────────────────────────────
|
// ── API key flow ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Non-TTY: resolve defaults so partial flags work in pipelines / CI
|
// Non-TTY: resolve defaults so partial flags work in pipelines / CI
|
||||||
@@ -345,7 +386,7 @@ export async function runInit(
|
|||||||
if (!opts.apiKey) {
|
if (!opts.apiKey) {
|
||||||
printError(
|
printError(
|
||||||
"Non-interactive terminal detected and --api-key is required.",
|
"Non-interactive terminal detected and --api-key is required.",
|
||||||
"Usage: mem0 init --api-key <key> [--user-id <id>]",
|
"Usage: mem0 init --api-key <key>, --email <addr>, or --agent for unattended Agent Mode bootstrap.",
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ export interface PlatformConfig {
|
|||||||
apiKey: string;
|
apiKey: string;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
userEmail: string;
|
userEmail: string;
|
||||||
|
// Agent Mode (unclaimed-shadow signup)
|
||||||
|
agentMode: boolean; // true while the key is an unclaimed agent-mode key
|
||||||
|
createdVia: string; // "agent_mode" | "email" | "api_key" | "existing_key"
|
||||||
|
claimedAt: string; // ISO timestamp once the agent has been claimed
|
||||||
|
defaultUserId: string; // `user_<slug>` returned by bootstrap; auto-default scope
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DefaultsConfig {
|
export interface DefaultsConfig {
|
||||||
@@ -54,6 +59,10 @@ export function createDefaultConfig(): Mem0Config {
|
|||||||
apiKey: "",
|
apiKey: "",
|
||||||
baseUrl: DEFAULT_BASE_URL,
|
baseUrl: DEFAULT_BASE_URL,
|
||||||
userEmail: "",
|
userEmail: "",
|
||||||
|
agentMode: false,
|
||||||
|
createdVia: "",
|
||||||
|
claimedAt: "",
|
||||||
|
defaultUserId: "",
|
||||||
},
|
},
|
||||||
telemetry: {
|
telemetry: {
|
||||||
anonymousId: "",
|
anonymousId: "",
|
||||||
@@ -79,6 +88,10 @@ export function loadConfig(): Mem0Config {
|
|||||||
config.platform.apiKey = plat.api_key ?? "";
|
config.platform.apiKey = plat.api_key ?? "";
|
||||||
config.platform.baseUrl = plat.base_url ?? DEFAULT_BASE_URL;
|
config.platform.baseUrl = plat.base_url ?? DEFAULT_BASE_URL;
|
||||||
config.platform.userEmail = plat.user_email ?? "";
|
config.platform.userEmail = plat.user_email ?? "";
|
||||||
|
config.platform.agentMode = Boolean(plat.agent_mode ?? false);
|
||||||
|
config.platform.createdVia = plat.created_via ?? "";
|
||||||
|
config.platform.claimedAt = plat.claimed_at ?? "";
|
||||||
|
config.platform.defaultUserId = plat.default_user_id ?? "";
|
||||||
|
|
||||||
const defaults = data.defaults ?? {};
|
const defaults = data.defaults ?? {};
|
||||||
config.defaults.userId = defaults.user_id ?? "";
|
config.defaults.userId = defaults.user_id ?? "";
|
||||||
@@ -118,6 +131,10 @@ export function saveConfig(config: Mem0Config): void {
|
|||||||
api_key: config.platform.apiKey,
|
api_key: config.platform.apiKey,
|
||||||
base_url: config.platform.baseUrl,
|
base_url: config.platform.baseUrl,
|
||||||
user_email: config.platform.userEmail,
|
user_email: config.platform.userEmail,
|
||||||
|
agent_mode: config.platform.agentMode,
|
||||||
|
created_via: config.platform.createdVia,
|
||||||
|
claimed_at: config.platform.claimedAt,
|
||||||
|
default_user_id: config.platform.defaultUserId,
|
||||||
},
|
},
|
||||||
telemetry: {
|
telemetry: {
|
||||||
anonymous_id: config.telemetry.anonymousId,
|
anonymous_id: config.telemetry.anonymousId,
|
||||||
|
|||||||
@@ -166,6 +166,10 @@ program.hook("preAction", (_thisCommand, actionCommand) => {
|
|||||||
parentName && parentName !== "mem0"
|
parentName && parentName !== "mem0"
|
||||||
? `${parentName}.${commandName}`
|
? `${parentName}.${commandName}`
|
||||||
: commandName;
|
: commandName;
|
||||||
|
// init fires its own telemetry from runInit with full M1-M6 props
|
||||||
|
// (mode/agent_caller/signup_source/claimed_agent_mode); skip the
|
||||||
|
// auto-fire here so we don't double-count.
|
||||||
|
if (fullCommand === "init") return;
|
||||||
const isAgent = !!(program.opts().json || program.opts().agent);
|
const isAgent = !!(program.opts().json || program.opts().agent);
|
||||||
captureEvent(
|
captureEvent(
|
||||||
`cli.${fullCommand}`,
|
`cli.${fullCommand}`,
|
||||||
@@ -193,9 +197,11 @@ program
|
|||||||
"Verification code (use with --email for non-interactive login).",
|
"Verification code (use with --email for non-interactive login).",
|
||||||
)
|
)
|
||||||
.option("--force", "Overwrite existing config without confirmation.", false)
|
.option("--force", "Overwrite existing config without confirmation.", false)
|
||||||
|
.option("--agent", "Bootstrap an unattended Agent Mode account (no email required).", false)
|
||||||
|
.option("--source <channel>", "Channel attribution for signup (e.g. github, hn, ph).")
|
||||||
.addHelpText(
|
.addHelpText(
|
||||||
"after",
|
"after",
|
||||||
"\nExamples:\n $ mem0 init\n $ mem0 init --api-key m0-xxx --user-id alice\n $ mem0 init --email you@example.com\n $ mem0 init --email you@example.com --code 123456",
|
"\nExamples:\n $ mem0 init\n $ mem0 init --api-key m0-xxx --user-id alice\n $ mem0 init --email you@example.com\n $ mem0 init --email you@example.com --code 123456\n $ mem0 init --agent # Bootstrap an Agent Mode account (unattended)\n $ mem0 init --email you@example.com # Claims an existing Agent Mode key when one is present",
|
||||||
)
|
)
|
||||||
.action(async (opts) => {
|
.action(async (opts) => {
|
||||||
const { runInit } = await import("./commands/init.js");
|
const { runInit } = await import("./commands/init.js");
|
||||||
@@ -205,6 +211,8 @@ program
|
|||||||
email: opts.email,
|
email: opts.email,
|
||||||
code: opts.code,
|
code: opts.code,
|
||||||
force: opts.force,
|
force: opts.force,
|
||||||
|
agent: opts.agent,
|
||||||
|
source: opts.source,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -115,6 +115,9 @@ export function captureEvent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// M4: every cli.* event carries agent_mode based on the config flag
|
||||||
|
// (unclaimed Agent Mode key). This is the growth-doc property used to
|
||||||
|
// join init → add → search funnels in PostHog.
|
||||||
const payload = {
|
const payload = {
|
||||||
api_key: POSTHOG_API_KEY,
|
api_key: POSTHOG_API_KEY,
|
||||||
distinct_id: distinctId,
|
distinct_id: distinctId,
|
||||||
@@ -123,6 +126,7 @@ export function captureEvent(
|
|||||||
source: "CLI",
|
source: "CLI",
|
||||||
language: "node",
|
language: "node",
|
||||||
cli_version: CLI_VERSION,
|
cli_version: CLI_VERSION,
|
||||||
|
agent_mode: Boolean(config.platform.agentMode),
|
||||||
node_version: process.version,
|
node_version: process.version,
|
||||||
os: process.platform,
|
os: process.platform,
|
||||||
...properties,
|
...properties,
|
||||||
|
|||||||
Reference in New Issue
Block a user