Merge pull request #35 from MkSaaSHQ/user-table

feat: user management
This commit is contained in:
javayhu 2025-05-11 14:04:12 +08:00 committed by GitHub
commit 29af46fa28
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1159 additions and 688 deletions

View File

@ -440,7 +440,56 @@
"admin": {
"title": "Admin",
"users": {
"title": "Users"
"title": "Users",
"error": "Failed to get users",
"search": "Search users...",
"columns": {
"columns": "Columns",
"name": "Name",
"email": "Email",
"role": "Role",
"createdAt": "Created At",
"customerId": "Customer ID",
"status": "Status",
"banReason": "Ban Reason",
"banExpires": "Ban Expires"
},
"noResults": "No results",
"firstPage": "First Page",
"lastPage": "Last Page",
"nextPage": "Next Page",
"previousPage": "Previous Page",
"rowsPerPage": "Rows per page",
"page": "Page",
"loading": "Loading...",
"admin": "Admin",
"user": "User",
"email": {
"verified": "Email Verified",
"unverified": "Email Unverified"
},
"emailCopied": "Email copied to clipboard",
"banned": "Banned",
"active": "Active",
"joined": "Joined",
"updated": "Updated",
"ban": {
"reason": "Ban Reason",
"reasonPlaceholder": "Enter the reason for banning this user",
"defaultReason": "Spamming",
"never": "Never",
"expires": "Ban Expires",
"selectDate": "Select Date",
"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,56 @@
"admin": {
"title": "系统管理",
"users": {
"title": "用户管理"
"title": "用户管理",
"error": "获取用户失败",
"search": "搜索用户...",
"columns": {
"columns": "显示列",
"name": "姓名",
"email": "邮箱",
"role": "角色",
"createdAt": "创建时间",
"customerId": "客户ID",
"status": "状态",
"banReason": "封禁原因",
"banExpires": "封禁到期时间"
},
"noResults": "没有结果",
"firstPage": "第一页",
"lastPage": "最后一页",
"nextPage": "下一页",
"previousPage": "上一页",
"rowsPerPage": "每页行数",
"page": "页",
"loading": "加载中...",
"admin": "管理员",
"user": "用户",
"email": {
"verified": "邮箱已验证",
"unverified": "邮箱未验证"
},
"emailCopied": "邮箱已复制到剪贴板",
"banned": "账号被封禁",
"active": "账号正常",
"joined": "加入时间",
"updated": "更新时间",
"ban": {
"reason": "封禁原因",
"reasonPlaceholder": "请输入封禁该用户的原因",
"defaultReason": "垃圾信息",
"never": "永不解禁",
"expires": "封禁到期时间",
"selectDate": "选择日期",
"button": "封禁用户",
"success": "用户已被封禁",
"error": "封禁用户失败"
},
"unban": {
"button": "解除封禁",
"success": "用户已被解除封禁",
"error": "解除封禁失败"
},
"close": "关闭"
}
},
"settings": {

View File

@ -98,7 +98,7 @@
"next-themes": "^0.4.4",
"postgres": "^3.4.5",
"react": "^19.0.0",
"react-day-picker": "9.6.3",
"react-day-picker": "8.10.1",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
"react-remove-scroll": "^2.6.3",

26
pnpm-lock.yaml generated
View File

@ -252,8 +252,8 @@ importers:
specifier: ^19.0.0
version: 19.0.0
react-day-picker:
specifier: 9.6.3
version: 9.6.3(react@19.0.0)
specifier: 8.10.1
version: 8.10.1(date-fns@4.1.0)(react@19.0.0)
react-dom:
specifier: ^19.0.0
version: 19.0.0(react@19.0.0)
@ -785,9 +785,6 @@ packages:
'@content-collections/core': 0.x
next: ^12 || ^13 || ^14 || ^15
'@date-fns/tz@1.2.0':
resolution: {integrity: sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==}
'@dnd-kit/accessibility@3.1.1':
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
peerDependencies:
@ -3568,9 +3565,6 @@ packages:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
date-fns-jalali@4.1.0-0:
resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==}
date-fns@4.1.0:
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
@ -4919,11 +4913,11 @@ packages:
randombytes@2.1.0:
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
react-day-picker@9.6.3:
resolution: {integrity: sha512-rDqCSKAl5MLX0z1fLkYcBenQK4ANlYaAhUR0ruVSVAhAa7/ZmKQqgDpXPoS7bYEkgBRH06LO1qNFP1Ki8uiZpw==}
engines: {node: '>=18'}
react-day-picker@8.10.1:
resolution: {integrity: sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==}
peerDependencies:
react: '>=16.8.0'
date-fns: ^2.28.0 || ^3.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0
react-dom@19.0.0:
resolution: {integrity: sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==}
@ -6389,8 +6383,6 @@ snapshots:
'@content-collections/integrations': 0.2.1(@content-collections/core@0.8.0(typescript@5.7.3))
next: 15.2.1(@babel/core@7.24.5)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@date-fns/tz@1.2.0': {}
'@dnd-kit/accessibility@3.1.1(react@19.0.0)':
dependencies:
react: 19.0.0
@ -8899,8 +8891,6 @@ snapshots:
d3-timer@3.0.1: {}
date-fns-jalali@4.1.0-0: {}
date-fns@4.1.0: {}
debounce@2.0.0: {}
@ -10621,11 +10611,9 @@ snapshots:
dependencies:
safe-buffer: 5.2.1
react-day-picker@9.6.3(react@19.0.0):
react-day-picker@8.10.1(date-fns@4.1.0)(react@19.0.0):
dependencies:
'@date-fns/tz': 1.2.0
date-fns: 4.1.0
date-fns-jalali: 4.1.0-0
react: 19.0.0
react-dom@19.0.0(react@19.0.0):

85
src/actions/get-users.ts Normal file
View File

@ -0,0 +1,85 @@
'use server';
import db from '@/db';
import { user } from '@/db/schema';
import { asc, desc, ilike, or, sql } from 'drizzle-orm';
import { createSafeActionClient } from 'next-safe-action';
import { z } from 'zod';
// Create a safe action client
const actionClient = createSafeActionClient();
// Define the schema for getUsers parameters
const getUsersSchema = z.object({
pageIndex: z.number().min(0).default(0),
pageSize: z.number().min(1).max(100).default(10),
search: z.string().optional().default(''),
sorting: z
.array(
z.object({
id: z.string(),
desc: z.boolean(),
})
)
.optional()
.default([]),
});
// Define sort field mapping
const sortFieldMap = {
name: user.name,
email: user.email,
createdAt: user.createdAt,
role: user.role,
banned: user.banned,
customerId: user.customerId,
banReason: user.banReason,
banExpires: user.banExpires,
} as const;
// Create a safe action for getting users
export const getUsersAction = actionClient
.schema(getUsersSchema)
.action(async ({ parsedInput }) => {
try {
const { pageIndex, pageSize, search, sorting } = parsedInput;
const where = search
? or(ilike(user.name, `%${search}%`), ilike(user.email, `%${search}%`))
: undefined;
const offset = pageIndex * pageSize;
// Get the sort configuration
const sortConfig = sorting[0];
const sortField = sortConfig?.id
? sortFieldMap[sortConfig.id as keyof typeof sortFieldMap]
: user.createdAt;
const sortDirection = sortConfig?.desc ? desc : asc;
const [items, [{ count }]] = await Promise.all([
db
.select()
.from(user)
.where(where)
.orderBy(sortDirection(sortField))
.limit(pageSize)
.offset(offset),
db.select({ count: sql`count(*)` }).from(user).where(where),
]);
return {
success: true,
data: {
items,
total: Number(count),
},
};
} catch (error) {
console.error('get users error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch users',
};
}
});

View File

@ -1,614 +0,0 @@
[
{
"id": 1,
"header": "Cover page",
"type": "Cover page",
"status": "In Process",
"target": "18",
"limit": "5",
"reviewer": "Eddie Lake"
},
{
"id": 2,
"header": "Table of contents",
"type": "Table of contents",
"status": "Done",
"target": "29",
"limit": "24",
"reviewer": "Eddie Lake"
},
{
"id": 3,
"header": "Executive summary",
"type": "Narrative",
"status": "Done",
"target": "10",
"limit": "13",
"reviewer": "Eddie Lake"
},
{
"id": 4,
"header": "Technical approach",
"type": "Narrative",
"status": "Done",
"target": "27",
"limit": "23",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 5,
"header": "Design",
"type": "Narrative",
"status": "In Process",
"target": "2",
"limit": "16",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 6,
"header": "Capabilities",
"type": "Narrative",
"status": "In Process",
"target": "20",
"limit": "8",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 7,
"header": "Integration with existing systems",
"type": "Narrative",
"status": "In Process",
"target": "19",
"limit": "21",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 8,
"header": "Innovation and Advantages",
"type": "Narrative",
"status": "Done",
"target": "25",
"limit": "26",
"reviewer": "Assign reviewer"
},
{
"id": 9,
"header": "Overview of EMR's Innovative Solutions",
"type": "Technical content",
"status": "Done",
"target": "7",
"limit": "23",
"reviewer": "Assign reviewer"
},
{
"id": 10,
"header": "Advanced Algorithms and Machine Learning",
"type": "Narrative",
"status": "Done",
"target": "30",
"limit": "28",
"reviewer": "Assign reviewer"
},
{
"id": 11,
"header": "Adaptive Communication Protocols",
"type": "Narrative",
"status": "Done",
"target": "9",
"limit": "31",
"reviewer": "Assign reviewer"
},
{
"id": 12,
"header": "Advantages Over Current Technologies",
"type": "Narrative",
"status": "Done",
"target": "12",
"limit": "0",
"reviewer": "Assign reviewer"
},
{
"id": 13,
"header": "Past Performance",
"type": "Narrative",
"status": "Done",
"target": "22",
"limit": "33",
"reviewer": "Assign reviewer"
},
{
"id": 14,
"header": "Customer Feedback and Satisfaction Levels",
"type": "Narrative",
"status": "Done",
"target": "15",
"limit": "34",
"reviewer": "Assign reviewer"
},
{
"id": 15,
"header": "Implementation Challenges and Solutions",
"type": "Narrative",
"status": "Done",
"target": "3",
"limit": "35",
"reviewer": "Assign reviewer"
},
{
"id": 16,
"header": "Security Measures and Data Protection Policies",
"type": "Narrative",
"status": "In Process",
"target": "6",
"limit": "36",
"reviewer": "Assign reviewer"
},
{
"id": 17,
"header": "Scalability and Future Proofing",
"type": "Narrative",
"status": "Done",
"target": "4",
"limit": "37",
"reviewer": "Assign reviewer"
},
{
"id": 18,
"header": "Cost-Benefit Analysis",
"type": "Plain language",
"status": "Done",
"target": "14",
"limit": "38",
"reviewer": "Assign reviewer"
},
{
"id": 19,
"header": "User Training and Onboarding Experience",
"type": "Narrative",
"status": "Done",
"target": "17",
"limit": "39",
"reviewer": "Assign reviewer"
},
{
"id": 20,
"header": "Future Development Roadmap",
"type": "Narrative",
"status": "Done",
"target": "11",
"limit": "40",
"reviewer": "Assign reviewer"
},
{
"id": 21,
"header": "System Architecture Overview",
"type": "Technical content",
"status": "In Process",
"target": "24",
"limit": "18",
"reviewer": "Maya Johnson"
},
{
"id": 22,
"header": "Risk Management Plan",
"type": "Narrative",
"status": "Done",
"target": "15",
"limit": "22",
"reviewer": "Carlos Rodriguez"
},
{
"id": 23,
"header": "Compliance Documentation",
"type": "Legal",
"status": "In Process",
"target": "31",
"limit": "27",
"reviewer": "Sarah Chen"
},
{
"id": 24,
"header": "API Documentation",
"type": "Technical content",
"status": "Done",
"target": "8",
"limit": "12",
"reviewer": "Raj Patel"
},
{
"id": 25,
"header": "User Interface Mockups",
"type": "Visual",
"status": "In Process",
"target": "19",
"limit": "25",
"reviewer": "Leila Ahmadi"
},
{
"id": 26,
"header": "Database Schema",
"type": "Technical content",
"status": "Done",
"target": "22",
"limit": "20",
"reviewer": "Thomas Wilson"
},
{
"id": 27,
"header": "Testing Methodology",
"type": "Technical content",
"status": "In Process",
"target": "17",
"limit": "14",
"reviewer": "Assign reviewer"
},
{
"id": 28,
"header": "Deployment Strategy",
"type": "Narrative",
"status": "Done",
"target": "26",
"limit": "30",
"reviewer": "Eddie Lake"
},
{
"id": 29,
"header": "Budget Breakdown",
"type": "Financial",
"status": "In Process",
"target": "13",
"limit": "16",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 30,
"header": "Market Analysis",
"type": "Research",
"status": "Done",
"target": "29",
"limit": "32",
"reviewer": "Sophia Martinez"
},
{
"id": 31,
"header": "Competitor Comparison",
"type": "Research",
"status": "In Process",
"target": "21",
"limit": "19",
"reviewer": "Assign reviewer"
},
{
"id": 32,
"header": "Maintenance Plan",
"type": "Technical content",
"status": "Done",
"target": "16",
"limit": "23",
"reviewer": "Alex Thompson"
},
{
"id": 33,
"header": "User Personas",
"type": "Research",
"status": "In Process",
"target": "27",
"limit": "24",
"reviewer": "Nina Patel"
},
{
"id": 34,
"header": "Accessibility Compliance",
"type": "Legal",
"status": "Done",
"target": "18",
"limit": "21",
"reviewer": "Assign reviewer"
},
{
"id": 35,
"header": "Performance Metrics",
"type": "Technical content",
"status": "In Process",
"target": "23",
"limit": "26",
"reviewer": "David Kim"
},
{
"id": 36,
"header": "Disaster Recovery Plan",
"type": "Technical content",
"status": "Done",
"target": "14",
"limit": "17",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 37,
"header": "Third-party Integrations",
"type": "Technical content",
"status": "In Process",
"target": "25",
"limit": "28",
"reviewer": "Eddie Lake"
},
{
"id": 38,
"header": "User Feedback Summary",
"type": "Research",
"status": "Done",
"target": "20",
"limit": "15",
"reviewer": "Assign reviewer"
},
{
"id": 39,
"header": "Localization Strategy",
"type": "Narrative",
"status": "In Process",
"target": "12",
"limit": "19",
"reviewer": "Maria Garcia"
},
{
"id": 40,
"header": "Mobile Compatibility",
"type": "Technical content",
"status": "Done",
"target": "28",
"limit": "31",
"reviewer": "James Wilson"
},
{
"id": 41,
"header": "Data Migration Plan",
"type": "Technical content",
"status": "In Process",
"target": "19",
"limit": "22",
"reviewer": "Assign reviewer"
},
{
"id": 42,
"header": "Quality Assurance Protocols",
"type": "Technical content",
"status": "Done",
"target": "30",
"limit": "33",
"reviewer": "Priya Singh"
},
{
"id": 43,
"header": "Stakeholder Analysis",
"type": "Research",
"status": "In Process",
"target": "11",
"limit": "14",
"reviewer": "Eddie Lake"
},
{
"id": 44,
"header": "Environmental Impact Assessment",
"type": "Research",
"status": "Done",
"target": "24",
"limit": "27",
"reviewer": "Assign reviewer"
},
{
"id": 45,
"header": "Intellectual Property Rights",
"type": "Legal",
"status": "In Process",
"target": "17",
"limit": "20",
"reviewer": "Sarah Johnson"
},
{
"id": 46,
"header": "Customer Support Framework",
"type": "Narrative",
"status": "Done",
"target": "22",
"limit": "25",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 47,
"header": "Version Control Strategy",
"type": "Technical content",
"status": "In Process",
"target": "15",
"limit": "18",
"reviewer": "Assign reviewer"
},
{
"id": 48,
"header": "Continuous Integration Pipeline",
"type": "Technical content",
"status": "Done",
"target": "26",
"limit": "29",
"reviewer": "Michael Chen"
},
{
"id": 49,
"header": "Regulatory Compliance",
"type": "Legal",
"status": "In Process",
"target": "13",
"limit": "16",
"reviewer": "Assign reviewer"
},
{
"id": 50,
"header": "User Authentication System",
"type": "Technical content",
"status": "Done",
"target": "28",
"limit": "31",
"reviewer": "Eddie Lake"
},
{
"id": 51,
"header": "Data Analytics Framework",
"type": "Technical content",
"status": "In Process",
"target": "21",
"limit": "24",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 52,
"header": "Cloud Infrastructure",
"type": "Technical content",
"status": "Done",
"target": "16",
"limit": "19",
"reviewer": "Assign reviewer"
},
{
"id": 53,
"header": "Network Security Measures",
"type": "Technical content",
"status": "In Process",
"target": "29",
"limit": "32",
"reviewer": "Lisa Wong"
},
{
"id": 54,
"header": "Project Timeline",
"type": "Planning",
"status": "Done",
"target": "14",
"limit": "17",
"reviewer": "Eddie Lake"
},
{
"id": 55,
"header": "Resource Allocation",
"type": "Planning",
"status": "In Process",
"target": "27",
"limit": "30",
"reviewer": "Assign reviewer"
},
{
"id": 56,
"header": "Team Structure and Roles",
"type": "Planning",
"status": "Done",
"target": "20",
"limit": "23",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 57,
"header": "Communication Protocols",
"type": "Planning",
"status": "In Process",
"target": "15",
"limit": "18",
"reviewer": "Assign reviewer"
},
{
"id": 58,
"header": "Success Metrics",
"type": "Planning",
"status": "Done",
"target": "30",
"limit": "33",
"reviewer": "Eddie Lake"
},
{
"id": 59,
"header": "Internationalization Support",
"type": "Technical content",
"status": "In Process",
"target": "23",
"limit": "26",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 60,
"header": "Backup and Recovery Procedures",
"type": "Technical content",
"status": "Done",
"target": "18",
"limit": "21",
"reviewer": "Assign reviewer"
},
{
"id": 61,
"header": "Monitoring and Alerting System",
"type": "Technical content",
"status": "In Process",
"target": "25",
"limit": "28",
"reviewer": "Daniel Park"
},
{
"id": 62,
"header": "Code Review Guidelines",
"type": "Technical content",
"status": "Done",
"target": "12",
"limit": "15",
"reviewer": "Eddie Lake"
},
{
"id": 63,
"header": "Documentation Standards",
"type": "Technical content",
"status": "In Process",
"target": "27",
"limit": "30",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 64,
"header": "Release Management Process",
"type": "Planning",
"status": "Done",
"target": "22",
"limit": "25",
"reviewer": "Assign reviewer"
},
{
"id": 65,
"header": "Feature Prioritization Matrix",
"type": "Planning",
"status": "In Process",
"target": "19",
"limit": "22",
"reviewer": "Emma Davis"
},
{
"id": 66,
"header": "Technical Debt Assessment",
"type": "Technical content",
"status": "Done",
"target": "24",
"limit": "27",
"reviewer": "Eddie Lake"
},
{
"id": 67,
"header": "Capacity Planning",
"type": "Planning",
"status": "In Process",
"target": "21",
"limit": "24",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 68,
"header": "Service Level Agreements",
"type": "Legal",
"status": "Done",
"target": "26",
"limit": "29",
"reviewer": "Assign reviewer"
}
]

View File

@ -0,0 +1,35 @@
import { DashboardHeader } from '@/components/dashboard/dashboard-header';
import { getTranslations } from 'next-intl/server';
interface UsersLayoutProps {
children: React.ReactNode;
}
export default async function UsersLayout({ children }: UsersLayoutProps) {
const t = await getTranslations('Dashboard.admin');
const breadcrumbs = [
{
label: t('title'),
isCurrentPage: false,
},
{
label: t('users.title'),
isCurrentPage: true,
},
];
return (
<>
<DashboardHeader breadcrumbs={breadcrumbs} />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
{children}
</div>
</div>
</div>
</>
);
}

View File

@ -0,0 +1,5 @@
import { Loader2Icon } from 'lucide-react';
export default function Loading() {
return <Loader2Icon className="my-32 mx-auto size-6 animate-spin" />;
}

View File

@ -1,46 +1,11 @@
import { ChartAreaInteractive } from '@/components/dashboard/chart-area-interactive';
import { DashboardHeader } from '@/components/dashboard/dashboard-header';
import { DataTable } from '@/components/dashboard/data-table';
import { SectionCards } from '@/components/dashboard/section-cards';
import { useTranslations } from 'next-intl';
import data from './data.json';
import { UsersPageClient } from '@/components/admin/users-page';
/**
* Admin users page
* Users page
*
* NOTICE: This is a demo page for the admin, no real data is used,
* we will show real data in the future
* This page is used to manage users for the admin,
* it is protected and only accessible to the admin role
*/
export default function AdminUsersPage() {
const t = useTranslations();
const breadcrumbs = [
{
label: t('Dashboard.admin.title'),
isCurrentPage: false,
},
{
label: t('Dashboard.admin.users.title'),
isCurrentPage: true,
},
];
return (
<>
<DashboardHeader breadcrumbs={breadcrumbs} />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
<SectionCards />
<div className="px-4 lg:px-6">
<ChartAreaInteractive />
</div>
<DataTable data={data} />
</div>
</div>
</div>
</>
);
export default function UsersPage() {
return <UsersPageClient />;
}

View File

@ -0,0 +1,293 @@
import { UserAvatar } from '@/components/layout/user-avatar';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from '@/components/ui/drawer';
import { Label } from '@/components/ui/label';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Separator } from '@/components/ui/separator';
import { Textarea } from '@/components/ui/textarea';
import { useIsMobile } from '@/hooks/use-mobile';
import { authClient } from '@/lib/auth-client';
import type { User } from '@/lib/auth-types';
import { formatDate } from '@/lib/formatter';
import { cn } from '@/lib/utils';
import { useUsersStore } from '@/stores/users-store';
import {
CalendarIcon,
Loader2Icon,
MailCheckIcon,
MailQuestionIcon,
UserRoundCheckIcon,
UserRoundXIcon,
} from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { toast } from 'sonner';
interface UserDetailViewerProps {
user: User;
}
export function UserDetailViewer({ user }: 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(t('ban.defaultReason'));
const [banExpiresAt, setBanExpiresAt] = useState<Date | undefined>();
const triggerRefresh = useUsersStore((state) => state.triggerRefresh);
const handleBan = async () => {
if (!banReason) {
setError(t('ban.error'));
return;
}
if (!user.id) {
setError('User ID is required');
return;
}
setIsLoading(true);
setError('');
try {
await authClient.admin.banUser({
userId: user.id,
banReason,
banExpiresIn: banExpiresAt
? Math.floor((banExpiresAt.getTime() - Date.now()) / 1000)
: undefined,
});
toast.success(t('ban.success'));
// Reset form
setBanReason('');
setBanExpiresAt(undefined);
// Trigger refresh
triggerRefresh();
} catch (err) {
const error = err as Error;
console.error('Failed to ban user:', error);
setError(error.message || t('ban.error'));
toast.error(error.message || t('ban.error'));
} finally {
setIsLoading(false);
}
};
const handleUnban = async () => {
if (!user.id) {
setError('User ID is required');
return;
}
setIsLoading(true);
setError('');
try {
await authClient.admin.unbanUser({
userId: user.id,
});
toast.success(t('unban.success'));
// Trigger refresh
triggerRefresh();
} catch (err) {
const error = err as Error;
console.error('Failed to unban user:', error);
setError(error.message || t('unban.error'));
toast.error(error.message || t('unban.error'));
} finally {
setIsLoading(false);
}
};
return (
<Drawer direction={isMobile ? 'bottom' : 'right'}>
<DrawerTrigger asChild>
<Button
variant="link"
className="cursor-pointer 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 className="hover:underline hover:underline-offset-4">
{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">
{/* role */}
<Badge
variant={user.role === 'admin' ? 'default' : 'outline'}
className="px-1.5"
>
{t(user.role === 'admin' ? 'admin' : 'user')}
</Badge>
{/* email verified */}
<Badge variant="outline" className="px-1.5 hover:bg-accent">
{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>
{/* user banned */}
<div className="flex items-center gap-2">
<Badge variant="outline" className="px-1.5 hover:bg-accent">
{user.banned ? (
<UserRoundXIcon className="stroke-red-500 dark:stroke-red-400" />
) : (
<UserRoundCheckIcon className="stroke-green-500 dark:stroke-green-400" />
)}
{user.banned ? t('banned') : t('active')}
</Badge>
</div>
</div>
{/* information */}
<div className="text-muted-foreground">
{t('joined')}: {formatDate(user.createdAt)}
</div>
<div className="text-muted-foreground">
{t('updated')}: {formatDate(user.updatedAt)}
</div>
</div>
<Separator />
{/* error */}
{error && <div className="text-sm text-destructive">{error}</div>}
{/* ban or unban user */}
{user.banned ? (
<div className="grid gap-4">
<div className="text-muted-foreground">
{t('ban.reason')}: {user.banReason}
</div>
<div className="text-muted-foreground">
{t('ban.expires')}:{' '}
{(user.banExpires && formatDate(user.banExpires)) ||
t('ban.never')}
</div>
<Button
variant="destructive"
onClick={handleUnban}
disabled={isLoading}
className="mt-4 cursor-pointer"
>
{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>{t('ban.expires')}</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
'justify-start text-left font-normal cursor-pointer',
!banExpiresAt && 'text-muted-foreground'
)}
>
<CalendarIcon />
{banExpiresAt ? (
formatDate(banExpiresAt)
) : (
<span>{t('ban.selectDate')}</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={banExpiresAt}
onSelect={setBanExpiresAt}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
<Button
type="submit"
variant="destructive"
disabled={isLoading || !banReason}
className="mt-4 cursor-pointer"
>
{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

@ -0,0 +1,72 @@
'use client';
import { getUsersAction } from '@/actions/get-users';
import { UsersTable } from '@/components/admin/users-table';
import type { User } from '@/lib/auth-types';
import { useUsersStore } from '@/stores/users-store';
import type { SortingState } from '@tanstack/react-table';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
export function UsersPageClient() {
const t = useTranslations('Dashboard.admin.users');
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const [search, setSearch] = useState('');
const [data, setData] = useState<User[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [sorting, setSorting] = useState<SortingState>([]);
const refreshTrigger = useUsersStore((state) => state.refreshTrigger);
useEffect(() => {
const fetchUsers = async () => {
try {
setLoading(true);
const result = await getUsersAction({
pageIndex,
pageSize,
search,
sorting,
});
if (result?.data?.success) {
setData(result.data.data?.items || []);
setTotal(result.data.data?.total || 0);
} else {
const errorMessage = result?.data?.error || t('error');
toast.error(errorMessage);
setData([]);
setTotal(0);
}
} catch (error) {
console.error('Failed to fetch users:', error);
toast.error(t('error'));
setData([]);
setTotal(0);
} finally {
setLoading(false);
}
};
fetchUsers();
}, [pageIndex, pageSize, search, sorting, refreshTrigger]);
return (
<>
<UsersTable
data={data}
total={total}
pageIndex={pageIndex}
pageSize={pageSize}
search={search}
loading={loading}
onSearch={setSearch}
onPageChange={setPageIndex}
onPageSizeChange={setPageSize}
onSortingChange={setSorting}
/>
</>
);
}

View File

@ -0,0 +1,512 @@
'use client';
import { UserDetailViewer } from '@/components/admin/user-detail-viewer';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import type { User } from '@/lib/auth-types';
import { formatDate } from '@/lib/formatter';
import { getStripeDashboardCustomerUrl } from '@/lib/urls/urls';
import {
type ColumnDef,
type ColumnFiltersState,
type SortingState,
type VisibilityState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table';
import {
ArrowUpDownIcon,
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
ChevronsLeftIcon,
ChevronsRightIcon,
MailCheckIcon,
MailQuestionIcon,
UserRoundCheckIcon,
UserRoundXIcon,
} from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { toast } from 'sonner';
import { Badge } from '../ui/badge';
import { Label } from '../ui/label';
interface DataTableColumnHeaderProps<TData, TValue>
extends React.HTMLAttributes<HTMLDivElement> {
column: any;
title: string;
}
function DataTableColumnHeader<TData, TValue>({
column,
title,
className,
}: DataTableColumnHeaderProps<TData, TValue>) {
if (!column.getCanSort()) {
return <div className={className}>{title}</div>;
}
return (
<div className={className}>
<Button
variant="ghost"
className="cursor-pointer flex items-center gap-2"
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
>
{title}
<ArrowUpDownIcon className="h-4 w-4" />
</Button>
</div>
);
}
interface UsersTableProps {
data: User[];
total: number;
pageIndex: number;
pageSize: number;
search: string;
loading?: boolean;
onSearch: (search: string) => void;
onPageChange: (page: number) => void;
onPageSizeChange: (size: number) => void;
onSortingChange?: (sorting: SortingState) => void;
}
/**
* https://ui.shadcn.com/docs/components/data-table
*/
export function UsersTable({
data,
total,
pageIndex,
pageSize,
search,
loading,
onSearch,
onPageChange,
onPageSizeChange,
onSortingChange,
}: UsersTableProps) {
const t = useTranslations('Dashboard.admin.users');
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
// Map column IDs to translation keys
const columnIdToTranslationKey = {
name: 'columns.name' as const,
email: 'columns.email' as const,
role: 'columns.role' as const,
createdAt: 'columns.createdAt' as const,
customerId: 'columns.customerId' as const,
banned: 'columns.status' as const,
banReason: 'columns.banReason' as const,
banExpires: 'columns.banExpires' as const,
} as const;
// Table columns definition
const columns: ColumnDef<User>[] = [
{
accessorKey: 'name',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('columns.name')} />
),
cell: ({ row }) => {
const user = row.original;
return <UserDetailViewer user={user} />;
},
},
{
accessorKey: 'email',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('columns.email')} />
),
cell: ({ row }) => {
const user = row.original;
return (
<div className="flex items-center gap-2 pl-3">
<Badge
variant="outline"
className="text-sm px-1.5 cursor-pointer hover:bg-accent"
onClick={() => {
navigator.clipboard.writeText(user.email);
toast.success(t('emailCopied'));
}}
>
{user.emailVerified ? (
<MailCheckIcon className="stroke-green-500 dark:stroke-green-400" />
) : (
<MailQuestionIcon className="stroke-red-500 dark:stroke-red-400" />
)}
{user.email}
</Badge>
</div>
);
},
},
{
accessorKey: 'role',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('columns.role')} />
),
cell: ({ row }) => {
const user = row.original;
const role = user.role || 'user';
return (
<div className="flex items-center gap-2 pl-3">
<Badge
variant={role === 'admin' ? 'default' : 'outline'}
className="px-1.5"
>
{t(role === 'admin' ? 'admin' : 'user')}
</Badge>
</div>
);
},
},
{
accessorKey: 'createdAt',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('columns.createdAt')} />
),
cell: ({ row }) => {
const user = row.original;
return (
<div className="flex items-center gap-2 pl-3">
{formatDate(user.createdAt)}
</div>
);
},
},
{
accessorKey: 'customerId',
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={t('columns.customerId')}
/>
),
cell: ({ row }) => {
const user = row.original;
return (
<div className="flex items-center gap-2 pl-3">
{user.customerId ? (
<a
href={getStripeDashboardCustomerUrl(user.customerId)}
target="_blank"
rel="noopener noreferrer"
className="hover:underline hover:underline-offset-4"
>
{user.customerId}
</a>
) : (
'-'
)}
</div>
);
},
},
{
accessorKey: 'banned',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('columns.status')} />
),
cell: ({ row }) => {
const user = row.original;
return (
<div className="flex items-center gap-2 pl-3">
<Badge variant="outline" className="px-1.5 hover:bg-accent">
{user.banned ? (
<UserRoundXIcon className="stroke-red-500 dark:stroke-red-400" />
) : (
<UserRoundCheckIcon className="stroke-green-500 dark:stroke-green-400" />
)}
{user.banned ? t('banned') : t('active')}
</Badge>
</div>
);
},
},
{
accessorKey: 'banReason',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('columns.banReason')} />
),
cell: ({ row }) => {
const user = row.original;
return (
<div className="flex items-center gap-2 pl-3">
{user.banReason || '-'}
</div>
);
},
},
{
accessorKey: 'banExpires',
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={t('columns.banExpires')}
/>
),
cell: ({ row }) => {
const user = row.original;
return (
<div className="flex items-center gap-2 pl-3">
{user.banExpires ? formatDate(user.banExpires) : '-'}
</div>
);
},
},
];
const table = useReactTable({
data,
columns,
pageCount: Math.ceil(total / pageSize),
state: {
sorting,
columnFilters,
columnVisibility,
pagination: { pageIndex, pageSize },
},
onSortingChange: (updater) => {
const next = typeof updater === 'function' ? updater(sorting) : updater;
setSorting(next);
onSortingChange?.(next);
},
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange: (updater) => {
const next =
typeof updater === 'function'
? updater({ pageIndex, pageSize })
: updater;
if (next.pageIndex !== pageIndex) onPageChange(next.pageIndex);
if (next.pageSize !== pageSize) onPageSizeChange(next.pageSize);
},
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
manualPagination: true,
manualSorting: true,
});
return (
<div className="w-full flex-col justify-start gap-6 space-y-4">
<div className="flex items-center justify-between px-4 lg:px-6 gap-4">
<Input
placeholder={t('search')}
value={search}
onChange={(event) => {
onSearch(event.target.value);
onPageChange(0);
}}
className="max-w-sm"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="cursor-pointer">
{/* <IconLayoutColumns /> */}
<span className="inline">{t('columns.columns')}</span>
<ChevronDownIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize cursor-pointer"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(!!value)
}
>
{t(
columnIdToTranslationKey[
column.id as keyof typeof columnIdToTranslationKey
] || 'columns.columns'
)}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="relative flex flex-col gap-4 overflow-auto px-4 lg:px-6">
<div className="overflow-hidden rounded-lg border">
<Table>
<TableHeader className="bg-muted sticky top-0 z-10">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
{t('loading')}
</TableCell>
</TableRow>
) : table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="py-4">
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
{t('noResults')}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between px-4">
<div className="text-muted-foreground hidden flex-1 text-sm lg:flex">
{/* empty here for now */}
</div>
<div className="flex w-full items-center gap-8 lg:w-fit">
<div className="hidden items-center gap-2 lg:flex">
<Label htmlFor="rows-per-page" className="text-sm font-medium">
{t('rowsPerPage')}
</Label>
<Select
value={`${pageSize}`}
onValueChange={(value) => {
onPageSizeChange(Number(value));
onPageChange(0);
}}
>
<SelectTrigger
size="sm"
className="w-20 cursor-pointer"
id="rows-per-page"
>
<SelectValue placeholder={pageSize} />
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-fit items-center justify-center text-sm font-medium">
{t('page')} {pageIndex + 1} {' / '}
{Math.max(1, Math.ceil(total / pageSize))}
</div>
<div className="ml-auto flex items-center gap-2 lg:ml-0">
<Button
variant="outline"
className="cursor-pointer hidden h-8 w-8 p-0 lg:flex"
onClick={() => onPageChange(0)}
disabled={pageIndex === 0}
>
<span className="sr-only">{t('firstPage')}</span>
<ChevronsLeftIcon />
</Button>
<Button
variant="outline"
className="cursor-pointer size-8"
size="icon"
onClick={() => onPageChange(pageIndex - 1)}
disabled={pageIndex === 0}
>
<span className="sr-only">{t('previousPage')}</span>
<ChevronLeftIcon />
</Button>
<Button
variant="outline"
className="cursor-pointer size-8"
size="icon"
onClick={() => onPageChange(pageIndex + 1)}
disabled={pageIndex + 1 >= Math.ceil(total / pageSize)}
>
<span className="sr-only">{t('nextPage')}</span>
<ChevronRightIcon />
</Button>
<Button
variant="outline"
className="cursor-pointer hidden size-8 lg:flex"
size="icon"
onClick={() =>
onPageChange(Math.max(0, Math.ceil(total / pageSize) - 1))
}
disabled={pageIndex + 1 >= Math.ceil(total / pageSize)}
>
<span className="sr-only">{t('lastPage')}</span>
<ChevronsRightIcon />
</Button>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -60,15 +60,11 @@ function Calendar({
...classNames,
}}
components={{
PreviousMonthButton: (props) => (
<button {...props}>
<ChevronLeft className={cn("size-4", props.className)} />
</button>
IconLeft: ({ className, ...props }) => (
<ChevronLeft className={cn("size-4", className)} {...props} />
),
NextMonthButton: (props) => (
<button {...props}>
<ChevronRight className={cn("size-4", props.className)} />
</button>
IconRight: ({ className, ...props }) => (
<ChevronRight className={cn("size-4", className)} {...props} />
),
}}
{...props}

View File

@ -2,3 +2,5 @@ import type { auth } from './auth';
// https://www.better-auth.com/docs/concepts/typescript#additional-fields
export type Session = typeof auth.$Infer.Session;
export type User = typeof auth.$Infer.Session.user;

View File

@ -108,8 +108,12 @@ export const auth = betterAuth({
},
user: {
// https://www.better-auth.com/docs/concepts/database#extending-core-schema
// additionalFields: {
// },
additionalFields: {
customerId: {
type: 'string',
required: false,
},
},
// https://www.better-auth.com/docs/concepts/users-accounts#delete-user
deleteUser: {
enabled: true,
@ -142,7 +146,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

View File

@ -78,3 +78,15 @@ export function getUrlWithLocaleInCallbackUrl(
return url;
}
}
/**
* Get the Stripe dashboard customer URL
* @param customerId - The Stripe customer ID
* @returns The Stripe dashboard customer URL
*/
export function getStripeDashboardCustomerUrl(customerId: string): string {
if (process.env.NODE_ENV === 'development') {
return `https://dashboard.stripe.com/test/customers/${customerId}`;
}
return `https://dashboard.stripe.com/customers/${customerId}`;
}

12
src/stores/users-store.ts Normal file
View File

@ -0,0 +1,12 @@
import { create } from 'zustand';
interface UsersState {
refreshTrigger: number;
triggerRefresh: () => void;
}
export const useUsersStore = create<UsersState>((set) => ({
refreshTrigger: 0,
triggerRefresh: () =>
set((state) => ({ refreshTrigger: state.refreshTrigger + 1 })),
}));