feat(config): 配置页 bool 开关控件 + 全站 antd App.useApp 迁移 (#12)
- 系统配置页支持 bool 类型(Switch 控件),供「提现自动对账」「比价期广告」等运营开关使用 - message / Modal.confirm 静态调用迁移到 App.useApp() 的 message / modal,消除 React 19 "Static function can not consume context" 告警;providers 用 <App> 容器包裹全站 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: OuYingJun1024 <1034284404@qq.com> Reviewed-on: #12 Co-authored-by: ouzhou <ouzhou@wonderable.ai> Co-committed-by: ouzhou <ouzhou@wonderable.ai>
This commit was merged in pull request #12.
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
App,
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
@@ -19,7 +20,6 @@ import {
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import {
|
||||
FallOutlined,
|
||||
@@ -269,6 +269,7 @@ function TrendChart({ points }: { points: TrendPoint[] }) {
|
||||
// 广告数据页:单表 —— 按 用户×类型×应用×代码位 聚合,一行同时给出
|
||||
// 展示条数 / 收益 / 应发金币 / 实发金币 / 是否一致(发奖对账下沉到聚合级)。
|
||||
export default function AdRevenuePage() {
|
||||
const { message } = App.useApp();
|
||||
const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs(), dayjs()]);
|
||||
const [userId, setUserId] = useState<number | null>(null);
|
||||
const [adType, setAdType] = useState<string | undefined>();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { Button, Form, Input, Modal, Select, Table, Tag, message } from 'antd';
|
||||
import { App, Button, Form, Input, Modal, Select, Table, Tag } from 'antd';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { formatUtcTime } from '@/lib/format';
|
||||
import type { AdminInfo } from '@/lib/types';
|
||||
@@ -16,6 +16,7 @@ const ROLES = [
|
||||
const dt = (v: string | null) => (v ? formatUtcTime(v, 'YYYY-MM-DD HH:mm:ss') : '从未');
|
||||
|
||||
export default function AdminsPage() {
|
||||
const { message, modal } = App.useApp();
|
||||
const [admins, setAdmins] = useState<AdminInfo[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
@@ -49,7 +50,7 @@ export default function AdminsPage() {
|
||||
|
||||
const changeRole = (a: AdminInfo, role: string) => {
|
||||
if (role === a.role) return;
|
||||
Modal.confirm({
|
||||
modal.confirm({
|
||||
title: `把 ${a.username} 的角色改为 ${role}?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
@@ -65,7 +66,7 @@ export default function AdminsPage() {
|
||||
|
||||
const toggle = (a: AdminInfo) => {
|
||||
const next = a.status === 'active' ? 'disabled' : 'active';
|
||||
Modal.confirm({
|
||||
modal.confirm({
|
||||
title: `${next === 'disabled' ? '禁用' : '启用'}管理员 ${a.username}?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Card, Input, InputNumber, Space, Spin, Tag, Tooltip, message } from 'antd';
|
||||
import { App, Button, Card, Input, InputNumber, Space, Spin, Switch, Tag, Tooltip } from 'antd';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
|
||||
interface ConfigItem {
|
||||
key: string;
|
||||
label: string;
|
||||
group: string;
|
||||
type: string; // int / int_list / dict_str_int
|
||||
type: string; // int / int_list / dict_str_int / bool
|
||||
help: string | null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
default: any;
|
||||
@@ -28,6 +28,7 @@ function toEdit(item: ConfigItem): any {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function fromEdit(type: string, raw: any): any {
|
||||
if (type === 'int') return Number(raw);
|
||||
if (type === 'bool') return Boolean(raw);
|
||||
if (type === 'int_list') {
|
||||
return String(raw)
|
||||
.split(',')
|
||||
@@ -38,6 +39,7 @@ function fromEdit(type: string, raw: any): any {
|
||||
}
|
||||
|
||||
export default function ConfigPage() {
|
||||
const { message } = App.useApp();
|
||||
const [items, setItems] = useState<ConfigItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -109,7 +111,12 @@ export default function ConfigPage() {
|
||||
)}
|
||||
</div>
|
||||
<Space wrap>
|
||||
{item.type === 'int' ? (
|
||||
{item.type === 'bool' ? (
|
||||
<Switch
|
||||
checked={!!edits[item.key]}
|
||||
onChange={(v) => setEdits({ ...edits, [item.key]: v })}
|
||||
/>
|
||||
) : item.type === 'int' ? (
|
||||
<InputNumber
|
||||
value={edits[item.key]}
|
||||
onChange={(v) => setEdits({ ...edits, [item.key]: v })}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
App,
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
@@ -12,7 +13,6 @@ import {
|
||||
Spin,
|
||||
Switch,
|
||||
Table,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
|
||||
@@ -51,6 +51,7 @@ const yuan = (cents: number) => (cents / 100).toFixed(2);
|
||||
|
||||
/** 首页轮播「种子」管理(真实记录不足时的兜底假数据,现为「生成规则」)。「数据大盘」页的一个区块。 */
|
||||
export default function HomeMarqueeSeeds() {
|
||||
const { message } = App.useApp();
|
||||
const [seeds, setSeeds] = useState<Seed[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]); // 批量操作选中行
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Col,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Radio,
|
||||
Row,
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
Spin,
|
||||
Tag,
|
||||
Tooltip,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { formatUtcTime } from '@/lib/format';
|
||||
@@ -249,6 +248,7 @@ function buildBody(metric: string, e: Edit, immediate: boolean): Record<string,
|
||||
|
||||
/** 首页三门面数字(帮助用户/完成比价/累计节省)的展示模式配置。作为「数据大盘」页的一个区块。 */
|
||||
export default function HomeStatsConfig() {
|
||||
const { message, modal } = App.useApp();
|
||||
const [items, setItems] = useState<StatItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [edits, setEdits] = useState<Record<string, Edit>>({});
|
||||
@@ -321,7 +321,7 @@ export default function HomeStatsConfig() {
|
||||
// 智能校验:按下后三者大概会呈现的关系是否合理,不合理则弹窗劝阻、确认才提交。
|
||||
const rel = checkRelations(items, edits);
|
||||
if (rel && rel.warnings.length > 0) {
|
||||
Modal.confirm({
|
||||
modal.confirm({
|
||||
title: '数据关系校验',
|
||||
width: 480,
|
||||
okText: '仍要这样设置',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { Button, Modal, Popconfirm, Space, Table, message } from 'antd';
|
||||
import { App, Button, Popconfirm, Space, Table } from 'antd';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { canDo } from '@/lib/auth';
|
||||
import { formatUtcTime } from '@/lib/format';
|
||||
@@ -13,6 +13,7 @@ const dt = (v: string) => formatUtcTime(v);
|
||||
const EMPTY: Record<string, unknown> = {};
|
||||
|
||||
export default function DevicesPage() {
|
||||
const { message, modal } = App.useApp();
|
||||
// 后端按设备聚合、全量返回(next_cursor 恒 null),故无筛选/加载更多。
|
||||
const { items, loading, reload } = useCursorList<DeviceOnboardingItem>(
|
||||
'/admin/api/onboarding/devices',
|
||||
@@ -31,7 +32,7 @@ export default function DevicesPage() {
|
||||
};
|
||||
|
||||
const resetAll = () => {
|
||||
Modal.confirm({
|
||||
modal.confirm({
|
||||
title: '确认全部重设新手引导?',
|
||||
content:
|
||||
'清空所有设备的引导完成记录,库里所有用户下次打开 App 都会重走一遍新手引导。此操作不可逆。',
|
||||
|
||||
@@ -5,17 +5,16 @@ import type { Key } from 'react';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { SorterResult } from 'antd/es/table/interface';
|
||||
import {
|
||||
App,
|
||||
Button,
|
||||
DatePicker,
|
||||
Image,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
@@ -34,6 +33,7 @@ const mediaUrl = (p: string) => (p.startsWith('http') ? p : MEDIA_BASE + p);
|
||||
type SortField = 'id' | 'created_at';
|
||||
|
||||
export default function FeedbacksPage() {
|
||||
const { message, modal } = App.useApp();
|
||||
// 筛选草稿:点「查询」才应用(避免输入即刷新)
|
||||
const [status, setStatus] = useState<string | undefined>();
|
||||
const [userId, setUserId] = useState('');
|
||||
@@ -114,7 +114,7 @@ export default function FeedbacksPage() {
|
||||
message.warning('没有可处理的选中项(仅待处理 new 态生效)');
|
||||
return;
|
||||
}
|
||||
Modal.confirm({
|
||||
modal.confirm({
|
||||
title: `确认批量标记处理 ${selectedNew.length} 条反馈?`,
|
||||
content: '仅对选中的待处理(new)反馈生效',
|
||||
onOk: async () => {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Image,
|
||||
@@ -13,7 +14,6 @@ import {
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
|
||||
@@ -57,6 +57,7 @@ function statusTag(status: string) {
|
||||
}
|
||||
|
||||
export default function PriceReportsPage() {
|
||||
const { message, modal } = App.useApp();
|
||||
const [activeStatus, setActiveStatus] = useState('pending');
|
||||
const [summary, setSummary] = useState<PriceReportSummary | null>(null);
|
||||
const [rejecting, setRejecting] = useState<PriceReport | null>(null);
|
||||
@@ -88,7 +89,7 @@ export default function PriceReportsPage() {
|
||||
};
|
||||
|
||||
const approve = (r: PriceReport) => {
|
||||
Modal.confirm({
|
||||
modal.confirm({
|
||||
title: '确认通过该上报?',
|
||||
icon: <CheckCircleOutlined style={{ color: '#52c41a' }} />,
|
||||
content: (
|
||||
|
||||
@@ -4,11 +4,11 @@ import { useCallback, useEffect, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Descriptions,
|
||||
Modal,
|
||||
Result,
|
||||
Row,
|
||||
Space,
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { canDo } from '@/lib/auth';
|
||||
@@ -27,6 +26,7 @@ import { AdjustCashModal, AdjustCoinModal } from '@/components/AdjustBalanceModa
|
||||
import type { CashTxn, CoinTxn, UserOverview, WithdrawOrder } from '@/lib/types';
|
||||
|
||||
export default function UserDetailPage() {
|
||||
const { message, modal } = App.useApp();
|
||||
const params = useParams<{ id: string }>();
|
||||
const uid = Number(params.id);
|
||||
const [data, setData] = useState<UserOverview | null>(null);
|
||||
@@ -56,7 +56,7 @@ export default function UserDetailPage() {
|
||||
const toggleStatus = () => {
|
||||
if (!data) return;
|
||||
const next = data.user.status === 'active' ? 'disabled' : 'active';
|
||||
Modal.confirm({
|
||||
modal.confirm({
|
||||
title: `确认${next === 'disabled' ? '封禁' : '解封'}用户 ${data.user.phone}?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { Key } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { SorterResult } from 'antd/es/table/interface';
|
||||
import { Button, DatePicker, Input, Modal, Select, Space, Table, Tag, message } from 'antd';
|
||||
import { App, Button, DatePicker, Input, Select, Space, Table, Tag } from 'antd';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { canDo } from '@/lib/auth';
|
||||
@@ -22,6 +22,7 @@ type SortField = 'id' | 'created_at' | 'last_login_at';
|
||||
|
||||
export default function UsersPage() {
|
||||
const router = useRouter();
|
||||
const { message, modal } = App.useApp();
|
||||
|
||||
// 筛选草稿:点「查询」才应用(避免输入即刷新);状态/渠道/日期一并随查询提交
|
||||
const [phone, setPhone] = useState('');
|
||||
@@ -97,7 +98,7 @@ export default function UsersPage() {
|
||||
|
||||
const toggleStatus = (u: UserListItem) => {
|
||||
const next = u.status === 'active' ? 'disabled' : 'active';
|
||||
Modal.confirm({
|
||||
modal.confirm({
|
||||
title: `确认${next === 'disabled' ? '封禁' : '解封'}用户 ${u.phone}?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
@@ -113,7 +114,7 @@ export default function UsersPage() {
|
||||
|
||||
const toggleDebugTrace = (u: UserListItem) => {
|
||||
const next = !u.debug_trace_enabled;
|
||||
Modal.confirm({
|
||||
modal.confirm({
|
||||
title: `确认${next ? '开启' : '关闭'}用户 ${u.phone} 的调试链接权限?`,
|
||||
content: next
|
||||
? '开启后,该用户在 App 比价结果页/记录页可复制 trace 调试链接发给开发排障'
|
||||
@@ -156,7 +157,7 @@ export default function UsersPage() {
|
||||
message.warning(`没有可${target === 'disabled' ? '封禁(active)' : '解封(disabled)'}的选中用户`);
|
||||
return;
|
||||
}
|
||||
Modal.confirm({
|
||||
modal.confirm({
|
||||
title: `确认${label} ${targets.length} 个用户?`,
|
||||
content: target === 'disabled' ? '仅对选中的 active 用户生效' : '仅对选中的 disabled 用户生效',
|
||||
okButtonProps: { danger: target === 'disabled' },
|
||||
@@ -177,7 +178,7 @@ export default function UsersPage() {
|
||||
message.warning('没有需要变更的选中用户');
|
||||
return;
|
||||
}
|
||||
Modal.confirm({
|
||||
modal.confirm({
|
||||
title: `确认${label} ${targets.length} 个用户?`,
|
||||
onOk: () =>
|
||||
runBulk(targets, label, (u) =>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { Key } from 'react';
|
||||
import {
|
||||
App,
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
@@ -22,7 +23,6 @@ import {
|
||||
Tag,
|
||||
Timeline,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
@@ -169,6 +169,7 @@ function bulkResultText(action: string, result: WithdrawBulkResult) {
|
||||
}
|
||||
|
||||
export default function WithdrawsPage() {
|
||||
const { message, modal } = App.useApp();
|
||||
const [activeStatus, setActiveStatus] = useState('reviewing');
|
||||
const [summary, setSummary] = useState<WithdrawSummary | null>(null);
|
||||
const [health, setHealth] = useState<WxpayHealthCheck | null>(null);
|
||||
@@ -305,7 +306,7 @@ export default function WithdrawsPage() {
|
||||
};
|
||||
|
||||
const approve = (o: WithdrawOrder) => {
|
||||
Modal.confirm({
|
||||
modal.confirm({
|
||||
title: `确认通过并打款 ${yuan(o.amount_cents)}?`,
|
||||
content: (
|
||||
<div>
|
||||
@@ -368,7 +369,7 @@ export default function WithdrawsPage() {
|
||||
}
|
||||
const amount = selectedReviewing.reduce((sum, item) => sum + item.amount_cents, 0);
|
||||
let confirmText = '';
|
||||
Modal.confirm({
|
||||
modal.confirm({
|
||||
title: `确认批量通过 ${selectedReviewing.length} 笔提现?`,
|
||||
content: (
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
@@ -406,7 +407,7 @@ export default function WithdrawsPage() {
|
||||
message.warning('请选择打款中的提现单');
|
||||
return;
|
||||
}
|
||||
Modal.confirm({
|
||||
modal.confirm({
|
||||
title: `确认批量刷新 ${selectedPending.length} 笔打款状态?`,
|
||||
content: '会逐笔向微信查单,并按最新状态归一化。',
|
||||
okText: '批量刷新查单',
|
||||
@@ -428,7 +429,7 @@ export default function WithdrawsPage() {
|
||||
};
|
||||
|
||||
const retry = (o: WithdrawOrder) => {
|
||||
Modal.confirm({
|
||||
modal.confirm({
|
||||
title: `重新查询 ${o.out_bill_no} 的微信状态?`,
|
||||
content: '会按微信最新状态归一化为成功、失败退款或撤销未确认。',
|
||||
okText: '刷新查单',
|
||||
@@ -450,7 +451,7 @@ export default function WithdrawsPage() {
|
||||
};
|
||||
|
||||
const reconcile = () => {
|
||||
Modal.confirm({
|
||||
modal.confirm({
|
||||
title: '批量对账?',
|
||||
content: '扫描超过 15 分钟仍处于打款中的提现单,逐单向微信查实并归一化。',
|
||||
okText: '开始对账',
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button, Card, Form, Input, message } from 'antd';
|
||||
import { App, Button, Card, Form, Input } from 'antd';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { setAuth } from '@/lib/auth';
|
||||
import type { LoginResponse } from '@/lib/types';
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const { message } = App.useApp();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const onFinish = async (values: { username: string; password: string }) => {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// 必须在引入 antd 之前 import。
|
||||
import '@ant-design/v5-patch-for-react-19';
|
||||
import { AntdRegistry } from '@ant-design/nextjs-registry';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import { App, ConfigProvider } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
@@ -23,7 +23,9 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
||||
},
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{/* antd <App> 容器:让 message/notification/Modal 能通过 App.useApp() 读到 ConfigProvider
|
||||
的动态主题/locale,消除 "Static function can not consume context" 告警(全站生效) */}
|
||||
<App>{children}</App>
|
||||
</ConfigProvider>
|
||||
</AntdRegistry>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// 调金币 / 调现金弹窗(受控)。列表页与用户详情页共用,避免两处各写一套。
|
||||
// 每个弹窗自带「增减 / 设为指定值」两种模式;打开时拉一次 360 概览展示当前余额。
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Form, Input, InputNumber, Modal, Segmented, message } from 'antd';
|
||||
import { App, Form, Input, InputNumber, Modal, Segmented } from 'antd';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { yuan } from '@/lib/format';
|
||||
import type { UserOverview } from '@/lib/types';
|
||||
@@ -47,6 +47,7 @@ function useBalances(userId: number | undefined, open: boolean) {
|
||||
}
|
||||
|
||||
export function AdjustCoinModal({ user, open, onClose, onDone }: ModalProps) {
|
||||
const { message } = App.useApp();
|
||||
const [form] = Form.useForm();
|
||||
const [mode, setMode] = useState<'delta' | 'set'>('delta');
|
||||
const balances = useBalances(user?.id, open);
|
||||
@@ -110,6 +111,7 @@ export function AdjustCoinModal({ user, open, onClose, onDone }: ModalProps) {
|
||||
}
|
||||
|
||||
export function AdjustCashModal({ user, open, onClose, onDone }: ModalProps) {
|
||||
const { message } = App.useApp();
|
||||
const [form] = Form.useForm();
|
||||
const [mode, setMode] = useState<'delta' | 'set'>('delta');
|
||||
const balances = useBalances(user?.id, open);
|
||||
|
||||
Reference in New Issue
Block a user