feat: support ban and unban user & show user detailed information

This commit is contained in:
javayhu 2025-05-11 00:47:05 +08:00
parent 71e9e33fd7
commit 061b304aa8
7 changed files with 446 additions and 12 deletions

View File

@ -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": {

View File

@ -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
View 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
View 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',
};
}
});

View 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>
);
}

View File

@ -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;
}}
/>
);
},
},

View File

@ -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