forked from dsh/panel-auth
refactor: move logout/change-password UI from floating widget to settings
The injected floating buttons overlapped the mobile composer send button. Replace the tapIndex-injected widget with a proper client plugin (panel-auth-ui, ui/ package): registers an account & security section in the panel settings via the settings.section slot, with change-password form and logout action. Host plugin keeps only the /panel-auth/change- password and /panel-auth/logout endpoints. - ui/: ModuleLoader-format client bundle (dsh.client declaration, exports ./client and ./package.json), host no-op stub, bare-package symlink required in the profile node_modules (documented in README). - Host: renderAuthWidget/injectWidget/tapIndex injection removed. - Tests: widget tests removed; change-password flows unchanged.
This commit is contained in:
@@ -88,23 +88,38 @@ dsh.example.com {
|
||||
- panel-auth 的来源校验在 Host 为回环时自动跳过(代理场景);对外域名下的
|
||||
真实跨站提交仍会被拒绝(`非法请求来源`)。
|
||||
|
||||
## 面板内小组件(登出 / 修改密码)
|
||||
## 设置页「账号与安全」(登出 / 修改密码)
|
||||
|
||||
插件通过 `webServer.tapIndex` 在面板页面的右下角注入两个悬浮按钮:
|
||||
配套客户端插件 `panel-auth-ui`(本仓库 `ui/` 目录)在面板**设置**里注册
|
||||
"账号与安全"区块,包含:
|
||||
|
||||
- **退出登录**:跳转 `/panel-auth/logout`,清除 Cookie 并回到登录页。
|
||||
- **修改密码**:弹窗输入当前密码 + 新密码(≥8 位、不得与旧密码相同),
|
||||
- **修改密码**:输入当前密码 + 新密码(≥8 位、不得与旧密码相同),
|
||||
POST 到 `/panel-auth/change-password`:
|
||||
1. 校验会话(Cookie/Basic)与旧密码(错误计入防爆破);
|
||||
2. 生成新 scrypt 哈希,**写回 `patchFilePath`**(cordis.patch.yml,永续);
|
||||
3. 通过 loader 热更新运行配置(`live-updated`,无需重启面板);
|
||||
4. 审计记录 `password-change` 事件(含结果明细)。
|
||||
- **退出登录**:跳转 `/panel-auth/logout`,清除 Cookie 并回到登录页。
|
||||
- 改密不影响已登录会话(签名密钥不变,Cookie 继续有效)。
|
||||
- 新增配置项:
|
||||
```yaml
|
||||
changePasswordPath: '/panel-auth/change-password'
|
||||
patchFilePath: '/root/.dsh/profiles/web/cordis.patch.yml'
|
||||
```
|
||||
|
||||
部署要求(客户端包按裸包名解析):
|
||||
|
||||
```bash
|
||||
# 1. 把 ui/ 目录复制为 profile 内的独立包(host 存根 + client bundle)
|
||||
cp -r ui /root/.dsh/profiles/web/panel-auth-ui
|
||||
# 2. 建立裸包名解析符号链接(profiles 级 node_modules 为 pnpm 布局)
|
||||
ln -sfn ../web/panel-auth-ui /root/.dsh/profiles/node_modules/panel-auth-ui
|
||||
# 3. cordis.patch.yml 增加行(dsh.client 由 package.json 声明驱动)
|
||||
# - id: panel-auth-ui
|
||||
# name: 'panel-auth-ui'
|
||||
```
|
||||
|
||||
相关配置项:
|
||||
|
||||
```yaml
|
||||
changePasswordPath: '/panel-auth/change-password'
|
||||
patchFilePath: '/root/.dsh/profiles/web/cordis.patch.yml'
|
||||
```
|
||||
|
||||
## 修改密码(命令行)
|
||||
|
||||
|
||||
@@ -404,137 +404,6 @@ export function renderLoginPage({ realm, next = '/', error = '', loginPath = DEF
|
||||
</html>`
|
||||
}
|
||||
|
||||
// ── in-panel auth widget (logout / change password) ─────────────────────────
|
||||
|
||||
/**
|
||||
* Self-contained widget injected into the panel's index.html via
|
||||
* webServer.tapIndex: two floating buttons (logout, change password) and a
|
||||
* change-password modal. Vanilla JS only, all styles scoped to `pna-*`.
|
||||
*/
|
||||
export function renderAuthWidget({ logoutPath, changePasswordPath }) {
|
||||
const logoutUrl = JSON.stringify(logoutPath)
|
||||
const changeUrl = JSON.stringify(changePasswordPath)
|
||||
return `<style>
|
||||
#pna-widget { position: fixed; right: 18px; bottom: 18px; z-index: 2147483000; display: flex; flex-direction: column; gap: 8px; }
|
||||
.pna-btn { display: flex; align-items: center; gap: 7px; border: 1px solid rgba(255,255,255,0.14); background: rgba(22,26,40,0.88); color: #e8eaf2; font: 600 12.5px/1 "Segoe UI","PingFang SC","Microsoft YaHei",system-ui,sans-serif; padding: 9px 13px; border-radius: 10px; cursor: pointer; box-shadow: 0 8px 22px rgba(0,0,0,0.35); backdrop-filter: blur(10px); transition: border-color .15s ease, transform .15s ease; }
|
||||
.pna-btn:hover { border-color: rgba(109,141,255,0.65); transform: translateY(-1px); }
|
||||
.pna-btn svg { width: 14px; height: 14px; }
|
||||
#pna-modal { position: fixed; inset: 0; z-index: 2147483001; display: grid; place-items: center; background: rgba(8,10,20,0.55); backdrop-filter: blur(4px); padding: 20px; }
|
||||
#pna-modal[hidden] { display: none; }
|
||||
.pna-card { width: min(360px, 100%); background: rgba(22,26,40,0.94); border: 1px solid rgba(255,255,255,0.12); border-radius: 16px; padding: 24px 22px 18px; box-shadow: 0 24px 60px rgba(0,0,0,0.5); color: #e8eaf2; font: 14px/1.5 "Segoe UI","PingFang SC","Microsoft YaHei",system-ui,sans-serif; }
|
||||
.pna-card h3 { margin: 0 0 14px; font-size: 16px; font-weight: 650; }
|
||||
.pna-card label { display: block; font-size: 12px; color: #9aa1b5; margin: 10px 0 4px; }
|
||||
.pna-card input { width: 100%; box-sizing: border-box; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.14); border-radius: 9px; color: #e8eaf2; padding: 9px 11px; font-size: 14px; outline: none; }
|
||||
.pna-card input:focus { border-color: #6d8dff; box-shadow: 0 0 0 3px rgba(109,141,255,0.2); }
|
||||
.pna-error { background: rgba(248,113,113,0.12); border: 1px solid rgba(248,113,113,0.4); color: #f87171; border-radius: 8px; padding: 7px 10px; font-size: 12.5px; margin-bottom: 4px; }
|
||||
.pna-ok { background: rgba(74,222,128,0.12); border: 1px solid rgba(74,222,128,0.4); color: #4ade80; border-radius: 8px; padding: 7px 10px; font-size: 12.5px; margin-bottom: 4px; }
|
||||
.pna-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
|
||||
.pna-actions button { border: 1px solid rgba(255,255,255,0.14); background: transparent; color: #e8eaf2; font-size: 13px; padding: 8px 14px; border-radius: 9px; cursor: pointer; }
|
||||
.pna-actions .pna-primary { background: linear-gradient(135deg, #6d8dff, #8b5cf6); border: none; color: #fff; font-weight: 600; }
|
||||
.pna-actions .pna-primary:disabled { opacity: 0.6; cursor: wait; }
|
||||
</style>
|
||||
<div id="pna-widget">
|
||||
<button id="pna-change" class="pna-btn" title="修改面板密码">
|
||||
<svg viewBox="0 0 24 24" fill="none"><rect x="4" y="10" width="16" height="10" rx="2.5" stroke="currentColor" stroke-width="1.8"/><path d="M8 10V7a4 4 0 0 1 8 0v3" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><circle cx="12" cy="15" r="1.6" fill="currentColor"/></svg>
|
||||
修改密码
|
||||
</button>
|
||||
<button id="pna-logout" class="pna-btn" title="退出登录">
|
||||
<svg viewBox="0 0 24 24" fill="none"><path d="M9 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h3" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><path d="M15 8l4 4-4 4M19 12H9" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
<div id="pna-modal" hidden>
|
||||
<div class="pna-card">
|
||||
<h3>修改面板密码</h3>
|
||||
<div id="pna-msg" hidden></div>
|
||||
<label for="pna-old">当前密码</label>
|
||||
<input id="pna-old" type="password" autocomplete="current-password">
|
||||
<label for="pna-new">新密码(至少 8 位)</label>
|
||||
<input id="pna-new" type="password" autocomplete="new-password">
|
||||
<label for="pna-confirm">确认新密码</label>
|
||||
<input id="pna-confirm" type="password" autocomplete="new-password">
|
||||
<div class="pna-actions">
|
||||
<button id="pna-cancel" type="button">取消</button>
|
||||
<button id="pna-submit" class="pna-primary" type="button">确认修改</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var LOGOUT_URL = ${logoutUrl};
|
||||
var CHANGE_URL = ${changeUrl};
|
||||
var modal = document.getElementById('pna-modal');
|
||||
var msg = document.getElementById('pna-msg');
|
||||
var oldInput = document.getElementById('pna-old');
|
||||
var newInput = document.getElementById('pna-new');
|
||||
var confirmInput = document.getElementById('pna-confirm');
|
||||
var submitBtn = document.getElementById('pna-submit');
|
||||
function showMsg(text, ok) {
|
||||
msg.hidden = false;
|
||||
msg.textContent = text;
|
||||
msg.className = ok ? 'pna-ok' : 'pna-error';
|
||||
}
|
||||
function openModal() {
|
||||
modal.hidden = false;
|
||||
msg.hidden = true;
|
||||
oldInput.value = newInput.value = confirmInput.value = '';
|
||||
oldInput.focus();
|
||||
}
|
||||
function closeModal() {
|
||||
modal.hidden = true;
|
||||
}
|
||||
document.getElementById('pna-logout').addEventListener('click', function () {
|
||||
window.location.href = LOGOUT_URL;
|
||||
});
|
||||
document.getElementById('pna-change').addEventListener('click', openModal);
|
||||
document.getElementById('pna-cancel').addEventListener('click', closeModal);
|
||||
modal.addEventListener('click', function (event) {
|
||||
if (event.target === modal) closeModal();
|
||||
});
|
||||
document.addEventListener('keydown', function (event) {
|
||||
if (event.key === 'Escape' && !modal.hidden) closeModal();
|
||||
});
|
||||
submitBtn.addEventListener('click', function () {
|
||||
var oldPassword = oldInput.value;
|
||||
var newPassword = newInput.value;
|
||||
if (!oldPassword || !newPassword) { showMsg('请填写当前密码和新密码', false); return; }
|
||||
if (newPassword.length < 8) { showMsg('新密码至少 8 位', false); return; }
|
||||
if (newPassword !== confirmInput.value) { showMsg('两次输入的新密码不一致', false); return; }
|
||||
submitBtn.disabled = true;
|
||||
fetch(CHANGE_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ oldPassword: oldPassword, newPassword: newPassword }),
|
||||
}).then(function (response) {
|
||||
return response.json().then(function (data) {
|
||||
if (response.ok) {
|
||||
showMsg(data.message || '密码已更新', true);
|
||||
oldInput.value = newInput.value = confirmInput.value = '';
|
||||
setTimeout(closeModal, 1400);
|
||||
} else {
|
||||
showMsg(data.message || '修改失败', false);
|
||||
}
|
||||
}).catch(function () {
|
||||
showMsg(response.ok ? '密码已更新' : '修改失败,请稍后再试', response.ok);
|
||||
});
|
||||
}).catch(function () {
|
||||
showMsg('网络错误,请稍后再试', false);
|
||||
}).finally(function () {
|
||||
submitBtn.disabled = false;
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>`
|
||||
}
|
||||
|
||||
/** Inject the widget before </body>; no-op when the marker is absent. */
|
||||
export function injectWidget(html, widget) {
|
||||
if (typeof html !== 'string') return html
|
||||
const at = html.lastIndexOf('</body>')
|
||||
if (at === -1) return html
|
||||
return html.slice(0, at) + widget + html.slice(at)
|
||||
}
|
||||
|
||||
// ── request helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function jsonResponse(res, status, payload) {
|
||||
@@ -961,8 +830,6 @@ export default function panelAuth(ctx, config) {
|
||||
})
|
||||
const failedLoginDelayMs = positiveInt(cfg.failedLoginDelayMs, 300)
|
||||
console.log('[panel-auth] audit log: ' + auditPath)
|
||||
const widget = renderAuthWidget({ logoutPath, changePasswordPath })
|
||||
ctx.effect(() => ctx.webServer.tapIndex((html) => injectWidget(html, widget)), 'panel-auth: auth widget')
|
||||
let disposer
|
||||
let timer
|
||||
let stopped = false
|
||||
|
||||
@@ -6,7 +6,7 @@ import { strict as assert } from 'node:assert'
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { createGuard, installGuard, createAuditWriter, createLockout, renderLoginPage, renderAuthWidget, injectWidget } from './index.js'
|
||||
import { createGuard, installGuard, createAuditWriter, createLockout, renderLoginPage } from './index.js'
|
||||
import { hashPassword, verifyPassword } from './crypto.js'
|
||||
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'panel-auth-test-'))
|
||||
@@ -274,14 +274,6 @@ assert.ok(auditLines.some((e) => e.event === 'password-change'), 'audit missing
|
||||
assert.ok(auditLines.some((e) => e.event === 'login-fail' && e.reason === 'password-change-old-mismatch'), 'audit missing old-mismatch entry')
|
||||
assert.equal(auditLines.every((e) => typeof e.ip === 'string' && typeof e.ts === 'string'), true)
|
||||
|
||||
// 11b. widget rendering and index injection
|
||||
const widget = renderAuthWidget({ logoutPath: '/panel-auth/logout', changePasswordPath: '/panel-auth/change-password' })
|
||||
assert.ok(widget.includes('id="pna-widget"') && widget.includes('id="pna-logout"') && widget.includes('id="pna-change"') && widget.includes('pna-modal'))
|
||||
const injected = injectWidget('<html><head></head><body>hi</body></html>', widget)
|
||||
assert.ok(injected.includes('id="pna-widget"'))
|
||||
assert.ok(injected.indexOf('id="pna-widget"') < injected.indexOf('</body>'))
|
||||
assert.equal(injectWidget('<html>nobody</html>', widget), '<html>nobody</html>')
|
||||
|
||||
// 12. XSS: hostile `next` value is escaped in the page
|
||||
const hostile = renderLoginPage({ realm: 'T', next: '/" onmouseover="alert(1)', error: '' })
|
||||
assert.ok(!hostile.includes('onmouseover="alert(1)'), 'next not escaped')
|
||||
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
// panel-auth-ui client bundle (ModuleLoader format, served by the client
|
||||
// module system). Registers an "账号与安全" section in the panel settings
|
||||
// with change-password and logout actions. Depends on the panel-auth host
|
||||
// plugin's /panel-auth/change-password and /panel-auth/logout endpoints.
|
||||
window.__ModuleLoader__.load({
|
||||
id: "panel-auth-ui",
|
||||
factory: (require) => {
|
||||
const React = require("react")
|
||||
|
||||
const inputStyle = {
|
||||
width: "100%",
|
||||
boxSizing: "border-box",
|
||||
padding: "9px 11px",
|
||||
fontSize: 14,
|
||||
borderRadius: 10,
|
||||
border: "1px solid var(--dsw-alias-border-l2, rgba(255,255,255,0.14))",
|
||||
background: "var(--dsw-alias-bg-module-platform, rgba(255,255,255,0.05))",
|
||||
color: "var(--dsw-alias-label-primary, #e8eaf2)",
|
||||
outline: "none",
|
||||
}
|
||||
const cardStyle = {
|
||||
border: "1px solid var(--dsw-alias-border-l2, rgba(255,255,255,0.12))",
|
||||
borderRadius: 16,
|
||||
padding: "18px 16px",
|
||||
}
|
||||
const titleStyle = {
|
||||
margin: "0 0 6px",
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
color: "var(--dsw-alias-label-primary, #e8eaf2)",
|
||||
}
|
||||
const hintStyle = {
|
||||
margin: "0 0 14px",
|
||||
fontSize: 12.5,
|
||||
color: "var(--dsw-alias-label-secondary, #9aa1b5)",
|
||||
}
|
||||
const buttonStyle = (tone) => ({
|
||||
padding: "9px 16px",
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
borderRadius: 10,
|
||||
cursor: "pointer",
|
||||
border: tone === "danger" ? "1px solid rgba(248,113,113,0.5)" : "1px solid var(--dsw-alias-border-l2, rgba(255,255,255,0.16))",
|
||||
background: tone === "danger" ? "rgba(248,113,113,0.12)" : "var(--dsw-alias-bg-module-platform, rgba(255,255,255,0.06))",
|
||||
color: tone === "danger" ? "#f87171" : "var(--dsw-alias-label-primary, #e8eaf2)",
|
||||
})
|
||||
const messageStyle = (ok) => ({
|
||||
margin: "0 0 12px",
|
||||
padding: "8px 11px",
|
||||
fontSize: 12.5,
|
||||
borderRadius: 8,
|
||||
border: ok ? "1px solid rgba(74,222,128,0.4)" : "1px solid rgba(248,113,113,0.4)",
|
||||
background: ok ? "rgba(74,222,128,0.12)" : "rgba(248,113,113,0.12)",
|
||||
color: ok ? "#4ade80" : "#f87171",
|
||||
})
|
||||
|
||||
function Field(props) {
|
||||
return React.createElement(
|
||||
"label",
|
||||
{ style: { display: "block", marginBottom: 12 } },
|
||||
React.createElement("div", { style: { fontSize: 12, marginBottom: 6, color: "var(--dsw-alias-label-secondary, #9aa1b5)" } }, props.label),
|
||||
React.createElement("input", {
|
||||
type: "password",
|
||||
autoComplete: props.autoComplete || "off",
|
||||
value: props.value,
|
||||
onChange: (event) => props.onChange(event.target.value),
|
||||
style: inputStyle,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function PanelAuthSection() {
|
||||
const [oldPw, setOldPw] = React.useState("")
|
||||
const [newPw, setNewPw] = React.useState("")
|
||||
const [confirm, setConfirm] = React.useState("")
|
||||
const [busy, setBusy] = React.useState(false)
|
||||
const [message, setMessage] = React.useState(null)
|
||||
|
||||
const submit = () => {
|
||||
if (busy) return
|
||||
if (!oldPw || !newPw) {
|
||||
setMessage({ ok: false, text: "请填写当前密码和新密码" })
|
||||
return
|
||||
}
|
||||
if (newPw.length < 8) {
|
||||
setMessage({ ok: false, text: "新密码至少 8 位" })
|
||||
return
|
||||
}
|
||||
if (newPw !== confirm) {
|
||||
setMessage({ ok: false, text: "两次输入的新密码不一致" })
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setMessage(null)
|
||||
fetch("/panel-auth/change-password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({ oldPassword: oldPw, newPassword: newPw }),
|
||||
})
|
||||
.then((response) => response.json().then((data) => ({ ok: response.ok, data })))
|
||||
.then(({ ok, data }) => {
|
||||
if (ok) {
|
||||
setOldPw("")
|
||||
setNewPw("")
|
||||
setConfirm("")
|
||||
setMessage({ ok: true, text: (data && data.message) || "密码已更新" })
|
||||
} else {
|
||||
setMessage({ ok: false, text: (data && data.message) || "修改失败" })
|
||||
}
|
||||
})
|
||||
.catch(() => setMessage({ ok: false, text: "网络错误,请稍后再试" }))
|
||||
.finally(() => setBusy(false))
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
window.location.href = "/panel-auth/logout"
|
||||
}
|
||||
|
||||
return React.createElement(
|
||||
"div",
|
||||
{ style: { display: "flex", flexDirection: "column", gap: 18 } },
|
||||
React.createElement(
|
||||
"section",
|
||||
{ style: cardStyle },
|
||||
React.createElement("h3", { style: titleStyle }, "修改密码"),
|
||||
React.createElement("p", { style: hintStyle }, "需要输入当前密码验证身份,修改后无需重新登录。"),
|
||||
React.createElement(Field, { label: "当前密码", autoComplete: "current-password", value: oldPw, onChange: setOldPw }),
|
||||
React.createElement(Field, { label: "新密码(至少 8 位)", autoComplete: "new-password", value: newPw, onChange: setNewPw }),
|
||||
React.createElement(Field, { label: "确认新密码", autoComplete: "new-password", value: confirm, onChange: setConfirm }),
|
||||
message === null ? null : React.createElement("div", { style: messageStyle(message.ok) }, message.text),
|
||||
React.createElement("button", { onClick: submit, disabled: busy, style: { ...buttonStyle("primary"), opacity: busy ? 0.6 : 1 } }, busy ? "提交中…" : "确认修改"),
|
||||
),
|
||||
React.createElement(
|
||||
"section",
|
||||
{ style: cardStyle },
|
||||
React.createElement("h3", { style: titleStyle }, "退出登录"),
|
||||
React.createElement("p", { style: hintStyle }, "清除本浏览器的登录 Cookie 并返回登录页。"),
|
||||
React.createElement("button", { onClick: logout, style: buttonStyle("danger") }, "退出登录"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
apply(ctx) {
|
||||
const slots = ctx.get("slots")
|
||||
if (slots === undefined) return
|
||||
slots.inject("settings.section", () => slots.register(
|
||||
{ name: "settings.section", id: "panel-auth", order: 30, label: () => "账号与安全" },
|
||||
(props) => React.createElement(PanelAuthSection, { close: props.close }),
|
||||
))
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
// panel-auth-ui — host-side no-op stub.
|
||||
// The loader mounts every row host-side; the real work lives in client.js,
|
||||
// served by the client module system under the dsh.client declaration.
|
||||
export default {
|
||||
name: 'panel-auth-ui',
|
||||
apply() {},
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "panel-auth-ui",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"exports": {
|
||||
".": "./index.js",
|
||||
"./client": "./client.js",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-settings"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": false
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user