feat: add authenticated settings page.

This commit is contained in:
liqupan
2026-02-02 20:12:19 +08:00
parent cb3e16cd16
commit 6c32d845a7
259 changed files with 24685 additions and 0 deletions

View File

@@ -0,0 +1,139 @@
import { useState } from 'react'
import { type Table } from '@tanstack/react-table'
import { Trash2, UserX, UserCheck, Mail } from 'lucide-react'
import { toast } from 'sonner'
import { sleep } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table'
import { type User } from '../data/schema'
import { UsersMultiDeleteDialog } from './users-multi-delete-dialog'
type DataTableBulkActionsProps<TData> = {
table: Table<TData>
}
export function DataTableBulkActions<TData>({
table,
}: DataTableBulkActionsProps<TData>) {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const selectedRows = table.getFilteredSelectedRowModel().rows
const handleBulkStatusChange = (status: 'active' | 'inactive') => {
const selectedUsers = selectedRows.map((row) => row.original as User)
toast.promise(sleep(2000), {
loading: `${status === 'active' ? 'Activating' : 'Deactivating'} users...`,
success: () => {
table.resetRowSelection()
return `${status === 'active' ? 'Activated' : 'Deactivated'} ${selectedUsers.length} user${selectedUsers.length > 1 ? 's' : ''}`
},
error: `Error ${status === 'active' ? 'activating' : 'deactivating'} users`,
})
table.resetRowSelection()
}
const handleBulkInvite = () => {
const selectedUsers = selectedRows.map((row) => row.original as User)
toast.promise(sleep(2000), {
loading: 'Inviting users...',
success: () => {
table.resetRowSelection()
return `Invited ${selectedUsers.length} user${selectedUsers.length > 1 ? 's' : ''}`
},
error: 'Error inviting users',
})
table.resetRowSelection()
}
return (
<>
<BulkActionsToolbar table={table} entityName='user'>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='outline'
size='icon'
onClick={handleBulkInvite}
className='size-8'
aria-label='Invite selected users'
title='Invite selected users'
>
<Mail />
<span className='sr-only'>Invite selected users</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Invite selected users</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='outline'
size='icon'
onClick={() => handleBulkStatusChange('active')}
className='size-8'
aria-label='Activate selected users'
title='Activate selected users'
>
<UserCheck />
<span className='sr-only'>Activate selected users</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Activate selected users</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='outline'
size='icon'
onClick={() => handleBulkStatusChange('inactive')}
className='size-8'
aria-label='Deactivate selected users'
title='Deactivate selected users'
>
<UserX />
<span className='sr-only'>Deactivate selected users</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Deactivate selected users</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='destructive'
size='icon'
onClick={() => setShowDeleteConfirm(true)}
className='size-8'
aria-label='Delete selected users'
title='Delete selected users'
>
<Trash2 />
<span className='sr-only'>Delete selected users</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Delete selected users</p>
</TooltipContent>
</Tooltip>
</BulkActionsToolbar>
<UsersMultiDeleteDialog
table={table}
open={showDeleteConfirm}
onOpenChange={setShowDeleteConfirm}
/>
</>
)
}

View File

@@ -0,0 +1,63 @@
import { DotsHorizontalIcon } from '@radix-ui/react-icons'
import { type Row } from '@tanstack/react-table'
import { Trash2, UserPen } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { type User } from '../data/schema'
import { useUsers } from './users-provider'
type DataTableRowActionsProps = {
row: Row<User>
}
export function DataTableRowActions({ row }: DataTableRowActionsProps) {
const { setOpen, setCurrentRow } = useUsers()
return (
<>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
className='flex h-8 w-8 p-0 data-[state=open]:bg-muted'
>
<DotsHorizontalIcon className='h-4 w-4' />
<span className='sr-only'>Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-[160px]'>
<DropdownMenuItem
onClick={() => {
setCurrentRow(row.original)
setOpen('edit')
}}
>
Edit
<DropdownMenuShortcut>
<UserPen size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
setCurrentRow(row.original)
setOpen('delete')
}}
className='text-red-500!'
>
Delete
<DropdownMenuShortcut>
<Trash2 size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)
}

View File

@@ -0,0 +1,326 @@
'use client'
import { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { showSubmittedData } from '@/lib/show-submitted-data'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { PasswordInput } from '@/components/password-input'
import { SelectDropdown } from '@/components/select-dropdown'
import { roles } from '../data/data'
import { type User } from '../data/schema'
const formSchema = z
.object({
firstName: z.string().min(1, 'First Name is required.'),
lastName: z.string().min(1, 'Last Name is required.'),
username: z.string().min(1, 'Username is required.'),
phoneNumber: z.string().min(1, 'Phone number is required.'),
email: z.email({
error: (iss) => (iss.input === '' ? 'Email is required.' : undefined),
}),
password: z.string().transform((pwd) => pwd.trim()),
role: z.string().min(1, 'Role is required.'),
confirmPassword: z.string().transform((pwd) => pwd.trim()),
isEdit: z.boolean(),
})
.refine(
(data) => {
if (data.isEdit && !data.password) return true
return data.password.length > 0
},
{
message: 'Password is required.',
path: ['password'],
}
)
.refine(
({ isEdit, password }) => {
if (isEdit && !password) return true
return password.length >= 8
},
{
message: 'Password must be at least 8 characters long.',
path: ['password'],
}
)
.refine(
({ isEdit, password }) => {
if (isEdit && !password) return true
return /[a-z]/.test(password)
},
{
message: 'Password must contain at least one lowercase letter.',
path: ['password'],
}
)
.refine(
({ isEdit, password }) => {
if (isEdit && !password) return true
return /\d/.test(password)
},
{
message: 'Password must contain at least one number.',
path: ['password'],
}
)
.refine(
({ isEdit, password, confirmPassword }) => {
if (isEdit && !password) return true
return password === confirmPassword
},
{
message: "Passwords don't match.",
path: ['confirmPassword'],
}
)
type UserForm = z.infer<typeof formSchema>
type UserActionDialogProps = {
currentRow?: User
open: boolean
onOpenChange: (open: boolean) => void
}
export function UsersActionDialog({
currentRow,
open,
onOpenChange,
}: UserActionDialogProps) {
const isEdit = !!currentRow
const form = useForm<UserForm>({
resolver: zodResolver(formSchema),
defaultValues: isEdit
? {
...currentRow,
password: '',
confirmPassword: '',
isEdit,
}
: {
firstName: '',
lastName: '',
username: '',
email: '',
role: '',
phoneNumber: '',
password: '',
confirmPassword: '',
isEdit,
},
})
const onSubmit = (values: UserForm) => {
form.reset()
showSubmittedData(values)
onOpenChange(false)
}
const isPasswordTouched = !!form.formState.dirtyFields.password
return (
<Dialog
open={open}
onOpenChange={(state) => {
form.reset()
onOpenChange(state)
}}
>
<DialogContent className='sm:max-w-lg'>
<DialogHeader className='text-start'>
<DialogTitle>{isEdit ? 'Edit User' : 'Add New User'}</DialogTitle>
<DialogDescription>
{isEdit ? 'Update the user here. ' : 'Create new user here. '}
Click save when you&apos;re done.
</DialogDescription>
</DialogHeader>
<div className='h-105 w-[calc(100%+0.75rem)] overflow-y-auto py-1 pe-3'>
<Form {...form}>
<form
id='user-form'
onSubmit={form.handleSubmit(onSubmit)}
className='space-y-4 px-0.5'
>
<FormField
control={form.control}
name='firstName'
render={({ field }) => (
<FormItem className='grid grid-cols-6 items-center space-y-0 gap-x-4 gap-y-1'>
<FormLabel className='col-span-2 text-end'>
First Name
</FormLabel>
<FormControl>
<Input
placeholder='John'
className='col-span-4'
autoComplete='off'
{...field}
/>
</FormControl>
<FormMessage className='col-span-4 col-start-3' />
</FormItem>
)}
/>
<FormField
control={form.control}
name='lastName'
render={({ field }) => (
<FormItem className='grid grid-cols-6 items-center space-y-0 gap-x-4 gap-y-1'>
<FormLabel className='col-span-2 text-end'>
Last Name
</FormLabel>
<FormControl>
<Input
placeholder='Doe'
className='col-span-4'
autoComplete='off'
{...field}
/>
</FormControl>
<FormMessage className='col-span-4 col-start-3' />
</FormItem>
)}
/>
<FormField
control={form.control}
name='username'
render={({ field }) => (
<FormItem className='grid grid-cols-6 items-center space-y-0 gap-x-4 gap-y-1'>
<FormLabel className='col-span-2 text-end'>
Username
</FormLabel>
<FormControl>
<Input
placeholder='john_doe'
className='col-span-4'
{...field}
/>
</FormControl>
<FormMessage className='col-span-4 col-start-3' />
</FormItem>
)}
/>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem className='grid grid-cols-6 items-center space-y-0 gap-x-4 gap-y-1'>
<FormLabel className='col-span-2 text-end'>Email</FormLabel>
<FormControl>
<Input
placeholder='john.doe@gmail.com'
className='col-span-4'
{...field}
/>
</FormControl>
<FormMessage className='col-span-4 col-start-3' />
</FormItem>
)}
/>
<FormField
control={form.control}
name='phoneNumber'
render={({ field }) => (
<FormItem className='grid grid-cols-6 items-center space-y-0 gap-x-4 gap-y-1'>
<FormLabel className='col-span-2 text-end'>
Phone Number
</FormLabel>
<FormControl>
<Input
placeholder='+123456789'
className='col-span-4'
{...field}
/>
</FormControl>
<FormMessage className='col-span-4 col-start-3' />
</FormItem>
)}
/>
<FormField
control={form.control}
name='role'
render={({ field }) => (
<FormItem className='grid grid-cols-6 items-center space-y-0 gap-x-4 gap-y-1'>
<FormLabel className='col-span-2 text-end'>Role</FormLabel>
<SelectDropdown
defaultValue={field.value}
onValueChange={field.onChange}
placeholder='Select a role'
className='col-span-4'
items={roles.map(({ label, value }) => ({
label,
value,
}))}
/>
<FormMessage className='col-span-4 col-start-3' />
</FormItem>
)}
/>
<FormField
control={form.control}
name='password'
render={({ field }) => (
<FormItem className='grid grid-cols-6 items-center space-y-0 gap-x-4 gap-y-1'>
<FormLabel className='col-span-2 text-end'>
Password
</FormLabel>
<FormControl>
<PasswordInput
placeholder='e.g., S3cur3P@ssw0rd'
className='col-span-4'
{...field}
/>
</FormControl>
<FormMessage className='col-span-4 col-start-3' />
</FormItem>
)}
/>
<FormField
control={form.control}
name='confirmPassword'
render={({ field }) => (
<FormItem className='grid grid-cols-6 items-center space-y-0 gap-x-4 gap-y-1'>
<FormLabel className='col-span-2 text-end'>
Confirm Password
</FormLabel>
<FormControl>
<PasswordInput
disabled={!isPasswordTouched}
placeholder='e.g., S3cur3P@ssw0rd'
className='col-span-4'
{...field}
/>
</FormControl>
<FormMessage className='col-span-4 col-start-3' />
</FormItem>
)}
/>
</form>
</Form>
</div>
<DialogFooter>
<Button type='submit' form='user-form'>
Save changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,138 @@
import { type ColumnDef } from '@tanstack/react-table'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { DataTableColumnHeader } from '@/components/data-table'
import { LongText } from '@/components/long-text'
import { callTypes, roles } from '../data/data'
import { type User } from '../data/schema'
import { DataTableRowActions } from './data-table-row-actions'
export const usersColumns: ColumnDef<User>[] = [
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && 'indeterminate')
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label='Select all'
className='translate-y-[2px]'
/>
),
meta: {
className: cn('max-md:sticky start-0 z-10 rounded-tl-[inherit]'),
},
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label='Select row'
className='translate-y-[2px]'
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: 'username',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='Username' />
),
cell: ({ row }) => (
<LongText className='max-w-36 ps-3'>{row.getValue('username')}</LongText>
),
meta: {
className: cn(
'drop-shadow-[0_1px_2px_rgb(0_0_0_/_0.1)] dark:drop-shadow-[0_1px_2px_rgb(255_255_255_/_0.1)]',
'ps-0.5 max-md:sticky start-6 @4xl/content:table-cell @4xl/content:drop-shadow-none'
),
},
enableHiding: false,
},
{
id: 'fullName',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='Name' />
),
cell: ({ row }) => {
const { firstName, lastName } = row.original
const fullName = `${firstName} ${lastName}`
return <LongText className='max-w-36'>{fullName}</LongText>
},
meta: { className: 'w-36' },
},
{
accessorKey: 'email',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='Email' />
),
cell: ({ row }) => (
<div className='w-fit ps-2 text-nowrap'>{row.getValue('email')}</div>
),
},
{
accessorKey: 'phoneNumber',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='Phone Number' />
),
cell: ({ row }) => <div>{row.getValue('phoneNumber')}</div>,
enableSorting: false,
},
{
accessorKey: 'status',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='Status' />
),
cell: ({ row }) => {
const { status } = row.original
const badgeColor = callTypes.get(status)
return (
<div className='flex space-x-2'>
<Badge variant='outline' className={cn('capitalize', badgeColor)}>
{row.getValue('status')}
</Badge>
</div>
)
},
filterFn: (row, id, value) => {
return value.includes(row.getValue(id))
},
enableHiding: false,
enableSorting: false,
},
{
accessorKey: 'role',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='Role' />
),
cell: ({ row }) => {
const { role } = row.original
const userType = roles.find(({ value }) => value === role)
if (!userType) {
return null
}
return (
<div className='flex items-center gap-x-2'>
{userType.icon && (
<userType.icon size={16} className='text-muted-foreground' />
)}
<span className='text-sm capitalize'>{row.getValue('role')}</span>
</div>
)
},
filterFn: (row, id, value) => {
return value.includes(row.getValue(id))
},
enableSorting: false,
enableHiding: false,
},
{
id: 'actions',
cell: DataTableRowActions,
},
]

View File

@@ -0,0 +1,81 @@
'use client'
import { useState } from 'react'
import { AlertTriangle } from 'lucide-react'
import { showSubmittedData } from '@/lib/show-submitted-data'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { type User } from '../data/schema'
type UserDeleteDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
currentRow: User
}
export function UsersDeleteDialog({
open,
onOpenChange,
currentRow,
}: UserDeleteDialogProps) {
const [value, setValue] = useState('')
const handleDelete = () => {
if (value.trim() !== currentRow.username) return
onOpenChange(false)
showSubmittedData(currentRow, 'The following user has been deleted:')
}
return (
<ConfirmDialog
open={open}
onOpenChange={onOpenChange}
handleConfirm={handleDelete}
disabled={value.trim() !== currentRow.username}
title={
<span className='text-destructive'>
<AlertTriangle
className='me-1 inline-block stroke-destructive'
size={18}
/>{' '}
Delete User
</span>
}
desc={
<div className='space-y-4'>
<p className='mb-2'>
Are you sure you want to delete{' '}
<span className='font-bold'>{currentRow.username}</span>?
<br />
This action will permanently remove the user with the role of{' '}
<span className='font-bold'>
{currentRow.role.toUpperCase()}
</span>{' '}
from the system. This cannot be undone.
</p>
<Label className='my-2'>
Username:
<Input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder='Enter username to confirm deletion.'
/>
</Label>
<Alert variant='destructive'>
<AlertTitle>Warning!</AlertTitle>
<AlertDescription>
Please be careful, this operation can not be rolled back.
</AlertDescription>
</Alert>
</div>
}
confirmText='Delete'
destructive
/>
)
}

View File

@@ -0,0 +1,51 @@
import { UsersActionDialog } from './users-action-dialog'
import { UsersDeleteDialog } from './users-delete-dialog'
import { UsersInviteDialog } from './users-invite-dialog'
import { useUsers } from './users-provider'
export function UsersDialogs() {
const { open, setOpen, currentRow, setCurrentRow } = useUsers()
return (
<>
<UsersActionDialog
key='user-add'
open={open === 'add'}
onOpenChange={() => setOpen('add')}
/>
<UsersInviteDialog
key='user-invite'
open={open === 'invite'}
onOpenChange={() => setOpen('invite')}
/>
{currentRow && (
<>
<UsersActionDialog
key={`user-edit-${currentRow.id}`}
open={open === 'edit'}
onOpenChange={() => {
setOpen('edit')
setTimeout(() => {
setCurrentRow(null)
}, 500)
}}
currentRow={currentRow}
/>
<UsersDeleteDialog
key={`user-delete-${currentRow.id}`}
open={open === 'delete'}
onOpenChange={() => {
setOpen('delete')
setTimeout(() => {
setCurrentRow(null)
}, 500)
}}
currentRow={currentRow}
/>
</>
)}
</>
)
}

View File

@@ -0,0 +1,150 @@
import { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { MailPlus, Send } from 'lucide-react'
import { showSubmittedData } from '@/lib/show-submitted-data'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { SelectDropdown } from '@/components/select-dropdown'
import { roles } from '../data/data'
const formSchema = z.object({
email: z.email({
error: (iss) =>
iss.input === '' ? 'Please enter an email to invite.' : undefined,
}),
role: z.string().min(1, 'Role is required.'),
desc: z.string().optional(),
})
type UserInviteForm = z.infer<typeof formSchema>
type UserInviteDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
}
export function UsersInviteDialog({
open,
onOpenChange,
}: UserInviteDialogProps) {
const form = useForm<UserInviteForm>({
resolver: zodResolver(formSchema),
defaultValues: { email: '', role: '', desc: '' },
})
const onSubmit = (values: UserInviteForm) => {
form.reset()
showSubmittedData(values)
onOpenChange(false)
}
return (
<Dialog
open={open}
onOpenChange={(state) => {
form.reset()
onOpenChange(state)
}}
>
<DialogContent className='sm:max-w-md'>
<DialogHeader className='text-start'>
<DialogTitle className='flex items-center gap-2'>
<MailPlus /> Invite User
</DialogTitle>
<DialogDescription>
Invite new user to join your team by sending them an email
invitation. Assign a role to define their access level.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
id='user-invite-form'
onSubmit={form.handleSubmit(onSubmit)}
className='space-y-4'
>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type='email'
placeholder='eg: john.doe@gmail.com'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='role'
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<SelectDropdown
defaultValue={field.value}
onValueChange={field.onChange}
placeholder='Select a role'
items={roles.map(({ label, value }) => ({
label,
value,
}))}
/>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='desc'
render={({ field }) => (
<FormItem className=''>
<FormLabel>Description (optional)</FormLabel>
<FormControl>
<Textarea
className='resize-none'
placeholder='Add a personal note to your invitation (optional)'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
<DialogFooter className='gap-y-2'>
<DialogClose asChild>
<Button variant='outline'>Cancel</Button>
</DialogClose>
<Button type='submit' form='user-invite-form'>
Invite <Send />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,95 @@
'use client'
import { useState } from 'react'
import { type Table } from '@tanstack/react-table'
import { AlertTriangle } from 'lucide-react'
import { toast } from 'sonner'
import { sleep } from '@/lib/utils'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ConfirmDialog } from '@/components/confirm-dialog'
type UserMultiDeleteDialogProps<TData> = {
open: boolean
onOpenChange: (open: boolean) => void
table: Table<TData>
}
const CONFIRM_WORD = 'DELETE'
export function UsersMultiDeleteDialog<TData>({
open,
onOpenChange,
table,
}: UserMultiDeleteDialogProps<TData>) {
const [value, setValue] = useState('')
const selectedRows = table.getFilteredSelectedRowModel().rows
const handleDelete = () => {
if (value.trim() !== CONFIRM_WORD) {
toast.error(`Please type "${CONFIRM_WORD}" to confirm.`)
return
}
onOpenChange(false)
toast.promise(sleep(2000), {
loading: 'Deleting users...',
success: () => {
setValue('')
table.resetRowSelection()
return `Deleted ${selectedRows.length} ${
selectedRows.length > 1 ? 'users' : 'user'
}`
},
error: 'Error',
})
}
return (
<ConfirmDialog
open={open}
onOpenChange={onOpenChange}
handleConfirm={handleDelete}
disabled={value.trim() !== CONFIRM_WORD}
title={
<span className='text-destructive'>
<AlertTriangle
className='me-1 inline-block stroke-destructive'
size={18}
/>{' '}
Delete {selectedRows.length}{' '}
{selectedRows.length > 1 ? 'users' : 'user'}
</span>
}
desc={
<div className='space-y-4'>
<p className='mb-2'>
Are you sure you want to delete the selected users? <br />
This action cannot be undone.
</p>
<Label className='my-4 flex flex-col items-start gap-1.5'>
<span className=''>Confirm by typing "{CONFIRM_WORD}":</span>
<Input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={`Type "${CONFIRM_WORD}" to confirm.`}
/>
</Label>
<Alert variant='destructive'>
<AlertTitle>Warning!</AlertTitle>
<AlertDescription>
Please be careful, this operation can not be rolled back.
</AlertDescription>
</Alert>
</div>
}
confirmText='Delete'
destructive
/>
)
}

View File

@@ -0,0 +1,21 @@
import { MailPlus, UserPlus } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useUsers } from './users-provider'
export function UsersPrimaryButtons() {
const { setOpen } = useUsers()
return (
<div className='flex gap-2'>
<Button
variant='outline'
className='space-x-1'
onClick={() => setOpen('invite')}
>
<span>Invite User</span> <MailPlus size={18} />
</Button>
<Button className='space-x-1' onClick={() => setOpen('add')}>
<span>Add User</span> <UserPlus size={18} />
</Button>
</div>
)
}

View File

@@ -0,0 +1,36 @@
import React, { useState } from 'react'
import useDialogState from '@/hooks/use-dialog-state'
import { type User } from '../data/schema'
type UsersDialogType = 'invite' | 'add' | 'edit' | 'delete'
type UsersContextType = {
open: UsersDialogType | null
setOpen: (str: UsersDialogType | null) => void
currentRow: User | null
setCurrentRow: React.Dispatch<React.SetStateAction<User | null>>
}
const UsersContext = React.createContext<UsersContextType | null>(null)
export function UsersProvider({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useDialogState<UsersDialogType>(null)
const [currentRow, setCurrentRow] = useState<User | null>(null)
return (
<UsersContext value={{ open, setOpen, currentRow, setCurrentRow }}>
{children}
</UsersContext>
)
}
// eslint-disable-next-line react-refresh/only-export-components
export const useUsers = () => {
const usersContext = React.useContext(UsersContext)
if (!usersContext) {
throw new Error('useUsers has to be used within <UsersContext>')
}
return usersContext
}

View File

@@ -0,0 +1,194 @@
import { useEffect, useState } from 'react'
import {
type SortingState,
type VisibilityState,
flexRender,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table'
import { cn } from '@/lib/utils'
import { type NavigateFn, useTableUrlState } from '@/hooks/use-table-url-state'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { DataTablePagination, DataTableToolbar } from '@/components/data-table'
import { roles } from '../data/data'
import { type User } from '../data/schema'
import { DataTableBulkActions } from './data-table-bulk-actions'
import { usersColumns as columns } from './users-columns'
type DataTableProps = {
data: User[]
search: Record<string, unknown>
navigate: NavigateFn
}
export function UsersTable({ data, search, navigate }: DataTableProps) {
// Local UI-only states
const [rowSelection, setRowSelection] = useState({})
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const [sorting, setSorting] = useState<SortingState>([])
// Local state management for table (uncomment to use local-only state, not synced with URL)
// const [columnFilters, onColumnFiltersChange] = useState<ColumnFiltersState>([])
// const [pagination, onPaginationChange] = useState<PaginationState>({ pageIndex: 0, pageSize: 10 })
// Synced with URL states (keys/defaults mirror users route search schema)
const {
columnFilters,
onColumnFiltersChange,
pagination,
onPaginationChange,
ensurePageInRange,
} = useTableUrlState({
search,
navigate,
pagination: { defaultPage: 1, defaultPageSize: 10 },
globalFilter: { enabled: false },
columnFilters: [
// username per-column text filter
{ columnId: 'username', searchKey: 'username', type: 'string' },
{ columnId: 'status', searchKey: 'status', type: 'array' },
{ columnId: 'role', searchKey: 'role', type: 'array' },
],
})
// eslint-disable-next-line react-hooks/incompatible-library
const table = useReactTable({
data,
columns,
state: {
sorting,
pagination,
rowSelection,
columnFilters,
columnVisibility,
},
enableRowSelection: true,
onPaginationChange,
onColumnFiltersChange,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnVisibilityChange: setColumnVisibility,
getPaginationRowModel: getPaginationRowModel(),
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
})
useEffect(() => {
ensurePageInRange(table.getPageCount())
}, [table, ensurePageInRange])
return (
<div
className={cn(
'max-sm:has-[div[role="toolbar"]]:mb-16', // Add margin bottom to the table on mobile when the toolbar is visible
'flex flex-1 flex-col gap-4'
)}
>
<DataTableToolbar
table={table}
searchPlaceholder='Filter users...'
searchKey='username'
filters={[
{
columnId: 'status',
title: 'Status',
options: [
{ label: 'Active', value: 'active' },
{ label: 'Inactive', value: 'inactive' },
{ label: 'Invited', value: 'invited' },
{ label: 'Suspended', value: 'suspended' },
],
},
{
columnId: 'role',
title: 'Role',
options: roles.map((role) => ({ ...role })),
},
]}
/>
<div className='overflow-hidden rounded-md border'>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className='group/row'>
{headerGroup.headers.map((header) => {
return (
<TableHead
key={header.id}
colSpan={header.colSpan}
className={cn(
'bg-background group-hover/row:bg-muted group-data-[state=selected]/row:bg-muted',
header.column.columnDef.meta?.className,
header.column.columnDef.meta?.thClassName
)}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
className='group/row'
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={cn(
'bg-background group-hover/row:bg-muted group-data-[state=selected]/row:bg-muted',
cell.column.columnDef.meta?.className,
cell.column.columnDef.meta?.tdClassName
)}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className='h-24 text-center'
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<DataTablePagination table={table} className='mt-auto' />
<DataTableBulkActions table={table} />
</div>
)
}

View File

@@ -0,0 +1,35 @@
import { Shield, UserCheck, Users, CreditCard } from 'lucide-react'
import { type UserStatus } from './schema'
export const callTypes = new Map<UserStatus, string>([
['active', 'bg-teal-100/30 text-teal-900 dark:text-teal-200 border-teal-200'],
['inactive', 'bg-neutral-300/40 border-neutral-300'],
['invited', 'bg-sky-200/40 text-sky-900 dark:text-sky-100 border-sky-300'],
[
'suspended',
'bg-destructive/10 dark:bg-destructive/50 text-destructive dark:text-primary border-destructive/10',
],
])
export const roles = [
{
label: 'Superadmin',
value: 'superadmin',
icon: Shield,
},
{
label: 'Admin',
value: 'admin',
icon: UserCheck,
},
{
label: 'Manager',
value: 'manager',
icon: Users,
},
{
label: 'Cashier',
value: 'cashier',
icon: CreditCard,
},
] as const

View File

@@ -0,0 +1,32 @@
import { z } from 'zod'
const userStatusSchema = z.union([
z.literal('active'),
z.literal('inactive'),
z.literal('invited'),
z.literal('suspended'),
])
export type UserStatus = z.infer<typeof userStatusSchema>
const userRoleSchema = z.union([
z.literal('superadmin'),
z.literal('admin'),
z.literal('cashier'),
z.literal('manager'),
])
const userSchema = z.object({
id: z.string(),
firstName: z.string(),
lastName: z.string(),
username: z.string(),
email: z.string(),
phoneNumber: z.string(),
status: userStatusSchema,
role: userRoleSchema,
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
})
export type User = z.infer<typeof userSchema>
export const userListSchema = z.array(userSchema)

View File

@@ -0,0 +1,33 @@
import { faker } from '@faker-js/faker'
// Set a fixed seed for consistent data generation
faker.seed(67890)
export const users = Array.from({ length: 500 }, () => {
const firstName = faker.person.firstName()
const lastName = faker.person.lastName()
return {
id: faker.string.uuid(),
firstName,
lastName,
username: faker.internet
.username({ firstName, lastName })
.toLocaleLowerCase(),
email: faker.internet.email({ firstName }).toLocaleLowerCase(),
phoneNumber: faker.phone.number({ style: 'international' }),
status: faker.helpers.arrayElement([
'active',
'inactive',
'invited',
'suspended',
]),
role: faker.helpers.arrayElement([
'superadmin',
'admin',
'cashier',
'manager',
]),
createdAt: faker.date.past(),
updatedAt: faker.date.recent(),
}
})

View File

@@ -0,0 +1,47 @@
import { getRouteApi } from '@tanstack/react-router'
import { ConfigDrawer } from '@/components/config-drawer'
import { Header } from '@/components/layout/header'
import { Main } from '@/components/layout/main'
import { ProfileDropdown } from '@/components/profile-dropdown'
import { Search } from '@/components/search'
import { ThemeSwitch } from '@/components/theme-switch'
import { UsersDialogs } from './components/users-dialogs'
import { UsersPrimaryButtons } from './components/users-primary-buttons'
import { UsersProvider } from './components/users-provider'
import { UsersTable } from './components/users-table'
import { users } from './data/users'
const route = getRouteApi('/_authenticated/users/')
export function Users() {
const search = route.useSearch()
const navigate = route.useNavigate()
return (
<UsersProvider>
<Header fixed>
<Search />
<div className='ms-auto flex items-center space-x-4'>
<ThemeSwitch />
<ConfigDrawer />
<ProfileDropdown />
</div>
</Header>
<Main className='flex flex-1 flex-col gap-4 sm:gap-6'>
<div className='flex flex-wrap items-end justify-between gap-2'>
<div>
<h2 className='text-2xl font-bold tracking-tight'>User List</h2>
<p className='text-muted-foreground'>
Manage your users and their roles here.
</p>
</div>
<UsersPrimaryButtons />
</div>
<UsersTable data={users} search={search} navigate={navigate} />
</Main>
<UsersDialogs />
</UsersProvider>
)
}