fix(mqtt): persist topics and support advanced options
This commit is contained in:
1 parent
670ddcfcb3
commit
ab586d121a
19 files changed
+347
-51
No files matched your search
@@ -710,14 +710,18 @@ const mqttClientId = ref("");
|
||||
const mqttProtocolVersion = ref<"v3" | "v4" | "v5">("v5");
|
||||
const mqttTransportMode = ref<"tcp" | "websocket">("tcp");
|
||||
const mqttWsPath = ref("/mqtt");
|
||||
const mqttAuthKind = ref<"none" | "password">("none");
|
||||
const mqttAuthKind = ref<"none" | "password" | "certificate">("none");
|
||||
const mqttUsername = ref("");
|
||||
const mqttPassword = ref("");
|
||||
const mqttCaCertPath = ref("");
|
||||
const mqttClientCertPath = ref("");
|
||||
const mqttClientKeyPath = ref("");
|
||||
const mqttTls = ref(false);
|
||||
const mqttTlsSkipVerify = ref(false);
|
||||
const mqttKeepAliveSecs = ref(60);
|
||||
const mqttConnectTimeoutSecs = ref(30);
|
||||
const mqttMaxPacketSizeBytes = ref(16 * 1024 * 1024);
|
||||
const mqttSavedTopics = ref<MqttConnectionConfig["savedTopics"]>([]);
|
||||
const nacosPrimaryAddressPlaceholder = computed(() => {
|
||||
return "http://127.0.0.1:8848/nacos";
|
||||
});
|
||||
@@ -1231,15 +1235,29 @@ function resetMqttFields(config?: Partial<MqttConnectionConfig>) {
|
||||
mqttKeepAliveSecs.value = Math.max(1, config?.keepAliveSecs || 60);
|
||||
mqttConnectTimeoutSecs.value = Math.max(1, config?.connectTimeoutSecs || 30);
|
||||
mqttMaxPacketSizeBytes.value = Math.min(268435455, Math.max(1024, config?.maxPacketSizeBytes || 16 * 1024 * 1024));
|
||||
mqttSavedTopics.value = config?.savedTopics ?? [];
|
||||
const auth = config?.auth;
|
||||
if (auth && auth.kind === "password") {
|
||||
mqttAuthKind.value = "password";
|
||||
mqttUsername.value = auth.username || "";
|
||||
mqttPassword.value = auth.password || "";
|
||||
mqttCaCertPath.value = "";
|
||||
mqttClientCertPath.value = "";
|
||||
mqttClientKeyPath.value = "";
|
||||
} else if (auth && auth.kind === "certificate") {
|
||||
mqttAuthKind.value = "certificate";
|
||||
mqttUsername.value = "";
|
||||
mqttPassword.value = "";
|
||||
mqttCaCertPath.value = auth.caCertPath || "";
|
||||
mqttClientCertPath.value = auth.clientCertPath || "";
|
||||
mqttClientKeyPath.value = auth.clientKeyPath || "";
|
||||
} else {
|
||||
mqttAuthKind.value = "none";
|
||||
mqttUsername.value = "";
|
||||
mqttPassword.value = "";
|
||||
mqttCaCertPath.value = "";
|
||||
mqttClientCertPath.value = "";
|
||||
mqttClientKeyPath.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1252,7 +1270,12 @@ function hydrateMqttFields(value: unknown) {
|
||||
}
|
||||
|
||||
function buildMqttExternalConfig(): MqttConnectionConfig {
|
||||
const auth: MqttConnectionConfig["auth"] = mqttAuthKind.value === "password" ? { kind: "password", username: mqttUsername.value, password: mqttPassword.value } : { kind: "none" };
|
||||
const auth: MqttConnectionConfig["auth"] =
|
||||
mqttAuthKind.value === "password"
|
||||
? { kind: "password", username: mqttUsername.value, password: mqttPassword.value }
|
||||
: mqttAuthKind.value === "certificate"
|
||||
? { kind: "certificate", caCertPath: mqttCaCertPath.value || undefined, clientCertPath: mqttClientCertPath.value || undefined, clientKeyPath: mqttClientKeyPath.value || undefined }
|
||||
: { kind: "none" };
|
||||
|
||||
return {
|
||||
host: mqttHost.value.trim(),
|
||||
@@ -1266,6 +1289,7 @@ function buildMqttExternalConfig(): MqttConnectionConfig {
|
||||
keepAliveSecs: Math.max(1, mqttKeepAliveSecs.value),
|
||||
connectTimeoutSecs: Math.max(1, mqttConnectTimeoutSecs.value),
|
||||
maxPacketSizeBytes: Math.min(268435455, Math.max(1024, mqttMaxPacketSizeBytes.value)),
|
||||
savedTopics: mqttSavedTopics.value,
|
||||
wsPath: mqttTransportMode.value === "websocket" ? mqttWsPath.value || "/mqtt" : undefined,
|
||||
};
|
||||
}
|
||||
@@ -6050,6 +6074,7 @@ function openExternalUrl(url: string) {
|
||||
<div class="col-span-3 flex gap-2">
|
||||
<Button size="sm" :variant="mqttAuthKind === 'none' ? 'default' : 'outline'" @click="mqttAuthKind = 'none'">{{ t("connection.mqAuthNone") }}</Button>
|
||||
<Button size="sm" :variant="mqttAuthKind === 'password' ? 'default' : 'outline'" @click="mqttAuthKind = 'password'">{{ t("connection.mqAuthBasic") }}</Button>
|
||||
<Button size="sm" :variant="mqttAuthKind === 'certificate' ? 'default' : 'outline'" @click="mqttAuthKind = 'certificate'">{{ t("connection.mqttAuthCertificate") }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="mqttAuthKind === 'password'">
|
||||
@@ -6062,6 +6087,20 @@ function openExternalUrl(url: string) {
|
||||
<Input v-model="mqttPassword" type="password" class="col-span-3" :placeholder="t('connection.mqttPasswordPlaceholder')" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="mqttAuthKind === 'certificate'">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">{{ t("connection.mqttCaCertPath") }}</Label>
|
||||
<Input v-model="mqttCaCertPath" class="col-span-3" placeholder="/path/to/ca.pem" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">{{ t("connection.mqttClientCertPath") }}</Label>
|
||||
<Input v-model="mqttClientCertPath" class="col-span-3" placeholder="/path/to/client.crt" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">{{ t("connection.mqttClientKeyPath") }}</Label>
|
||||
<Input v-model="mqttClientKeyPath" class="col-span-3" placeholder="/path/to/client.key" />
|
||||
</div>
|
||||
</template>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">{{ t("connection.mqttTls") }}</Label>
|
||||
<div class="col-span-3 flex items-center gap-2">
|
||||
|
||||
@@ -20,6 +20,7 @@ const brokerInfo = ref<MqttBrokerInfo | null>(null);
|
||||
const topicTree = ref<MqttTopicNode | null>(null);
|
||||
const messages = ref<MqttMessage[]>([]);
|
||||
const subscribedTopics = ref<[string, string][]>([]);
|
||||
const noLocalSubscribe = ref(false);
|
||||
const selectedTopic = ref<string>(props.initialTopic ?? "");
|
||||
const loading = ref(true);
|
||||
const error = ref<string | null>(null);
|
||||
@@ -52,9 +53,9 @@ async function refreshData() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubscribe(topic: string) {
|
||||
async function handleSubscribe(topic: string, noLocal = noLocalSubscribe.value) {
|
||||
try {
|
||||
await mqttSubscribe(props.connectionId, topic, "atmostonce");
|
||||
await mqttSubscribe(props.connectionId, topic, "atmostonce", noLocal);
|
||||
await refreshData();
|
||||
} catch (e) {
|
||||
error.value = String(e);
|
||||
@@ -194,6 +195,7 @@ onUnmounted(() => {
|
||||
"
|
||||
>
|
||||
<input class="flex-1 h-7 px-2 text-xs rounded border bg-transparent" :placeholder="t('connection.mqttSubscribePlaceholder')" />
|
||||
<label class="flex items-center gap-1 text-[10px] whitespace-nowrap"><input v-model="noLocalSubscribe" type="checkbox" />{{ t("connection.mqttNoLocal") }}</label>
|
||||
<Button size="sm" variant="ghost" class="h-7 text-xs" type="submit">{{ t("connection.mqttSubscribe") }}</Button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -216,13 +218,13 @@ onUnmounted(() => {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 overflow-auto">
|
||||
<div class="flex-1 overflow-auto flex flex-col">
|
||||
<div v-if="messages.length === 0" class="text-xs text-muted-foreground p-4 text-center">{{ t("connection.mqttNoMessages") }}</div>
|
||||
<div
|
||||
v-for="(msg, i) in messages"
|
||||
:key="i"
|
||||
class="px-3 py-2 border-b last:border-b-0 cursor-pointer text-xs"
|
||||
:class="msg.direction === 'sent' ? 'bg-emerald-50/60 dark:bg-emerald-950/20 hover:bg-emerald-100/60 dark:hover:bg-emerald-950/40' : 'hover:bg-muted/50'"
|
||||
class="px-3 py-2 border-b last:border-b-0 cursor-pointer text-xs self-start w-full"
|
||||
:class="msg.direction === 'sent' ? 'bg-emerald-50/60 dark:bg-emerald-950/20 hover:bg-emerald-100/60 dark:hover:bg-emerald-950/40 ml-auto max-w-[85%] rounded-l-md' : 'hover:bg-muted/50 mr-auto max-w-[85%] rounded-r-md'"
|
||||
@click="handleTopicClick(msg.topic)"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-0.5">
|
||||
|
||||
@@ -32,7 +32,7 @@ const qosHints = computed(() => [t("connection.mqttQosAtMostOnce"), t("connectio
|
||||
const payloadPlaceholder = computed(() => {
|
||||
switch (encoding.value) {
|
||||
case "json":
|
||||
return t("connection.mqttPayloadPlaceholderJson");
|
||||
return t("connection.mqttPayloadPlaceholderJson", { example: '{"key": "value"}' });
|
||||
case "base64":
|
||||
return t("connection.mqttPayloadPlaceholderBase64");
|
||||
case "hex":
|
||||
|
||||
@@ -526,6 +526,10 @@ export default {
|
||||
mqttUsername: "Username",
|
||||
mqttUsernamePlaceholder: "Optional",
|
||||
mqttPassword: "Password",
|
||||
mqttAuthCertificate: "Client certificate",
|
||||
mqttCaCertPath: "CA certificate path",
|
||||
mqttClientCertPath: "Client certificate path",
|
||||
mqttClientKeyPath: "Client private key path",
|
||||
mqttPasswordPlaceholder: "Optional",
|
||||
mqttTls: "TLS",
|
||||
mqttTlsSkipVerify: "Skip certificate verification",
|
||||
@@ -542,6 +546,7 @@ export default {
|
||||
mqttMessages: "Messages",
|
||||
mqttPublish: "Publish Message",
|
||||
mqttSubscribe: "Subscribe",
|
||||
mqttNoLocal: "No Local",
|
||||
mqttUnsubscribe: "Unsubscribe",
|
||||
mqttNoMessages: "No messages",
|
||||
mqttNoTopics: "No subscribed topics",
|
||||
@@ -571,7 +576,7 @@ export default {
|
||||
mqttPayloadEncoding: "Payload encoding",
|
||||
mqttPayload: "Payload",
|
||||
mqttPayloadPlaceholderPlaintext: "Enter message content...",
|
||||
mqttPayloadPlaceholderJson: 'Enter JSON, for example {"key": "value"}',
|
||||
mqttPayloadPlaceholderJson: "Enter JSON, for example {example}",
|
||||
mqttPayloadPlaceholderBase64: "Enter Base64-encoded data",
|
||||
mqttPayloadPlaceholderHex: "Enter hexadecimal data, for example 48 65 6C 6C 6F",
|
||||
mqttPayloadPlaceholderStructured: "Enter a JSON value; it will be encoded as {encoding}",
|
||||
|
||||
@@ -792,7 +792,7 @@ export default withEnglishFallback({
|
||||
mqttPayloadEncoding: "Codificación del Payload",
|
||||
mqttPayload: "Payload",
|
||||
mqttPayloadPlaceholderPlaintext: "Introduce el contenido del mensaje...",
|
||||
mqttPayloadPlaceholderJson: 'Introduce JSON, por ejemplo {"key": "value"}',
|
||||
mqttPayloadPlaceholderJson: "Introduce JSON, por ejemplo {example}",
|
||||
mqttPayloadPlaceholderBase64: "Introduce datos codificados en Base64",
|
||||
mqttPayloadPlaceholderHex: "Introduce datos hexadecimales, por ejemplo 48 65 6C 6C 6F",
|
||||
mqttPayloadPlaceholderStructured: "Introduce un valor JSON; se codificará como {encoding}",
|
||||
@@ -812,6 +812,11 @@ export default withEnglishFallback({
|
||||
zookeeperAuthMethod: "Método de autenticación",
|
||||
zookeeperAuthDigest: "Digest (predeterminado)",
|
||||
zookeeperAuthSaslDigest: "SASL DIGEST-MD5",
|
||||
mqttAuthCertificate: "Certificado de cliente",
|
||||
mqttCaCertPath: "Ruta del certificado CA",
|
||||
mqttClientCertPath: "Ruta del certificado de cliente",
|
||||
mqttClientKeyPath: "Ruta de la clave privada del cliente",
|
||||
mqttNoLocal: "Prohibir reenvío local",
|
||||
},
|
||||
editor: {
|
||||
duckdbDraining: "La consulta anterior de DuckDB aún se está deteniendo. Vuelva a intentarlo en breve.",
|
||||
|
||||
@@ -790,7 +790,7 @@ export default withEnglishFallback({
|
||||
mqttPayloadEncoding: "Codifica del Payload",
|
||||
mqttPayload: "Payload",
|
||||
mqttPayloadPlaceholderPlaintext: "Inserisci il contenuto del messaggio...",
|
||||
mqttPayloadPlaceholderJson: 'Inserisci JSON, ad esempio {"key": "value"}',
|
||||
mqttPayloadPlaceholderJson: "Inserisci JSON, ad esempio {example}",
|
||||
mqttPayloadPlaceholderBase64: "Inserisci dati codificati in Base64",
|
||||
mqttPayloadPlaceholderHex: "Inserisci dati esadecimali, ad esempio 48 65 6C 6C 6F",
|
||||
mqttPayloadPlaceholderStructured: "Inserisci un valore JSON; verrà codificato come {encoding}",
|
||||
@@ -810,6 +810,11 @@ export default withEnglishFallback({
|
||||
zookeeperAuthMethod: "Metodo di autenticazione",
|
||||
zookeeperAuthDigest: "Digest (predefinito)",
|
||||
zookeeperAuthSaslDigest: "SASL DIGEST-MD5",
|
||||
mqttAuthCertificate: "Certificato client",
|
||||
mqttCaCertPath: "Percorso del certificato CA",
|
||||
mqttClientCertPath: "Percorso del certificato client",
|
||||
mqttClientKeyPath: "Percorso della chiave privata client",
|
||||
mqttNoLocal: "Disabilita inoltro locale",
|
||||
},
|
||||
editor: {
|
||||
duckdbDraining: "La query DuckDB precedente è ancora in fase di arresto. Riprova a breve.",
|
||||
|
||||
@@ -790,7 +790,7 @@ export default withEnglishFallback({
|
||||
mqttPayloadEncoding: "ペイロードのエンコード",
|
||||
mqttPayload: "ペイロード",
|
||||
mqttPayloadPlaceholderPlaintext: "メッセージ内容を入力...",
|
||||
mqttPayloadPlaceholderJson: 'JSON を入力(例: {"key": "value"})',
|
||||
mqttPayloadPlaceholderJson: "JSON を入力(例: {example})",
|
||||
mqttPayloadPlaceholderBase64: "Base64 エンコード済みデータを入力",
|
||||
mqttPayloadPlaceholderHex: "16 進数データを入力(例: 48 65 6C 6C 6F)",
|
||||
mqttPayloadPlaceholderStructured: "JSON 値を入力してください。{encoding} としてエンコードされます",
|
||||
@@ -831,6 +831,11 @@ export default withEnglishFallback({
|
||||
zookeeperAuthMethod: "認証方式",
|
||||
zookeeperAuthDigest: "Digest(デフォルト)",
|
||||
zookeeperAuthSaslDigest: "SASL DIGEST-MD5",
|
||||
mqttAuthCertificate: "クライアント証明書",
|
||||
mqttCaCertPath: "CA証明書パス",
|
||||
mqttClientCertPath: "クライアント証明書パス",
|
||||
mqttClientKeyPath: "クライアント秘密鍵パス",
|
||||
mqttNoLocal: "ローカル転送禁止",
|
||||
},
|
||||
editor: {
|
||||
duckdbDraining: "前回の DuckDB クエリはまだ停止処理中です。しばらくしてから再試行してください。",
|
||||
|
||||
@@ -791,7 +791,7 @@ export default withEnglishFallback({
|
||||
mqttPayloadEncoding: "Codificação do Payload",
|
||||
mqttPayload: "Payload",
|
||||
mqttPayloadPlaceholderPlaintext: "Digite o conteúdo da mensagem...",
|
||||
mqttPayloadPlaceholderJson: 'Digite JSON, por exemplo {"key": "value"}',
|
||||
mqttPayloadPlaceholderJson: "Digite JSON, por exemplo {example}",
|
||||
mqttPayloadPlaceholderBase64: "Digite dados codificados em Base64",
|
||||
mqttPayloadPlaceholderHex: "Digite dados hexadecimais, por exemplo 48 65 6C 6C 6F",
|
||||
mqttPayloadPlaceholderStructured: "Digite um valor JSON; ele será codificado como {encoding}",
|
||||
@@ -811,6 +811,11 @@ export default withEnglishFallback({
|
||||
zookeeperAuthMethod: "Método de autenticação",
|
||||
zookeeperAuthDigest: "Digest (padrão)",
|
||||
zookeeperAuthSaslDigest: "SASL DIGEST-MD5",
|
||||
mqttAuthCertificate: "Certificado do cliente",
|
||||
mqttCaCertPath: "Caminho do certificado CA",
|
||||
mqttClientCertPath: "Caminho do certificado do cliente",
|
||||
mqttClientKeyPath: "Caminho da chave privada do cliente",
|
||||
mqttNoLocal: "Proibir encaminhamento local",
|
||||
},
|
||||
editor: {
|
||||
duckdbDraining: "A consulta anterior do DuckDB ainda está sendo interrompida. Tente novamente em breve.",
|
||||
|
||||
@@ -527,6 +527,10 @@ export default withEnglishFallback({
|
||||
mqttUsername: "用户名",
|
||||
mqttUsernamePlaceholder: "选填",
|
||||
mqttPassword: "密码",
|
||||
mqttAuthCertificate: "客户端证书",
|
||||
mqttCaCertPath: "CA 证书路径",
|
||||
mqttClientCertPath: "客户端证书路径",
|
||||
mqttClientKeyPath: "客户端私钥路径",
|
||||
mqttPasswordPlaceholder: "选填",
|
||||
mqttTls: "TLS",
|
||||
mqttTlsSkipVerify: "跳过证书校验",
|
||||
@@ -543,6 +547,7 @@ export default withEnglishFallback({
|
||||
mqttMessages: "消息",
|
||||
mqttPublish: "发布消息",
|
||||
mqttSubscribe: "订阅",
|
||||
mqttNoLocal: "禁止本地转发",
|
||||
mqttUnsubscribe: "取消订阅",
|
||||
mqttNoMessages: "暂无消息",
|
||||
mqttNoTopics: "暂无已订阅的 Topic",
|
||||
@@ -572,7 +577,7 @@ export default withEnglishFallback({
|
||||
mqttPayloadEncoding: "Payload 编码",
|
||||
mqttPayload: "消息内容 (Payload)",
|
||||
mqttPayloadPlaceholderPlaintext: "输入消息内容...",
|
||||
mqttPayloadPlaceholderJson: '输入 JSON,例如 {"key": "value"}',
|
||||
mqttPayloadPlaceholderJson: "输入 JSON,例如 {example}",
|
||||
mqttPayloadPlaceholderBase64: "输入 Base64 编码的数据",
|
||||
mqttPayloadPlaceholderHex: "输入十六进制数据,例如 48 65 6C 6C 6F",
|
||||
mqttPayloadPlaceholderStructured: "输入 JSON 值,发送时将自动编码为 {encoding}",
|
||||
|
||||
@@ -790,7 +790,7 @@ export default withEnglishFallback({
|
||||
mqttPayloadEncoding: "Payload 編碼",
|
||||
mqttPayload: "訊息內容 (Payload)",
|
||||
mqttPayloadPlaceholderPlaintext: "輸入訊息內容...",
|
||||
mqttPayloadPlaceholderJson: '輸入 JSON,例如 {"key": "value"}',
|
||||
mqttPayloadPlaceholderJson: "輸入 JSON,例如 {example}",
|
||||
mqttPayloadPlaceholderBase64: "輸入 Base64 編碼的資料",
|
||||
mqttPayloadPlaceholderHex: "輸入十六進位資料,例如 48 65 6C 6C 6F",
|
||||
mqttPayloadPlaceholderStructured: "輸入 JSON 值,傳送時將自動編碼為 {encoding}",
|
||||
@@ -810,6 +810,11 @@ export default withEnglishFallback({
|
||||
zookeeperAuthMethod: "認證方式",
|
||||
zookeeperAuthDigest: "Digest(預設)",
|
||||
zookeeperAuthSaslDigest: "SASL DIGEST-MD5",
|
||||
mqttAuthCertificate: "用戶端憑證",
|
||||
mqttCaCertPath: "CA 憑證路徑",
|
||||
mqttClientCertPath: "用戶端憑證路徑",
|
||||
mqttClientKeyPath: "用戶端私鑰路徑",
|
||||
mqttNoLocal: "禁止本機轉送",
|
||||
},
|
||||
editor: {
|
||||
duckdbDraining: "上一筆 DuckDB 查詢仍在停止,請稍後重試。",
|
||||
|
||||
@@ -9,7 +9,7 @@ export async function mqttGetBrokerInfo(_connectionId: string) {
|
||||
mqttWebNotAvailable("mqttGetBrokerInfo");
|
||||
}
|
||||
|
||||
export async function mqttSubscribe(_connectionId: string, _topic: string, _qos?: string | null) {
|
||||
export async function mqttSubscribe(_connectionId: string, _topic: string, _qos?: string | null, _noLocal?: boolean) {
|
||||
mqttWebNotAvailable("mqttSubscribe");
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ export async function mqttGetBrokerInfo(connectionId: string) {
|
||||
return invoke("mqtt_get_broker_info", { connectionId });
|
||||
}
|
||||
|
||||
export async function mqttSubscribe(connectionId: string, topic: string, qos?: string | null) {
|
||||
return invoke("mqtt_subscribe", { connectionId, topic, qos: qos ?? null });
|
||||
export async function mqttSubscribe(connectionId: string, topic: string, qos?: string | null, noLocal?: boolean) {
|
||||
return invoke("mqtt_subscribe", { connectionId, topic, qos: qos ?? null, noLocal: noLocal ?? false });
|
||||
}
|
||||
|
||||
export async function mqttUnsubscribe(connectionId: string, topic: string) {
|
||||
|
||||
@@ -26,6 +26,13 @@ export interface MqttConnectionConfig {
|
||||
connectTimeoutSecs: number;
|
||||
maxPacketSizeBytes: number;
|
||||
wsPath?: string;
|
||||
savedTopics?: MqttSavedTopic[];
|
||||
}
|
||||
|
||||
export interface MqttSavedTopic {
|
||||
topic: string;
|
||||
qos: MqttQoS;
|
||||
noLocal?: boolean;
|
||||
}
|
||||
|
||||
export interface MqttBrokerInfo {
|
||||
|
||||
Reference in new issue
Block a user