feat: support ban and unban user & show user detailed information
This commit is contained in:
parent
71e9e33fd7
commit
061b304aa8
@ -440,7 +440,27 @@
|
||||
"admin": {
|
||||
"title": "Admin",
|
||||
"users": {
|
||||
"title": "Users"
|
||||
"title": "Users",
|
||||
"email": {
|
||||
"verified": "Verified",
|
||||
"unverified": "Unverified"
|
||||
},
|
||||
"banned": "Banned",
|
||||
"joined": "Joined",
|
||||
"ban": {
|
||||
"reason": "Ban Reason",
|
||||
"reasonPlaceholder": "Enter the reason for banning this user",
|
||||
"expires": "Ban Expires",
|
||||
"button": "Ban User",
|
||||
"success": "User has been banned",
|
||||
"error": "Failed to ban user"
|
||||
},
|
||||
"unban": {
|
||||
"button": "Unban User",
|
||||
"success": "User has been unbanned",
|
||||
"error": "Failed to unban user"
|
||||
},
|
||||
"close": "Close"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
|
@ -441,7 +441,27 @@
|
||||
"admin": {
|
||||
"title": "系统管理",
|
||||
"users": {
|
||||
"title": "用户管理"
|
||||
"title": "用户管理",
|
||||
"email": {
|
||||
"verified": "已验证",
|
||||
"unverified": "未验证"
|
||||
},
|
||||
"banned": "已封禁",
|
||||
"joined": "加入时间",
|
||||
"ban": {
|
||||
"reason": "封禁原因",
|
||||
"reasonPlaceholder": "请输入封禁该用户的原因",
|
||||
"expires": "封禁到期时间",
|
||||
"button": "封禁用户",
|
||||
"success": "用户已被封禁",
|
||||
"error": "封禁用户失败"
|
||||
},
|
||||
"unban": {
|
||||
"button": "解除封禁",
|
||||
"success": "用户已被解除封禁",
|
||||
"error": "解除封禁失败"
|
||||
},
|
||||
"close": "关闭"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
|
47
src/actions/ban-user.ts
Normal file
47
src/actions/ban-user.ts
Normal file
@ -0,0 +1,47 @@
|
||||
'use server';
|
||||
|
||||
import db from '@/db';
|
||||
import { user } from '@/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { createSafeActionClient } from 'next-safe-action';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Create a safe action client
|
||||
const actionClient = createSafeActionClient();
|
||||
|
||||
// Ban user schema for validation
|
||||
const banUserSchema = z.object({
|
||||
userId: z.string().min(1, { message: 'User ID is required' }),
|
||||
reason: z.string().optional(),
|
||||
expiresAt: z.date().nullable(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Ban a user
|
||||
*/
|
||||
export const banUserAction = actionClient
|
||||
.schema(banUserSchema)
|
||||
.action(async ({ parsedInput }) => {
|
||||
try {
|
||||
const { userId, reason, expiresAt } = parsedInput;
|
||||
|
||||
await db
|
||||
.update(user)
|
||||
.set({
|
||||
banned: true,
|
||||
banReason: reason,
|
||||
banExpires: expiresAt,
|
||||
})
|
||||
.where(eq(user.id, userId));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Ban user error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to ban user',
|
||||
};
|
||||
}
|
||||
});
|
45
src/actions/unban-user.ts
Normal file
45
src/actions/unban-user.ts
Normal file
@ -0,0 +1,45 @@
|
||||
'use server';
|
||||
|
||||
import db from '@/db';
|
||||
import { user } from '@/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { createSafeActionClient } from 'next-safe-action';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Create a safe action client
|
||||
const actionClient = createSafeActionClient();
|
||||
|
||||
// Unban user schema for validation
|
||||
const unbanUserSchema = z.object({
|
||||
userId: z.string().min(1, { message: 'User ID is required' }),
|
||||
});
|
||||
|
||||
/**
|
||||
* Unban a user
|
||||
*/
|
||||
export const unbanUserAction = actionClient
|
||||
.schema(unbanUserSchema)
|
||||
.action(async ({ parsedInput }) => {
|
||||
try {
|
||||
const { userId } = parsedInput;
|
||||
|
||||
await db
|
||||
.update(user)
|
||||
.set({
|
||||
banned: false,
|
||||
banReason: null,
|
||||
banExpires: null,
|
||||
})
|
||||
.where(eq(user.id, userId));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Unban user error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to unban user',
|
||||
};
|
||||
}
|
||||
});
|
269
src/components/admin/user-detail-viewer.tsx
Normal file
269
src/components/admin/user-detail-viewer.tsx
Normal file
@ -0,0 +1,269 @@
|
||||
import { format } from 'date-fns';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { UserAvatar } from '@/components/layout/user-avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from '@/components/ui/drawer';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { Loader2Icon, MailCheckIcon, MailQuestionIcon } from 'lucide-react';
|
||||
import type { SafeActionResult } from 'next-safe-action';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
emailVerified: boolean;
|
||||
image: string | null;
|
||||
role: string | null;
|
||||
createdAt: Date;
|
||||
customerId: string | null;
|
||||
banned: boolean | null;
|
||||
banReason: string | null;
|
||||
banExpires: Date | null;
|
||||
}
|
||||
|
||||
interface UserDetailViewerProps {
|
||||
user: User;
|
||||
onBan: (
|
||||
userId: string,
|
||||
reason: string,
|
||||
expiresAt: Date | null
|
||||
) => Promise<SafeActionResult<string, any, any, any, any>>;
|
||||
onUnban: (
|
||||
userId: string
|
||||
) => Promise<SafeActionResult<string, any, any, any, any>>;
|
||||
}
|
||||
|
||||
export function UserDetailViewer({
|
||||
user,
|
||||
onBan,
|
||||
onUnban,
|
||||
}: UserDetailViewerProps) {
|
||||
const t = useTranslations('Dashboard.admin.users');
|
||||
const isMobile = useIsMobile();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | undefined>('');
|
||||
const [banReason, setBanReason] = useState('');
|
||||
const [banExpiresAt, setBanExpiresAt] = useState<string>('');
|
||||
|
||||
const handleBan = async () => {
|
||||
if (!banReason) {
|
||||
setError(t('ban.error'));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const expiresAt = banExpiresAt ? new Date(banExpiresAt) : null;
|
||||
const result = await onBan(user.id, banReason, expiresAt);
|
||||
|
||||
if (
|
||||
result?.data &&
|
||||
typeof result.data === 'object' &&
|
||||
'success' in result.data &&
|
||||
result.data.success
|
||||
) {
|
||||
toast.success(t('ban.success'));
|
||||
// Reset form
|
||||
setBanReason('');
|
||||
setBanExpiresAt('');
|
||||
} else {
|
||||
const errorMessage =
|
||||
result?.data &&
|
||||
typeof result.data === 'object' &&
|
||||
'error' in result.data
|
||||
? String(result.data.error)
|
||||
: t('ban.error');
|
||||
toast.error(errorMessage);
|
||||
setError(errorMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('ban user error:', error);
|
||||
setError(t('ban.error'));
|
||||
toast.error(t('ban.error'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnban = async () => {
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const result = await onUnban(user.id);
|
||||
|
||||
if (
|
||||
result?.data &&
|
||||
typeof result.data === 'object' &&
|
||||
'success' in result.data &&
|
||||
result.data.success
|
||||
) {
|
||||
toast.success(t('unban.success'));
|
||||
} else {
|
||||
const errorMessage =
|
||||
result?.data &&
|
||||
typeof result.data === 'object' &&
|
||||
'error' in result.data
|
||||
? String(result.data.error)
|
||||
: t('unban.error');
|
||||
toast.error(errorMessage);
|
||||
setError(errorMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('unban user error:', error);
|
||||
setError(t('unban.error'));
|
||||
toast.error(t('unban.error'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer direction={isMobile ? 'bottom' : 'right'}>
|
||||
<DrawerTrigger asChild>
|
||||
<Button variant="link" className="text-foreground w-fit px-0 text-left">
|
||||
<div className="flex items-center gap-2 pl-3">
|
||||
<UserAvatar
|
||||
name={user.name}
|
||||
image={user.image}
|
||||
className="size-8 border"
|
||||
/>
|
||||
<span>{user.name}</span>
|
||||
</div>
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader className="gap-1">
|
||||
<div className="flex items-center gap-4">
|
||||
<UserAvatar
|
||||
name={user.name}
|
||||
image={user.image}
|
||||
className="size-12 border"
|
||||
/>
|
||||
<div>
|
||||
<DrawerTitle>{user.name}</DrawerTitle>
|
||||
<DrawerDescription>{user.email}</DrawerDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DrawerHeader>
|
||||
<div className="flex flex-col gap-4 overflow-y-auto px-4 text-sm">
|
||||
<div className="grid gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-muted-foreground px-1.5">
|
||||
{user.emailVerified ? (
|
||||
<MailCheckIcon className="stroke-green-500 dark:stroke-green-400" />
|
||||
) : (
|
||||
<MailQuestionIcon className="stroke-red-500 dark:stroke-red-400" />
|
||||
)}
|
||||
{user.emailVerified
|
||||
? t('email.verified')
|
||||
: t('email.unverified')}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={user.role === 'admin' ? 'default' : 'outline'}
|
||||
className="px-1.5"
|
||||
>
|
||||
{user.role || 'user'}
|
||||
</Badge>
|
||||
{user.banned && (
|
||||
<Badge variant="destructive" className="px-1.5">
|
||||
{t('banned')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{t('joined')}: {format(user.createdAt, 'PPP')}
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
{error && <div className="text-sm text-destructive">{error}</div>}
|
||||
{user.banned ? (
|
||||
<div className="grid gap-4">
|
||||
<div className="text-muted-foreground">
|
||||
{t('ban.reason')}: {user.banReason}
|
||||
</div>
|
||||
{user.banExpires && (
|
||||
<div className="text-muted-foreground">
|
||||
{t('ban.expires')}: {format(user.banExpires, 'PPP')}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleUnban}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading && (
|
||||
<Loader2Icon className="mr-2 size-4 animate-spin" />
|
||||
)}
|
||||
{t('unban.button')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleBan();
|
||||
}}
|
||||
className="grid gap-4"
|
||||
>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="ban-reason">{t('ban.reason')}</Label>
|
||||
<Textarea
|
||||
id="ban-reason"
|
||||
value={banReason}
|
||||
onChange={(e) => setBanReason(e.target.value)}
|
||||
placeholder={t('ban.reasonPlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="ban-expires">{t('ban.expires')}</Label>
|
||||
<Input
|
||||
id="ban-expires"
|
||||
type="datetime-local"
|
||||
value={banExpiresAt}
|
||||
onChange={(e) => setBanExpiresAt(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
disabled={isLoading || !banReason}
|
||||
>
|
||||
{isLoading && (
|
||||
<Loader2Icon className="mr-2 size-4 animate-spin" />
|
||||
)}
|
||||
{t('ban.button')}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
<DrawerFooter>
|
||||
<DrawerClose asChild>
|
||||
<Button variant="outline">{t('close')}</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
@ -1,6 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { UserAvatar } from '@/components/layout/user-avatar';
|
||||
import { banUserAction } from '@/actions/ban-user';
|
||||
import { unbanUserAction } from '@/actions/unban-user';
|
||||
import { UserDetailViewer } from '@/components/admin/user-detail-viewer';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@ -107,14 +109,39 @@ const columns: ColumnDef<User>[] = [
|
||||
cell: ({ row }) => {
|
||||
const user = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-2 pl-3">
|
||||
<UserAvatar
|
||||
name={user.name}
|
||||
image={user.image}
|
||||
className="size-8 border"
|
||||
/>
|
||||
<span>{user.name}</span>
|
||||
</div>
|
||||
<UserDetailViewer
|
||||
user={user}
|
||||
onBan={async (userId, reason, expiresAt) => {
|
||||
const result = await banUserAction({ userId, reason, expiresAt });
|
||||
if (!result) {
|
||||
throw new Error('Failed to ban user');
|
||||
}
|
||||
if (result.validationErrors) {
|
||||
throw new Error(
|
||||
Object.values(result.validationErrors).join(', ')
|
||||
);
|
||||
}
|
||||
if (result.serverError) {
|
||||
throw new Error(result.serverError);
|
||||
}
|
||||
return result;
|
||||
}}
|
||||
onUnban={async (userId) => {
|
||||
const result = await unbanUserAction({ userId });
|
||||
if (!result) {
|
||||
throw new Error('Failed to unban user');
|
||||
}
|
||||
if (result.validationErrors) {
|
||||
throw new Error(
|
||||
Object.values(result.validationErrors).join(', ')
|
||||
);
|
||||
}
|
||||
if (result.serverError) {
|
||||
throw new Error(result.serverError);
|
||||
}
|
||||
return result;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
@ -142,7 +142,13 @@ export const auth = betterAuth({
|
||||
plugins: [
|
||||
// https://www.better-auth.com/docs/plugins/admin
|
||||
// support user management, ban/unban user, manage user roles, etc.
|
||||
admin(),
|
||||
admin({
|
||||
// https://www.better-auth.com/docs/plugins/admin#default-ban-reason
|
||||
defaultBanReason: 'Spamming',
|
||||
defaultBanExpiresIn: undefined,
|
||||
bannedUserMessage:
|
||||
'You have been banned from this application. Please contact support if you believe this is an error.',
|
||||
}),
|
||||
],
|
||||
onAPIError: {
|
||||
// https://www.better-auth.com/docs/reference/options#onapierror
|
||||
|
Loading…
Reference in New Issue
Block a user