mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 15:30:39 +00:00
Onboard: require explicit mode for env secret refs
This commit is contained in:
committed by
Peter Steinberger
parent
4d94b05ac5
commit
cb119874dc
@@ -4,6 +4,10 @@ import {
|
||||
normalizeApiKeyInput,
|
||||
validateApiKeyInput,
|
||||
} from "./auth-choice.api-key.js";
|
||||
import {
|
||||
normalizeSecretInputModeInput,
|
||||
resolveSecretInputModeForEnvSelection,
|
||||
} from "./auth-choice.apply-helpers.js";
|
||||
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
|
||||
import { applyPrimaryModel } from "./model-picker.js";
|
||||
import { applyAuthProfileConfig, setByteplusApiKey } from "./onboard-auth.js";
|
||||
@@ -18,6 +22,7 @@ export async function applyAuthChoiceBytePlus(
|
||||
return null;
|
||||
}
|
||||
|
||||
const requestedSecretInputMode = normalizeSecretInputModeInput(params.opts?.secretInputMode);
|
||||
const envKey = resolveEnvApiKey("byteplus");
|
||||
if (envKey) {
|
||||
const useExisting = await params.prompter.confirm({
|
||||
@@ -25,7 +30,11 @@ export async function applyAuthChoiceBytePlus(
|
||||
initialValue: true,
|
||||
});
|
||||
if (useExisting) {
|
||||
await setByteplusApiKey(envKey.apiKey, params.agentDir);
|
||||
const mode = await resolveSecretInputModeForEnvSelection({
|
||||
prompter: params.prompter,
|
||||
explicitMode: requestedSecretInputMode,
|
||||
});
|
||||
await setByteplusApiKey(envKey.apiKey, params.agentDir, { secretInputMode: mode });
|
||||
const configWithAuth = applyAuthProfileConfig(params.config, {
|
||||
profileId: "byteplus:default",
|
||||
provider: "byteplus",
|
||||
@@ -50,7 +59,9 @@ export async function applyAuthChoiceBytePlus(
|
||||
}
|
||||
|
||||
const trimmed = normalizeApiKeyInput(String(key));
|
||||
await setByteplusApiKey(trimmed, params.agentDir);
|
||||
await setByteplusApiKey(trimmed, params.agentDir, {
|
||||
secretInputMode: requestedSecretInputMode,
|
||||
});
|
||||
const configWithAuth = applyAuthProfileConfig(params.config, {
|
||||
profileId: "byteplus:default",
|
||||
provider: "byteplus",
|
||||
|
||||
@@ -28,7 +28,7 @@ describe("volcengine/byteplus auth choice", () => {
|
||||
await lifecycle.cleanup();
|
||||
});
|
||||
|
||||
it("stores volcengine env key as keyRef and configures auth profile", async () => {
|
||||
it("stores volcengine env key as plaintext by default", async () => {
|
||||
const agentDir = await setupTempState();
|
||||
process.env.VOLCANO_ENGINE_API_KEY = "volc-env-key";
|
||||
|
||||
@@ -37,7 +37,7 @@ describe("volcengine/byteplus auth choice", () => {
|
||||
confirm: vi.fn(async () => true),
|
||||
text: vi.fn(async () => "unused"),
|
||||
},
|
||||
{ defaultSelect: "" },
|
||||
{ defaultSelect: "plaintext" },
|
||||
);
|
||||
const runtime = createExitThrowingRuntime();
|
||||
|
||||
@@ -55,6 +55,35 @@ describe("volcengine/byteplus auth choice", () => {
|
||||
mode: "api_key",
|
||||
});
|
||||
|
||||
const parsed = await readAuthProfilesForAgent<{
|
||||
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
|
||||
}>(agentDir);
|
||||
expect(parsed.profiles?.["volcengine:default"]?.key).toBe("volc-env-key");
|
||||
expect(parsed.profiles?.["volcengine:default"]?.keyRef).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stores volcengine env key as keyRef in ref mode", async () => {
|
||||
const agentDir = await setupTempState();
|
||||
process.env.VOLCANO_ENGINE_API_KEY = "volc-env-key";
|
||||
|
||||
const prompter = createWizardPrompter(
|
||||
{
|
||||
confirm: vi.fn(async () => true),
|
||||
text: vi.fn(async () => "unused"),
|
||||
},
|
||||
{ defaultSelect: "ref" },
|
||||
);
|
||||
const runtime = createExitThrowingRuntime();
|
||||
|
||||
const result = await applyAuthChoiceVolcengine({
|
||||
authChoice: "volcengine-api-key",
|
||||
config: {},
|
||||
prompter,
|
||||
runtime,
|
||||
setDefaultModel: true,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
const parsed = await readAuthProfilesForAgent<{
|
||||
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
|
||||
}>(agentDir);
|
||||
@@ -64,7 +93,7 @@ describe("volcengine/byteplus auth choice", () => {
|
||||
expect(parsed.profiles?.["volcengine:default"]?.key).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stores byteplus env key as keyRef and configures auth profile", async () => {
|
||||
it("stores byteplus env key as plaintext by default", async () => {
|
||||
const agentDir = await setupTempState();
|
||||
process.env.BYTEPLUS_API_KEY = "byte-env-key";
|
||||
|
||||
@@ -73,7 +102,7 @@ describe("volcengine/byteplus auth choice", () => {
|
||||
confirm: vi.fn(async () => true),
|
||||
text: vi.fn(async () => "unused"),
|
||||
},
|
||||
{ defaultSelect: "" },
|
||||
{ defaultSelect: "plaintext" },
|
||||
);
|
||||
const runtime = createExitThrowingRuntime();
|
||||
|
||||
@@ -91,6 +120,35 @@ describe("volcengine/byteplus auth choice", () => {
|
||||
mode: "api_key",
|
||||
});
|
||||
|
||||
const parsed = await readAuthProfilesForAgent<{
|
||||
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
|
||||
}>(agentDir);
|
||||
expect(parsed.profiles?.["byteplus:default"]?.key).toBe("byte-env-key");
|
||||
expect(parsed.profiles?.["byteplus:default"]?.keyRef).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stores byteplus env key as keyRef in ref mode", async () => {
|
||||
const agentDir = await setupTempState();
|
||||
process.env.BYTEPLUS_API_KEY = "byte-env-key";
|
||||
|
||||
const prompter = createWizardPrompter(
|
||||
{
|
||||
confirm: vi.fn(async () => true),
|
||||
text: vi.fn(async () => "unused"),
|
||||
},
|
||||
{ defaultSelect: "ref" },
|
||||
);
|
||||
const runtime = createExitThrowingRuntime();
|
||||
|
||||
const result = await applyAuthChoiceBytePlus({
|
||||
authChoice: "byteplus-api-key",
|
||||
config: {},
|
||||
prompter,
|
||||
runtime,
|
||||
setDefaultModel: true,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
const parsed = await readAuthProfilesForAgent<{
|
||||
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
|
||||
}>(agentDir);
|
||||
|
||||
@@ -4,6 +4,10 @@ import {
|
||||
normalizeApiKeyInput,
|
||||
validateApiKeyInput,
|
||||
} from "./auth-choice.api-key.js";
|
||||
import {
|
||||
normalizeSecretInputModeInput,
|
||||
resolveSecretInputModeForEnvSelection,
|
||||
} from "./auth-choice.apply-helpers.js";
|
||||
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
|
||||
import { applyPrimaryModel } from "./model-picker.js";
|
||||
import { applyAuthProfileConfig, setVolcengineApiKey } from "./onboard-auth.js";
|
||||
@@ -18,6 +22,7 @@ export async function applyAuthChoiceVolcengine(
|
||||
return null;
|
||||
}
|
||||
|
||||
const requestedSecretInputMode = normalizeSecretInputModeInput(params.opts?.secretInputMode);
|
||||
const envKey = resolveEnvApiKey("volcengine");
|
||||
if (envKey) {
|
||||
const useExisting = await params.prompter.confirm({
|
||||
@@ -25,7 +30,11 @@ export async function applyAuthChoiceVolcengine(
|
||||
initialValue: true,
|
||||
});
|
||||
if (useExisting) {
|
||||
await setVolcengineApiKey(envKey.apiKey, params.agentDir);
|
||||
const mode = await resolveSecretInputModeForEnvSelection({
|
||||
prompter: params.prompter,
|
||||
explicitMode: requestedSecretInputMode,
|
||||
});
|
||||
await setVolcengineApiKey(envKey.apiKey, params.agentDir, { secretInputMode: mode });
|
||||
const configWithAuth = applyAuthProfileConfig(params.config, {
|
||||
profileId: "volcengine:default",
|
||||
provider: "volcengine",
|
||||
@@ -50,7 +59,9 @@ export async function applyAuthChoiceVolcengine(
|
||||
}
|
||||
|
||||
const trimmed = normalizeApiKeyInput(String(key));
|
||||
await setVolcengineApiKey(trimmed, params.agentDir);
|
||||
await setVolcengineApiKey(trimmed, params.agentDir, {
|
||||
secretInputMode: requestedSecretInputMode,
|
||||
});
|
||||
const configWithAuth = applyAuthProfileConfig(params.config, {
|
||||
profileId: "volcengine:default",
|
||||
provider: "volcengine",
|
||||
|
||||
@@ -44,7 +44,7 @@ describe("onboard auth credentials secret refs", () => {
|
||||
expect(parsed.profiles?.["moonshot:default"]?.keyRef).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stores env-backed moonshot key as keyRef in ref mode", async () => {
|
||||
it("stores env-backed moonshot key as keyRef when secret-input-mode=ref", async () => {
|
||||
const env = await setupAuthTestEnv("openclaw-onboard-auth-credentials-ref-");
|
||||
lifecycle.setStateDir(env.stateDir);
|
||||
process.env.MOONSHOT_API_KEY = "sk-moonshot-env";
|
||||
@@ -142,14 +142,14 @@ describe("onboard auth credentials secret refs", () => {
|
||||
expect(parsed.profiles?.["openai:default"]?.key).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stores env-backed volcengine and byteplus keys as keyRef", async () => {
|
||||
it("stores env-backed volcengine and byteplus keys as keyRef in ref mode", async () => {
|
||||
const env = await setupAuthTestEnv("openclaw-onboard-auth-credentials-volc-byte-");
|
||||
lifecycle.setStateDir(env.stateDir);
|
||||
process.env.VOLCANO_ENGINE_API_KEY = "volcengine-secret";
|
||||
process.env.BYTEPLUS_API_KEY = "byteplus-secret";
|
||||
|
||||
await setVolcengineApiKey("volcengine-secret");
|
||||
await setByteplusApiKey("byteplus-secret");
|
||||
await setVolcengineApiKey("volcengine-secret", env.agentDir, { secretInputMode: "ref" });
|
||||
await setByteplusApiKey("byteplus-secret", env.agentDir, { secretInputMode: "ref" });
|
||||
|
||||
const parsed = await readAuthProfilesForAgent<{
|
||||
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { parseDurationMs } from "../../../cli/parse-duration.js";
|
||||
import type { OpenClawConfig } from "../../../config/config.js";
|
||||
import type { RuntimeEnv } from "../../../runtime.js";
|
||||
import { normalizeSecretInput } from "../../../utils/normalize-secret-input.js";
|
||||
import { normalizeSecretInputModeInput } from "../../auth-choice.apply-helpers.js";
|
||||
import { buildTokenProfileId, validateAnthropicSetupToken } from "../../auth-token.js";
|
||||
import { applyGoogleGeminiModelDefault } from "../../google-gemini-model-default.js";
|
||||
import { applyPrimaryModel } from "../../model-picker.js";
|
||||
@@ -74,6 +75,15 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
}): Promise<OpenClawConfig | null> {
|
||||
const { authChoice, opts, runtime, baseConfig } = params;
|
||||
let nextConfig = params.nextConfig;
|
||||
const requestedSecretInputMode = normalizeSecretInputModeInput(opts.secretInputMode);
|
||||
if (opts.secretInputMode && !requestedSecretInputMode) {
|
||||
runtime.error('Invalid --secret-input-mode. Use "plaintext" or "ref".');
|
||||
runtime.exit(1);
|
||||
return null;
|
||||
}
|
||||
const apiKeyStorageOptions = requestedSecretInputMode
|
||||
? { secretInputMode: requestedSecretInputMode }
|
||||
: undefined;
|
||||
|
||||
if (authChoice === "claude-cli" || authChoice === "codex-cli") {
|
||||
runtime.error(
|
||||
@@ -121,7 +131,7 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
return null;
|
||||
}
|
||||
if (resolved.source !== "profile") {
|
||||
await setAnthropicApiKey(resolved.key);
|
||||
await setAnthropicApiKey(resolved.key, undefined, apiKeyStorageOptions);
|
||||
}
|
||||
return applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "anthropic:default",
|
||||
@@ -198,7 +208,7 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
return null;
|
||||
}
|
||||
if (resolved.source !== "profile") {
|
||||
await setGeminiApiKey(resolved.key);
|
||||
await setGeminiApiKey(resolved.key, undefined, apiKeyStorageOptions);
|
||||
}
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "google:default",
|
||||
@@ -227,7 +237,7 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
return null;
|
||||
}
|
||||
if (resolved.source !== "profile") {
|
||||
await setZaiApiKey(resolved.key);
|
||||
await setZaiApiKey(resolved.key, undefined, apiKeyStorageOptions);
|
||||
}
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "zai:default",
|
||||
@@ -276,7 +286,7 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
return null;
|
||||
}
|
||||
if (resolved.source !== "profile") {
|
||||
await setXiaomiApiKey(resolved.key);
|
||||
await setXiaomiApiKey(resolved.key, undefined, apiKeyStorageOptions);
|
||||
}
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "xiaomi:default",
|
||||
@@ -299,7 +309,7 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
return null;
|
||||
}
|
||||
if (resolved.source !== "profile") {
|
||||
setXaiApiKey(resolved.key);
|
||||
setXaiApiKey(resolved.key, undefined, apiKeyStorageOptions);
|
||||
}
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "xai:default",
|
||||
@@ -322,7 +332,7 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
return null;
|
||||
}
|
||||
if (resolved.source !== "profile") {
|
||||
await setMistralApiKey(resolved.key);
|
||||
await setMistralApiKey(resolved.key, undefined, apiKeyStorageOptions);
|
||||
}
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "mistral:default",
|
||||
@@ -345,7 +355,7 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
return null;
|
||||
}
|
||||
if (resolved.source !== "profile") {
|
||||
await setVolcengineApiKey(resolved.key);
|
||||
await setVolcengineApiKey(resolved.key, undefined, apiKeyStorageOptions);
|
||||
}
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "volcengine:default",
|
||||
@@ -368,7 +378,7 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
return null;
|
||||
}
|
||||
if (resolved.source !== "profile") {
|
||||
await setByteplusApiKey(resolved.key);
|
||||
await setByteplusApiKey(resolved.key, undefined, apiKeyStorageOptions);
|
||||
}
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "byteplus:default",
|
||||
@@ -391,7 +401,7 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
return null;
|
||||
}
|
||||
if (resolved.source !== "profile") {
|
||||
setQianfanApiKey(resolved.key);
|
||||
setQianfanApiKey(resolved.key, undefined, apiKeyStorageOptions);
|
||||
}
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "qianfan:default",
|
||||
@@ -414,7 +424,7 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
return null;
|
||||
}
|
||||
if (resolved.source !== "profile") {
|
||||
await setOpenaiApiKey(resolved.key);
|
||||
await setOpenaiApiKey(resolved.key, undefined, apiKeyStorageOptions);
|
||||
}
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "openai:default",
|
||||
@@ -437,7 +447,7 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
return null;
|
||||
}
|
||||
if (resolved.source !== "profile") {
|
||||
await setOpenrouterApiKey(resolved.key);
|
||||
await setOpenrouterApiKey(resolved.key, undefined, apiKeyStorageOptions);
|
||||
}
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "openrouter:default",
|
||||
@@ -460,7 +470,7 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
return null;
|
||||
}
|
||||
if (resolved.source !== "profile") {
|
||||
await setKilocodeApiKey(resolved.key);
|
||||
await setKilocodeApiKey(resolved.key, undefined, apiKeyStorageOptions);
|
||||
}
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "kilocode:default",
|
||||
@@ -483,7 +493,7 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
return null;
|
||||
}
|
||||
if (resolved.source !== "profile") {
|
||||
await setLitellmApiKey(resolved.key);
|
||||
await setLitellmApiKey(resolved.key, undefined, apiKeyStorageOptions);
|
||||
}
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "litellm:default",
|
||||
@@ -506,7 +516,7 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
return null;
|
||||
}
|
||||
if (resolved.source !== "profile") {
|
||||
await setVercelAiGatewayApiKey(resolved.key);
|
||||
await setVercelAiGatewayApiKey(resolved.key, undefined, apiKeyStorageOptions);
|
||||
}
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "vercel-ai-gateway:default",
|
||||
@@ -541,7 +551,13 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
return null;
|
||||
}
|
||||
if (resolved.source !== "profile") {
|
||||
await setCloudflareAiGatewayConfig(accountId, gatewayId, resolved.key);
|
||||
await setCloudflareAiGatewayConfig(
|
||||
accountId,
|
||||
gatewayId,
|
||||
resolved.key,
|
||||
undefined,
|
||||
apiKeyStorageOptions,
|
||||
);
|
||||
}
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "cloudflare-ai-gateway:default",
|
||||
@@ -569,7 +585,7 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
return null;
|
||||
}
|
||||
if (resolved.source !== "profile") {
|
||||
await setMoonshotApiKey(resolved.key);
|
||||
await setMoonshotApiKey(resolved.key, undefined, apiKeyStorageOptions);
|
||||
}
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "moonshot:default",
|
||||
@@ -600,7 +616,7 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
return null;
|
||||
}
|
||||
if (resolved.source !== "profile") {
|
||||
await setKimiCodingApiKey(resolved.key);
|
||||
await setKimiCodingApiKey(resolved.key, undefined, apiKeyStorageOptions);
|
||||
}
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "kimi-coding:default",
|
||||
@@ -623,7 +639,7 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
return null;
|
||||
}
|
||||
if (resolved.source !== "profile") {
|
||||
await setSyntheticApiKey(resolved.key);
|
||||
await setSyntheticApiKey(resolved.key, undefined, apiKeyStorageOptions);
|
||||
}
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "synthetic:default",
|
||||
@@ -646,7 +662,7 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
return null;
|
||||
}
|
||||
if (resolved.source !== "profile") {
|
||||
await setVeniceApiKey(resolved.key);
|
||||
await setVeniceApiKey(resolved.key, undefined, apiKeyStorageOptions);
|
||||
}
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "venice:default",
|
||||
@@ -677,7 +693,7 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
return null;
|
||||
}
|
||||
if (resolved.source !== "profile") {
|
||||
await setMinimaxApiKey(resolved.key, undefined, profileId);
|
||||
await setMinimaxApiKey(resolved.key, undefined, profileId, apiKeyStorageOptions);
|
||||
}
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId,
|
||||
@@ -708,7 +724,7 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
return null;
|
||||
}
|
||||
if (resolved.source !== "profile") {
|
||||
await setOpencodeZenApiKey(resolved.key);
|
||||
await setOpencodeZenApiKey(resolved.key, undefined, apiKeyStorageOptions);
|
||||
}
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "opencode:default",
|
||||
@@ -731,7 +747,7 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
return null;
|
||||
}
|
||||
if (resolved.source !== "profile") {
|
||||
await setTogetherApiKey(resolved.key);
|
||||
await setTogetherApiKey(resolved.key, undefined, apiKeyStorageOptions);
|
||||
}
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "together:default",
|
||||
@@ -754,7 +770,7 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
return null;
|
||||
}
|
||||
if (resolved.source !== "profile") {
|
||||
await setHuggingfaceApiKey(resolved.key);
|
||||
await setHuggingfaceApiKey(resolved.key, undefined, apiKeyStorageOptions);
|
||||
}
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "huggingface:default",
|
||||
|
||||
Reference in New Issue
Block a user