feat(page-data): owner CRUD, public read, and role policy UI
Expose owner PATCH/soft-delete routes, allow anonymous read/stats on public pages when policy enables read, and add login_required role editor to the ops panel with tests and doc updates. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -23,11 +23,10 @@
|
||||
|
||||
```text
|
||||
GET /api/page-data # 列出已注册 dataset
|
||||
GET /api/page-data/:dataset # 读行
|
||||
POST /api/page-data/:dataset/rows # 插入
|
||||
GET /api/page-data/:dataset/schema
|
||||
GET /api/page-data/:dataset/stats
|
||||
GET /api/page-data/:dataset/export?format=json|csv
|
||||
GET /api/page-data/:dataset
|
||||
POST /api/page-data/:dataset/rows
|
||||
PATCH /api/page-data/:dataset/rows/:id
|
||||
DELETE /api/page-data/:dataset/rows/:id # soft delete
|
||||
POST /api/page-data/:dataset/rows/:id/restore
|
||||
|
||||
GET /api/page-data/policies
|
||||
@@ -53,7 +52,7 @@ GET /api/public/pages/:pageId/data/:dataset/schema
|
||||
GET /api/public/pages/:pageId/data/:dataset/stats
|
||||
```
|
||||
|
||||
公开请求可带 `x-page-data-token`(口令/登录会话)。**公开页不能直接传 SQL**,只能提交策略白名单字段。
|
||||
公开请求可带 `x-page-data-token`(口令/登录会话)。**完全公开**模式下:默认仅 insert;若策略显式开启 `read`,匿名访问者可读白名单字段并查看 stats。
|
||||
|
||||
## HTML 页面嵌入
|
||||
|
||||
|
||||
@@ -174,6 +174,34 @@ test('acceptance: public insert only when authorized; read/update/delete denied
|
||||
assert.equal(deleteDenied.status, 403);
|
||||
});
|
||||
|
||||
test('acceptance: public read and stats when policy enables read', async () => {
|
||||
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-acc-public-read-'));
|
||||
const ownerService = await setupWorkspace(workspaceRoot);
|
||||
await ownerService.insertDatasetRow('leads', { name: '展示项', note: 'secret' });
|
||||
writePageAccessPolicy(workspaceRoot, {
|
||||
pageId: PAGE_ID,
|
||||
ownerUserId: OWNER_ID,
|
||||
accessMode: 'public',
|
||||
datasets: {
|
||||
leads: {
|
||||
read: true,
|
||||
columns: { read: ['id', 'name', 'created_at'] },
|
||||
},
|
||||
},
|
||||
});
|
||||
const app = buildApp(workspaceRoot);
|
||||
|
||||
const listed = await request(app, 'GET', `/api/public/pages/${PAGE_ID}/data/leads?limit=10`);
|
||||
assert.equal(listed.status, 200);
|
||||
assert.equal(listed.body.data.rows.length, 1);
|
||||
assert.equal(listed.body.data.rows[0].name, '展示项');
|
||||
assert.equal(listed.body.data.rows[0].note, undefined);
|
||||
|
||||
const stats = await request(app, 'GET', `/api/public/pages/${PAGE_ID}/data/leads/stats`);
|
||||
assert.equal(stats.status, 200);
|
||||
assert.equal(stats.body.data.total, 1);
|
||||
});
|
||||
|
||||
test('acceptance: unauthorized dataset/action/column and SQL-like keys are rejected', async () => {
|
||||
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-acc-deny-'));
|
||||
await setupWorkspace(workspaceRoot);
|
||||
|
||||
@@ -344,6 +344,14 @@ test('integration: apply-publish route binds dataset after publication', async (
|
||||
assert.equal(applied.status, 200);
|
||||
assert.equal(applied.body.data.policy.datasets.signups.read, true);
|
||||
|
||||
const publicReadEmpty = await request(app, 'GET', `/api/public/pages/${PAGE_ID}/data/signups`);
|
||||
assert.equal(publicReadEmpty.status, 200);
|
||||
assert.equal(publicReadEmpty.body.data.rows.length, 0);
|
||||
|
||||
await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data/signups/rows`, {
|
||||
body: { name: '发布后公开读', phone: '13800000000' },
|
||||
});
|
||||
const publicRead = await request(app, 'GET', `/api/public/pages/${PAGE_ID}/data/signups`);
|
||||
assert.equal(publicRead.status, 403);
|
||||
assert.equal(publicRead.status, 200);
|
||||
assert.equal(publicRead.body.data.rows.length, 1);
|
||||
});
|
||||
|
||||
@@ -209,9 +209,15 @@ export function createPageDataPublicService(deps = {}) {
|
||||
function resolveAccessContext({ publication, policy, req, action, datasetName }) {
|
||||
const accessMode = publication.access_mode;
|
||||
if (accessMode === 'public') {
|
||||
if (action === 'read' || action === 'update' || action === 'soft_delete') {
|
||||
if (action === 'update' || action === 'soft_delete') {
|
||||
throw Object.assign(new Error('当前页面未开放此数据操作'), { code: 'action_not_allowed' });
|
||||
}
|
||||
if (action === 'read') {
|
||||
if (!policyAllowsAction(policy, datasetName, 'read')) {
|
||||
throw Object.assign(new Error('当前页面未开放此数据操作'), { code: 'action_not_allowed' });
|
||||
}
|
||||
return { accessMode, session: null };
|
||||
}
|
||||
if (!policyAllowsAction(policy, datasetName, action)) {
|
||||
throw Object.assign(new Error(`dataset 未授权 ${action}`), { code: 'action_not_allowed' });
|
||||
}
|
||||
|
||||
@@ -111,6 +111,19 @@ test('public page read is denied in public access mode by default', async () =>
|
||||
);
|
||||
});
|
||||
|
||||
test('public page read works when policy explicitly enables read', async () => {
|
||||
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-public-read-ok-'));
|
||||
const ownerService = await setupPublicWorkspace(workspaceRoot, { accessMode: 'public', withRead: true });
|
||||
await ownerService.insertRowForDataset(ownerService.getDataset('signups'), {
|
||||
name: '公开可读',
|
||||
phone: '13900000002',
|
||||
});
|
||||
const service = createPublicService(workspaceRoot, { accessMode: 'public' });
|
||||
const rows = await service.listRows(PAGE_ID, 'signups', { headers: {} });
|
||||
assert.equal(rows.rows.length, 1);
|
||||
assert.equal(rows.rows[0].name, '公开可读');
|
||||
});
|
||||
|
||||
test('login_required page read requires login and works for authenticated visitor', async () => {
|
||||
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-login-required-'));
|
||||
const ownerService = await setupPublicWorkspace(workspaceRoot, {
|
||||
|
||||
@@ -286,6 +286,36 @@ export function attachPageDataRoutes(api, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
api.patch('/page-data/:dataset/rows/:rowId', async (req, res) => {
|
||||
const user = requireUser(req, res, sendError);
|
||||
if (!user) return;
|
||||
const service = getPageDataService?.();
|
||||
if (!service) return sendError(res, req, 503, 'feature_disabled', 'Page Data API 未启用');
|
||||
const payload = parseRowPayload(req, { stripMeta: true });
|
||||
if (!payload) return sendError(res, req, 400, 'invalid_request', '更新数据必须是 JSON 对象');
|
||||
try {
|
||||
const result = await service.updateRow(user, req.params.dataset, req.params.rowId, payload);
|
||||
return sendData(res, req, { dataset: result.dataset.name, row: result.row });
|
||||
} catch (error) {
|
||||
const status = error?.status ?? 400;
|
||||
return sendError(res, req, status, error?.code ?? 'page_data_failed', error?.message ?? '更新失败');
|
||||
}
|
||||
});
|
||||
|
||||
api.delete('/page-data/:dataset/rows/:rowId', async (req, res) => {
|
||||
const user = requireUser(req, res, sendError);
|
||||
if (!user) return;
|
||||
const service = getPageDataService?.();
|
||||
if (!service) return sendError(res, req, 503, 'feature_disabled', 'Page Data API 未启用');
|
||||
try {
|
||||
const result = await service.softDeleteRow(user, req.params.dataset, req.params.rowId);
|
||||
return sendData(res, req, { dataset: result.dataset.name, row: result.row, deleted: true });
|
||||
} catch (error) {
|
||||
const status = error?.status ?? 400;
|
||||
return sendError(res, req, status, error?.code ?? 'page_data_failed', error?.message ?? '删除失败');
|
||||
}
|
||||
});
|
||||
|
||||
api.post('/public/pages/:pageId/data-auth', async (req, res) => {
|
||||
const publicService = getPageDataPublicService?.();
|
||||
if (!publicService) return sendError(res, req, 503, 'feature_disabled', 'Page Data API 未启用');
|
||||
|
||||
@@ -101,6 +101,54 @@ test('page data routes allow logged-in owner to read and insert dataset rows', a
|
||||
assert.equal(stats.body.data.total, 1);
|
||||
});
|
||||
|
||||
test('page data routes allow owner update and soft delete', async () => {
|
||||
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-api-crud-'));
|
||||
const service = createUserDataSpaceService({ workspaceRoot });
|
||||
await service.executeSql(`CREATE TABLE tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'open',
|
||||
created_at TEXT,
|
||||
deleted_at TEXT,
|
||||
deleted_by TEXT,
|
||||
updated_at TEXT
|
||||
);`);
|
||||
await service.upsertDataset({
|
||||
name: 'tasks',
|
||||
table: 'tasks',
|
||||
actions: ['read', 'insert', 'update', 'soft_delete'],
|
||||
columns: {
|
||||
read: ['id', 'title', 'status', 'created_at'],
|
||||
insert: ['title'],
|
||||
update: ['status'],
|
||||
soft_delete: ['id'],
|
||||
},
|
||||
});
|
||||
|
||||
const app = createApiApp({
|
||||
workspaceRoot,
|
||||
user: { id: 'user-1', workspaceRoot },
|
||||
});
|
||||
|
||||
const inserted = await requestJson(app, 'POST', '/api/page-data/tasks/rows', { title: '待办' });
|
||||
assert.equal(inserted.status, 201);
|
||||
const rowId = inserted.body.data.row.id;
|
||||
|
||||
const updated = await requestJson(app, 'PATCH', `/api/page-data/tasks/rows/${rowId}`, {
|
||||
status: 'done',
|
||||
});
|
||||
assert.equal(updated.status, 200);
|
||||
assert.equal(updated.body.data.row.status, 'done');
|
||||
|
||||
const deleted = await requestJson(app, 'DELETE', `/api/page-data/tasks/rows/${rowId}`);
|
||||
assert.equal(deleted.status, 200);
|
||||
assert.equal(deleted.body.data.deleted, true);
|
||||
|
||||
const listed = await requestJson(app, 'GET', '/api/page-data/tasks?limit=10');
|
||||
assert.equal(listed.status, 200);
|
||||
assert.equal(listed.body.data.rows.length, 0);
|
||||
});
|
||||
|
||||
test('page data routes reject unauthorized dataset action', async () => {
|
||||
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-api-deny-'));
|
||||
const service = createUserDataSpaceService({ workspaceRoot });
|
||||
|
||||
@@ -97,6 +97,24 @@ export function createPageDataService(deps = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
async function updateRow(user, datasetName, rowId, payload) {
|
||||
const { service } = await createServiceForUser(user);
|
||||
try {
|
||||
return service.updateDatasetRow(datasetName, rowId, payload);
|
||||
} catch (error) {
|
||||
throw mapServiceError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function softDeleteRow(user, datasetName, rowId) {
|
||||
const { service } = await createServiceForUser(user);
|
||||
try {
|
||||
return service.softDeleteDatasetRow(datasetName, rowId, { deletedBy: user.id });
|
||||
} catch (error) {
|
||||
throw mapServiceError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreRow(user, datasetName, rowId) {
|
||||
const { service } = await createServiceForUser(user);
|
||||
try {
|
||||
@@ -164,6 +182,8 @@ export function createPageDataService(deps = {}) {
|
||||
getSchema,
|
||||
getStats,
|
||||
insertRow,
|
||||
updateRow,
|
||||
softDeleteRow,
|
||||
restoreRow,
|
||||
exportDataset,
|
||||
listDatasets,
|
||||
|
||||
@@ -6,9 +6,30 @@ import {
|
||||
resetPageDataPassword,
|
||||
restorePageDataRow,
|
||||
revokePageDataTokens,
|
||||
savePageDataPolicy,
|
||||
} from '../api/client';
|
||||
import type { MindSpacePublishCheck, PageDataOpsOverview } from '../types';
|
||||
|
||||
type RowPolicyScope = 'all_rows' | 'own_rows' | 'owner_only';
|
||||
|
||||
function formatVisitorsText(visitors: Record<string, 'viewer' | 'editor'> | undefined) {
|
||||
return Object.entries(visitors ?? {})
|
||||
.map(([userId, role]) => `${userId} ${role}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function parseVisitorsText(text: string) {
|
||||
const visitors: Record<string, 'viewer' | 'editor'> = {};
|
||||
for (const line of text.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const match = trimmed.match(/^(\S+)[\s:]+(viewer|editor)$/i);
|
||||
if (!match) continue;
|
||||
visitors[match[1]] = match[2].toLowerCase() as 'viewer' | 'editor';
|
||||
}
|
||||
return visitors;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
pageId: string;
|
||||
pageTitle: string;
|
||||
@@ -29,6 +50,24 @@ export function MindSpacePageDataOpsPanel({ pageId, pageTitle, accessMode, onClo
|
||||
const [busyAction, setBusyAction] = useState<string | null>(null);
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [defaultVisitorRole, setDefaultVisitorRole] = useState<'deny' | 'viewer' | 'editor'>('deny');
|
||||
const [visitorsText, setVisitorsText] = useState('');
|
||||
const [rowPolicies, setRowPolicies] = useState<
|
||||
Record<string, { scope: RowPolicyScope; ownerColumn: string }>
|
||||
>({});
|
||||
|
||||
const syncRoleForm = useCallback((policy: PageDataOpsOverview['policy']) => {
|
||||
setDefaultVisitorRole(policy.defaultVisitorRole ?? 'deny');
|
||||
setVisitorsText(formatVisitorsText(policy.visitors));
|
||||
const next: Record<string, { scope: RowPolicyScope; ownerColumn: string }> = {};
|
||||
for (const [name, dataset] of Object.entries(policy.datasets ?? {})) {
|
||||
next[name] = {
|
||||
scope: dataset.rowPolicy?.scope ?? 'all_rows',
|
||||
ownerColumn: dataset.rowPolicy?.ownerColumn ?? 'created_by_user_id',
|
||||
};
|
||||
}
|
||||
setRowPolicies(next);
|
||||
}, []);
|
||||
|
||||
const loadOverview = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -36,6 +75,7 @@ export function MindSpacePageDataOpsPanel({ pageId, pageTitle, accessMode, onClo
|
||||
try {
|
||||
const next = await getPageDataOpsOverview(pageId);
|
||||
setOverview(next);
|
||||
syncRoleForm(next.policy);
|
||||
} catch (err) {
|
||||
const text = err instanceof Error ? err.message : '加载页面数据运维信息失败';
|
||||
if (text.includes('policy_not_found') || text.includes('策略未配置')) {
|
||||
@@ -47,7 +87,7 @@ export function MindSpacePageDataOpsPanel({ pageId, pageTitle, accessMode, onClo
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [pageId]);
|
||||
}, [pageId, syncRoleForm]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadOverview();
|
||||
@@ -129,6 +169,113 @@ export function MindSpacePageDataOpsPanel({ pageId, pageTitle, accessMode, onClo
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{overview.publication.accessMode === 'login_required' ? (
|
||||
<div className="mindspace-page-data-ops-section mindspace-page-data-ops-role-editor">
|
||||
<h4>登录访问 · 角色与行级策略</h4>
|
||||
<label>
|
||||
未列名访问者默认角色
|
||||
<select
|
||||
value={defaultVisitorRole}
|
||||
disabled={busyAction != null}
|
||||
onChange={(event) =>
|
||||
setDefaultVisitorRole(event.target.value as 'deny' | 'viewer' | 'editor')
|
||||
}
|
||||
>
|
||||
<option value="deny">拒绝</option>
|
||||
<option value="viewer">只读 (viewer)</option>
|
||||
<option value="editor">编辑 (editor)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
指定访问者(每行:用户 ID + 空格 + viewer/editor)
|
||||
<textarea
|
||||
value={visitorsText}
|
||||
disabled={busyAction != null}
|
||||
rows={4}
|
||||
placeholder={'user-a editor\nuser-b viewer'}
|
||||
onChange={(event) => setVisitorsText(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{overview.datasets.length > 0 ? (
|
||||
<div className="mindspace-page-data-ops-role-datasets">
|
||||
<span>Dataset 行级范围</span>
|
||||
{overview.datasets.map((dataset) => (
|
||||
<div key={dataset.name} className="mindspace-page-data-ops-role-dataset-row">
|
||||
<code>{dataset.name}</code>
|
||||
<select
|
||||
value={rowPolicies[dataset.name]?.scope ?? 'all_rows'}
|
||||
disabled={busyAction != null}
|
||||
onChange={(event) =>
|
||||
setRowPolicies((current) => ({
|
||||
...current,
|
||||
[dataset.name]: {
|
||||
scope: event.target.value as RowPolicyScope,
|
||||
ownerColumn: current[dataset.name]?.ownerColumn ?? 'created_by_user_id',
|
||||
},
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="all_rows">全部行</option>
|
||||
<option value="own_rows">仅自己的行</option>
|
||||
<option value="owner_only">仅 owner</option>
|
||||
</select>
|
||||
{(rowPolicies[dataset.name]?.scope ?? 'all_rows') === 'own_rows' ? (
|
||||
<input
|
||||
value={rowPolicies[dataset.name]?.ownerColumn ?? 'created_by_user_id'}
|
||||
disabled={busyAction != null}
|
||||
placeholder="owner 列名"
|
||||
onChange={(event) =>
|
||||
setRowPolicies((current) => ({
|
||||
...current,
|
||||
[dataset.name]: {
|
||||
scope: current[dataset.name]?.scope ?? 'own_rows',
|
||||
ownerColumn: event.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
disabled={busyAction != null}
|
||||
onClick={() =>
|
||||
void runAction('save-role-policy', async () => {
|
||||
if (!overview?.policy) return;
|
||||
const datasets = { ...overview.policy.datasets };
|
||||
for (const [name, rowPolicy] of Object.entries(rowPolicies)) {
|
||||
if (!datasets[name]) continue;
|
||||
datasets[name] = {
|
||||
...datasets[name],
|
||||
rowPolicy:
|
||||
rowPolicy.scope === 'all_rows'
|
||||
? undefined
|
||||
: {
|
||||
scope: rowPolicy.scope,
|
||||
ownerColumn:
|
||||
rowPolicy.scope === 'own_rows'
|
||||
? rowPolicy.ownerColumn.trim() || 'created_by_user_id'
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
await savePageDataPolicy(pageId, {
|
||||
defaultVisitorRole,
|
||||
visitors: parseVisitorsText(visitorsText),
|
||||
datasets,
|
||||
});
|
||||
setMessage('角色与行级策略已保存');
|
||||
})
|
||||
}
|
||||
>
|
||||
{busyAction === 'save-role-policy' ? '保存中…' : '保存角色策略'}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mindspace-page-data-ops-section">
|
||||
<h4>Dataset 状态</h4>
|
||||
{overview.datasets.length === 0 ? (
|
||||
|
||||
@@ -8410,6 +8410,40 @@ body,
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.mindspace-page-data-ops-role-editor {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mindspace-page-data-ops-role-editor label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mindspace-page-data-ops-role-editor textarea {
|
||||
min-height: 88px;
|
||||
resize: vertical;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.mindspace-page-data-ops-role-datasets {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mindspace-page-data-ops-role-datasets > span {
|
||||
color: rgba(24, 33, 29, 0.62);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.mindspace-page-data-ops-role-dataset-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(80px, 120px) 140px 1fr;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mindspace-page-data-ops-error {
|
||||
color: #9b2c2c;
|
||||
}
|
||||
|
||||
@@ -563,6 +563,22 @@ export function createUserDataSpaceService(options = {}) {
|
||||
return { dataset, row };
|
||||
}
|
||||
|
||||
async function updateDatasetRow(datasetName, rowId, payload, meta = {}) {
|
||||
const dataset = getDataset(datasetName);
|
||||
if (!dataset) {
|
||||
throw Object.assign(new Error('dataset 不存在'), { code: 'dataset_not_found' });
|
||||
}
|
||||
return updateRowForDataset(dataset, rowId, payload, meta);
|
||||
}
|
||||
|
||||
async function softDeleteDatasetRow(datasetName, rowId, meta = {}) {
|
||||
const dataset = getDataset(datasetName);
|
||||
if (!dataset) {
|
||||
throw Object.assign(new Error('dataset 不存在'), { code: 'dataset_not_found' });
|
||||
}
|
||||
return softDeleteRowForDataset(dataset, rowId, meta);
|
||||
}
|
||||
|
||||
async function insertDatasetRow(datasetName, payload, meta = {}) {
|
||||
const dataset = getDataset(datasetName);
|
||||
if (!dataset) {
|
||||
@@ -751,7 +767,9 @@ export function createUserDataSpaceService(options = {}) {
|
||||
getStatsForDataset,
|
||||
insertDatasetRow,
|
||||
insertRowForDataset,
|
||||
updateDatasetRow,
|
||||
updateRowForDataset,
|
||||
softDeleteDatasetRow,
|
||||
softDeleteRowForDataset,
|
||||
restoreSoftDeletedRowForDataset,
|
||||
restoreSoftDeletedRow,
|
||||
|
||||
Reference in New Issue
Block a user