增加了账号管理的分组功能
This commit is contained in:
@@ -36,11 +36,16 @@ export const userApi = {
|
||||
};
|
||||
|
||||
export const accountApi = {
|
||||
list: (assigned_only?: boolean) =>
|
||||
api.get<any, any[]>('/accounts', { params: assigned_only ? { assigned_only: true } : {} }),
|
||||
list: (params?: { assigned_only?: boolean; tag?: string }) =>
|
||||
api.get<any, any[]>('/accounts', { params }),
|
||||
import: (text: string) => api.post<any, any>('/accounts/import', { text }),
|
||||
assign: (id: number, assigned_to: number | null) =>
|
||||
api.put<any, any>(`/accounts/${id}/assign`, { assigned_to }),
|
||||
setTag: (id: number, tag: string) =>
|
||||
api.put<any, any>(`/accounts/${id}/tag`, { tag }),
|
||||
batchTag: (account_ids: number[], tag: string) =>
|
||||
api.put<any, any>('/accounts/batch-tag', { account_ids, tag }),
|
||||
listTags: () => api.get<any, string[]>('/accounts/tags/list'),
|
||||
delete: (id: number) => api.delete<any, any>(`/accounts/${id}`),
|
||||
};
|
||||
|
||||
|
||||
@@ -1,21 +1,31 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import {
|
||||
Table, Button, Modal, Input, Select, message, Popconfirm, Typography,
|
||||
Table, Button, Modal, Input, Select, message, Popconfirm, Typography, Tag, Space,
|
||||
Row, Col, Card, Statistic,
|
||||
} from 'antd';
|
||||
import { ImportOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import { ImportOutlined, DeleteOutlined, TagOutlined, FilterOutlined } from '@ant-design/icons';
|
||||
import { accountApi, userApi } from '../api/modules';
|
||||
import { getUser, hasPerm } from '../store/auth';
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Text } = Typography;
|
||||
|
||||
const TAG_COLORS = [
|
||||
'blue', 'green', 'cyan', 'geekblue', 'purple', 'orange', 'magenta', 'volcano',
|
||||
];
|
||||
|
||||
export default function AccountsPage() {
|
||||
const [accounts, setAccounts] = useState<any[]>([]);
|
||||
const [users, setUsers] = useState<any[]>([]);
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [importText, setImportText] = useState('');
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [tagFilter, setTagFilter] = useState<string>('');
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||
const [batchTagInput, setBatchTagInput] = useState('');
|
||||
const [batchTagVisible, setBatchTagVisible] = useState(false);
|
||||
const user = getUser();
|
||||
|
||||
const canViewAll = hasPerm(user, 'account:view_all');
|
||||
@@ -26,7 +36,9 @@ export default function AccountsPage() {
|
||||
const loadAccounts = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await accountApi.list();
|
||||
const params: any = {};
|
||||
if (tagFilter) params.tag = tagFilter;
|
||||
const data = await accountApi.list(params);
|
||||
setAccounts(data);
|
||||
} catch (e: any) {
|
||||
message.error(e.message);
|
||||
@@ -42,11 +54,31 @@ export default function AccountsPage() {
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const loadTags = async () => {
|
||||
try {
|
||||
const data = await accountApi.listTags();
|
||||
setTags(data);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadAccounts();
|
||||
if (canAssign) loadUsers();
|
||||
loadTags();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadAccounts();
|
||||
}, [tagFilter]);
|
||||
|
||||
const tagColorMap = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
tags.forEach((t, i) => {
|
||||
map[t] = TAG_COLORS[i % TAG_COLORS.length];
|
||||
});
|
||||
return map;
|
||||
}, [tags]);
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!importText.trim()) {
|
||||
message.warning('请输入账号数据');
|
||||
@@ -59,6 +91,7 @@ export default function AccountsPage() {
|
||||
setImportOpen(false);
|
||||
setImportText('');
|
||||
loadAccounts();
|
||||
loadTags();
|
||||
} catch (e: any) {
|
||||
message.error(e.message);
|
||||
} finally {
|
||||
@@ -76,11 +109,42 @@ export default function AccountsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetTag = async (accountId: number, tag: string) => {
|
||||
try {
|
||||
await accountApi.setTag(accountId, tag);
|
||||
message.success('标签已更新');
|
||||
loadAccounts();
|
||||
loadTags();
|
||||
} catch (e: any) {
|
||||
message.error(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchTag = async () => {
|
||||
if (selectedRowKeys.length === 0) {
|
||||
message.warning('请先选择账号');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await accountApi.batchTag(selectedRowKeys as number[], batchTagInput);
|
||||
message.success(`已为 ${selectedRowKeys.length} 个账号设置标签`);
|
||||
setBatchTagVisible(false);
|
||||
setBatchTagInput('');
|
||||
setSelectedRowKeys([]);
|
||||
loadAccounts();
|
||||
loadTags();
|
||||
} catch (e: any) {
|
||||
message.error(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await accountApi.delete(id);
|
||||
message.success('已删除');
|
||||
setSelectedRowKeys((prev) => prev.filter((k) => k !== id));
|
||||
loadAccounts();
|
||||
loadTags();
|
||||
} catch (e: any) {
|
||||
message.error(e.message);
|
||||
}
|
||||
@@ -89,6 +153,48 @@ export default function AccountsPage() {
|
||||
const columns: any[] = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 60 },
|
||||
{ title: '用户名', dataIndex: 'username' },
|
||||
{
|
||||
title: '标签',
|
||||
dataIndex: 'tag',
|
||||
width: 120,
|
||||
render: (tag: string, record: any) => {
|
||||
if (!tag) {
|
||||
if (canImport) {
|
||||
return (
|
||||
<Input
|
||||
size="small"
|
||||
placeholder="输入标签"
|
||||
style={{ width: 90 }}
|
||||
onPressEnter={(e) => {
|
||||
const val = (e.target as HTMLInputElement).value.trim();
|
||||
if (val) handleSetTag(record.id, val);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const val = e.target.value.trim();
|
||||
if (val) handleSetTag(record.id, val);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <Text type="secondary">-</Text>;
|
||||
}
|
||||
if (canImport) {
|
||||
return (
|
||||
<Tag
|
||||
color={tagColorMap[tag]}
|
||||
closable
|
||||
onClose={(e) => {
|
||||
e.preventDefault();
|
||||
handleSetTag(record.id, '');
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
return <Tag color={tagColorMap[tag]}>{tag}</Tag>;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (canViewAll) {
|
||||
@@ -135,13 +241,66 @@ export default function AccountsPage() {
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<h2>账号管理</h2>
|
||||
{canImport && (
|
||||
<Button type="primary" icon={<ImportOutlined />} onClick={() => setImportOpen(true)}>
|
||||
批量导入
|
||||
</Button>
|
||||
)}
|
||||
<Space>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="按标签筛选"
|
||||
style={{ width: 150 }}
|
||||
value={tagFilter || undefined}
|
||||
onChange={(val) => setTagFilter(val || '')}
|
||||
options={tags.map((t) => ({ value: t, label: t }))}
|
||||
prefix={<FilterOutlined />}
|
||||
/>
|
||||
{canImport && (
|
||||
<>
|
||||
<Button
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
icon={<TagOutlined />}
|
||||
onClick={() => {
|
||||
setBatchTagInput('');
|
||||
setBatchTagVisible(true);
|
||||
}}
|
||||
>
|
||||
批量打标签
|
||||
</Button>
|
||||
<Button type="primary" icon={<ImportOutlined />} onClick={() => setImportOpen(true)}>
|
||||
批量导入
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col span={6}>
|
||||
<Card size="small"><Statistic title="账号总数" value={accounts.length} /></Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card size="small"><Statistic title="标签数" value={tags.length} /></Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="已分配"
|
||||
value={accounts.filter((a) => a.assigned_to).length}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="未分配"
|
||||
value={accounts.filter((a) => !a.assigned_to).length}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Table
|
||||
rowSelection={canImport ? {
|
||||
selectedRowKeys,
|
||||
onChange: (keys) => setSelectedRowKeys(keys),
|
||||
} : undefined}
|
||||
columns={columns}
|
||||
dataSource={accounts}
|
||||
rowKey="id"
|
||||
@@ -149,6 +308,7 @@ export default function AccountsPage() {
|
||||
size="small"
|
||||
pagination={{ pageSize: 20 }}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="批量导入账号"
|
||||
open={importOpen}
|
||||
@@ -159,16 +319,35 @@ export default function AccountsPage() {
|
||||
width={600}
|
||||
>
|
||||
<Text type="secondary">
|
||||
格式:用户名|密码|邮箱|邮箱密码(每行一个)
|
||||
格式:用户名|密码|邮箱|邮箱密码|标签(可选,每行一个)
|
||||
</Text>
|
||||
<TextArea
|
||||
rows={10}
|
||||
value={importText}
|
||||
onChange={(e) => setImportText(e.target.value)}
|
||||
placeholder="用户名|密码|邮箱|邮箱密码 用户名|密码|邮箱|邮箱密码"
|
||||
placeholder={`用户名|密码|邮箱|邮箱密码|标签\n用户名|密码|邮箱|邮箱密码|标签`}
|
||||
style={{ marginTop: 8 }}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="批量设置标签"
|
||||
open={batchTagVisible}
|
||||
onCancel={() => setBatchTagVisible(false)}
|
||||
onOk={handleBatchTag}
|
||||
okText="确定"
|
||||
width={400}
|
||||
>
|
||||
<p>为选中的 {selectedRowKeys.length} 个账号设置标签:</p>
|
||||
<Select
|
||||
mode="tags"
|
||||
style={{ width: '100%' }}
|
||||
placeholder="输入或选择标签"
|
||||
value={batchTagInput ? [batchTagInput] : []}
|
||||
onChange={(vals) => setBatchTagInput(vals.length > 0 ? vals[vals.length - 1] : '')}
|
||||
options={tags.map((t) => ({ value: t, label: t }))}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useEffect, useState, useRef, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Table, Button, Select, message, Tag, Space, Card, Row, Col, Statistic, Spin,
|
||||
} from 'antd';
|
||||
import { PlayCircleOutlined, StopOutlined } from '@ant-design/icons';
|
||||
import { PlayCircleOutlined, StopOutlined, FilterOutlined } from '@ant-design/icons';
|
||||
import { accountApi, loginApi } from '../api/modules';
|
||||
import { getUser, hasPerm } from '../store/auth';
|
||||
|
||||
@@ -30,11 +30,31 @@ export default function LoginTasksPage() {
|
||||
const [batchId, setBatchId] = useState<string | null>(null);
|
||||
const [logs, setLogs] = useState<{ level: string; message: string }[]>([]);
|
||||
const [wsConnected, setWsConnected] = useState(false);
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const user = getUser();
|
||||
|
||||
const canBatch = hasPerm(user, 'login:batch');
|
||||
|
||||
// 从账号中提取所有标签
|
||||
const allTags = useMemo(() => {
|
||||
const tags = [...new Set(accounts.map((a) => (a.tag || '').trim()).filter(Boolean))];
|
||||
return tags.sort();
|
||||
}, [accounts]);
|
||||
|
||||
// 标签→账号ID映射
|
||||
const tagAccountMap = useMemo(() => {
|
||||
const map: Record<string, number[]> = {};
|
||||
accounts.forEach((a) => {
|
||||
const tag = (a.tag || '').trim();
|
||||
if (tag) {
|
||||
if (!map[tag]) map[tag] = [];
|
||||
map[tag].push(a.id);
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}, [accounts]);
|
||||
|
||||
const loadAccounts = async () => {
|
||||
try {
|
||||
const data = await accountApi.list();
|
||||
@@ -44,6 +64,37 @@ export default function LoginTasksPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 标签选择变化时,同步更新选中的账号
|
||||
const handleTagChange = useCallback((tags: string[]) => {
|
||||
setSelectedTags(tags);
|
||||
|
||||
setSelectedIds((prev) => {
|
||||
const prevTagSet = new Set(selectedTags);
|
||||
const newTagSet = new Set(tags);
|
||||
|
||||
// 新增的标签
|
||||
const addedTags = tags.filter((t) => !prevTagSet.has(t));
|
||||
// 移除的标签
|
||||
const removedTags = selectedTags.filter((t) => !newTagSet.has(t));
|
||||
|
||||
// 收集被移除标签下的所有账号ID
|
||||
const removedIds = new Set(removedTags.flatMap((t) => tagAccountMap[t] || []));
|
||||
// 收集新增标签下的所有账号ID
|
||||
const addedIds = addedTags.flatMap((t) => tagAccountMap[t] || []);
|
||||
|
||||
// 保留手动选择的账号(不属于任何已选标签的),移除被取消标签的账号,添加新选标签的账号
|
||||
const manualIds = prev.filter((id) => {
|
||||
const acc = accounts.find((a) => a.id === id);
|
||||
if (!acc) return false;
|
||||
const accTag = (acc.tag || '').trim();
|
||||
// 保留不属于当前任何已选标签、也不属于被移除标签的
|
||||
return !prevTagSet.has(accTag) && !removedIds.has(id);
|
||||
});
|
||||
|
||||
return [...new Set([...manualIds, ...addedIds])];
|
||||
});
|
||||
}, [selectedTags, tagAccountMap, accounts]);
|
||||
|
||||
const loadTasks = async () => {
|
||||
try {
|
||||
const data = await loginApi.listTasks(batchId || undefined);
|
||||
@@ -124,39 +175,103 @@ export default function LoginTasksPage() {
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>登录任务</h2>
|
||||
<div style={{ height: 'calc(100vh - 140px)', display: 'flex', flexDirection: 'column' }}>
|
||||
<h2 style={{ marginTop: 0, flexShrink: 0 }}>登录任务</h2>
|
||||
|
||||
{canBatch && (
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Space>
|
||||
<Select
|
||||
mode="multiple"
|
||||
style={{ minWidth: 400 }}
|
||||
placeholder="选择要登录的账号"
|
||||
value={selectedIds}
|
||||
onChange={setSelectedIds}
|
||||
options={accounts.map((a) => ({ value: a.id, label: a.username }))}
|
||||
maxTagCount="responsive"
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlayCircleOutlined />}
|
||||
loading={loading}
|
||||
onClick={handleBatchLogin}
|
||||
>
|
||||
开始登录
|
||||
</Button>
|
||||
{batchId && (
|
||||
<Button danger icon={<StopOutlined />} onClick={handleStop}>
|
||||
停止
|
||||
</Button>
|
||||
<Card size="small" style={{ marginBottom: 12, flexShrink: 0 }}>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{/* 标签快捷选择 */}
|
||||
{allTags.length > 0 && (
|
||||
<Space>
|
||||
<FilterOutlined />
|
||||
<Select
|
||||
mode="multiple"
|
||||
style={{ minWidth: 300 }}
|
||||
placeholder="选择标签快速筛选账号"
|
||||
value={selectedTags}
|
||||
onChange={handleTagChange}
|
||||
options={allTags.map((t) => ({ value: t, label: t }))}
|
||||
maxTagCount="responsive"
|
||||
allowClear
|
||||
/>
|
||||
{selectedTags.length > 0 && (
|
||||
<span style={{ color: '#888', fontSize: 12 }}>
|
||||
已通过标签选中 {accounts.filter((a) => selectedTags.includes((a.tag || '').trim())).length} 个账号
|
||||
</span>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
{/* 账号详情选择 */}
|
||||
<Space>
|
||||
<Select
|
||||
mode="multiple"
|
||||
style={{ minWidth: 400 }}
|
||||
placeholder="输入关键词筛选账号"
|
||||
value={selectedIds}
|
||||
onChange={setSelectedIds}
|
||||
options={(() => {
|
||||
const grouped: Record<string, { value: number; label: string }[]> = {};
|
||||
const noTag: { value: number; label: string }[] = [];
|
||||
accounts.forEach((a) => {
|
||||
const tag = (a.tag || '').trim();
|
||||
if (tag) {
|
||||
if (!grouped[tag]) grouped[tag] = [];
|
||||
grouped[tag].push({ value: a.id, label: a.username });
|
||||
} else {
|
||||
noTag.push({ value: a.id, label: a.username });
|
||||
}
|
||||
});
|
||||
const result: any[] = [];
|
||||
Object.keys(grouped).sort().forEach((tag) => {
|
||||
result.push({ label: tag, options: grouped[tag] });
|
||||
});
|
||||
if (noTag.length > 0) {
|
||||
result.push({ label: '未分组', options: noTag });
|
||||
}
|
||||
return result;
|
||||
})()}
|
||||
maxTagCount="responsive"
|
||||
showSearch
|
||||
filterOption={(input, option) => {
|
||||
if (!option) return false;
|
||||
const label = (option as any).label as string || '';
|
||||
return label.toLowerCase().includes(input.toLowerCase());
|
||||
}}
|
||||
dropdownRender={(menu) => (
|
||||
<>
|
||||
<div style={{ padding: '4px 8px', borderBottom: '1px solid #f0f0f0', display: 'flex', gap: 8 }}>
|
||||
<Button size="small" type="link" onClick={() => { setSelectedIds(accounts.map((a) => a.id)); setSelectedTags([]); }}>
|
||||
全选 ({accounts.length})
|
||||
</Button>
|
||||
<Button size="small" type="link" onClick={() => { setSelectedIds([]); setSelectedTags([]); }}>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
{menu}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlayCircleOutlined />}
|
||||
loading={loading}
|
||||
onClick={handleBatchLogin}
|
||||
disabled={selectedIds.length === 0}
|
||||
>
|
||||
开始登录
|
||||
</Button>
|
||||
{batchId && (
|
||||
<Button danger icon={<StopOutlined />} onClick={handleStop}>
|
||||
停止
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Row gutter={16} style={{ marginBottom: 12, flexShrink: 0 }}>
|
||||
<Col span={6}>
|
||||
<Card size="small"><Statistic title="任务总数" value={tasks.length} /></Card>
|
||||
</Col>
|
||||
@@ -171,23 +286,35 @@ export default function LoginTasksPage() {
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={14}>
|
||||
<Card title="任务列表" size="small">
|
||||
<Row gutter={16} style={{ flex: 1, minHeight: 0 }}>
|
||||
<Col span={14} style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<Card
|
||||
title="任务列表"
|
||||
size="small"
|
||||
style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}
|
||||
bodyStyle={{ flex: 1, overflow: 'auto', padding: 0 }}
|
||||
>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={tasks}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={{ pageSize: 15 }}
|
||||
pagination={{ pageSize: 15, size: 'small' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={10}>
|
||||
<Col span={10} style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<Card
|
||||
title="实时日志"
|
||||
size="small"
|
||||
bodyStyle={{ maxHeight: 500, overflow: 'auto', fontFamily: 'monospace', fontSize: 12 }}
|
||||
style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}
|
||||
bodyStyle={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
padding: 12,
|
||||
}}
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<Spin spinning={wsConnected} size="small" />
|
||||
@@ -197,10 +324,10 @@ export default function LoginTasksPage() {
|
||||
key={i}
|
||||
style={{
|
||||
color:
|
||||
log.level === 'error' ? '#ff0000' :
|
||||
log.level === 'success' ? '#008000' :
|
||||
log.level === 'warning' ? '#FF8C00' :
|
||||
'#333',
|
||||
log.level === 'error' ? '#ff4d4f' :
|
||||
log.level === 'success' ? '#52c41a' :
|
||||
log.level === 'warning' ? '#fa8c16' :
|
||||
'rgba(0,0,0,0.85)',
|
||||
}}
|
||||
>
|
||||
{log.message}
|
||||
|
||||
Reference in New Issue
Block a user