This commit is contained in:
phaichayon
2026-06-11 12:46:57 +07:00
parent 1993d88306
commit 7a390cf0df
720 changed files with 100919 additions and 0 deletions

View File

@@ -0,0 +1,27 @@
import { mutationOptions } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/query-client';
import { createUser, updateUser, deleteUser } from './service';
import { userKeys } from './queries';
import type { UserMutationPayload } from './types';
export const createUserMutation = mutationOptions({
mutationFn: (data: UserMutationPayload) => createUser(data),
onSuccess: () => {
getQueryClient().invalidateQueries({ queryKey: userKeys.all });
}
});
export const updateUserMutation = mutationOptions({
mutationFn: ({ id, values }: { id: string; values: UserMutationPayload }) =>
updateUser(id, values),
onSuccess: () => {
getQueryClient().invalidateQueries({ queryKey: userKeys.all });
}
});
export const deleteUserMutation = mutationOptions({
mutationFn: (id: string) => deleteUser(id),
onSuccess: () => {
getQueryClient().invalidateQueries({ queryKey: userKeys.all });
}
});

View File

@@ -0,0 +1,17 @@
import { queryOptions } from '@tanstack/react-query';
import { getUsers } from './service';
import type { User, UserFilters } from './types';
export type { User };
export const userKeys = {
all: ['users'] as const,
list: (filters: UserFilters) => [...userKeys.all, 'list', filters] as const,
detail: (id: string) => [...userKeys.all, 'detail', id] as const
};
export const usersQueryOptions = (filters: UserFilters) =>
queryOptions({
queryKey: userKeys.list(filters),
queryFn: () => getUsers(filters)
});

View File

@@ -0,0 +1,36 @@
import { apiClient } from '@/lib/api-client';
import type { UserFilters, UsersResponse, UserMutationPayload } from './types';
export async function getUsers(filters: UserFilters): Promise<UsersResponse> {
const searchParams = new URLSearchParams();
if (filters.page) searchParams.set('page', String(filters.page));
if (filters.limit) searchParams.set('limit', String(filters.limit));
if (filters.roles) searchParams.set('roles', filters.roles);
if (filters.search) searchParams.set('search', filters.search);
if (filters.sort) searchParams.set('sort', filters.sort);
const query = searchParams.toString();
return apiClient<UsersResponse>(`/users${query ? `?${query}` : ''}`);
}
export async function createUser(data: UserMutationPayload) {
return apiClient<{ success: boolean; message: string }>('/users', {
method: 'POST',
body: JSON.stringify(data)
});
}
export async function updateUser(id: string, data: UserMutationPayload) {
return apiClient<{ success: boolean; message: string }>(`/users/${id}`, {
method: 'PUT',
body: JSON.stringify(data)
});
}
export async function deleteUser(id: string) {
return apiClient<{ success: boolean; message: string }>(`/users/${id}`, {
method: 'DELETE'
});
}

View File

@@ -0,0 +1,65 @@
export interface UserOrganization {
id: string;
name: string;
role: 'admin' | 'user';
}
export interface UserMembership {
organizationId: string;
organizationName: string;
membershipRole: 'admin' | 'user';
businessRole:
| 'it_admin'
| 'helpdesk'
| 'infrastructure'
| 'application'
| 'auditor'
| 'viewer';
}
export interface User {
id: string;
name: string;
email: string;
systemRole: 'super_admin' | 'user';
activeMembershipRole: 'admin' | 'user' | null;
activeOrganizationId: string | null;
organizations: UserOrganization[];
memberships: UserMembership[];
}
export type UserFilters = {
page?: number;
limit?: number;
roles?: string;
search?: string;
sort?: string;
};
export type UsersResponse = {
success: boolean;
time: string;
message: string;
total_users: number;
offset: number;
limit: number;
users: User[];
};
export type UserMutationPayload = {
name: string;
email: string;
password?: string;
systemRole: 'super_admin' | 'user';
memberships: Array<{
organizationId: string;
membershipRole: 'admin' | 'user';
businessRole:
| 'it_admin'
| 'helpdesk'
| 'infrastructure'
| 'application'
| 'auditor'
| 'viewer';
}>;
};

View File

@@ -0,0 +1,383 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select';
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle
} from '@/components/ui/sheet';
import { Icons } from '@/components/icons';
import { useMutation } from '@tanstack/react-query';
import { createUserMutation, updateUserMutation } from '../api/mutations';
import type { User } from '../api/types';
import { toast } from 'sonner';
import { type UserFormValues, userSchema } from '../schemas/user';
interface UserFormSheetProps {
user?: User;
open: boolean;
onOpenChange: (open: boolean) => void;
}
type OrganizationOption = {
id: string;
name: string;
role: string;
businessRole?: string;
canManageUsers: boolean;
};
const membershipRoleOptions = [
{ value: 'admin', label: 'Organization Admin' },
{ value: 'user', label: 'User' }
] as const;
const businessRoleOptions = [
{ value: 'it_admin', label: 'IT Admin' },
{ value: 'helpdesk', label: 'Helpdesk' },
{ value: 'infrastructure', label: 'Infrastructure' },
{ value: 'application', label: 'Application' },
{ value: 'auditor', label: 'Auditor' },
{ value: 'viewer', label: 'Viewer' }
] as const;
export function UserFormSheet({ user, open, onOpenChange }: UserFormSheetProps) {
const isEdit = !!user;
const [organizations, setOrganizations] = useState<OrganizationOption[]>([]);
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false);
const createMutation = useMutation({
...createUserMutation,
onSuccess: () => {
toast.success('User created successfully');
onOpenChange(false);
form.reset();
},
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to create user')
});
const updateMutation = useMutation({
...updateUserMutation,
onSuccess: () => {
toast.success('User updated successfully');
onOpenChange(false);
},
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to update user')
});
const initialMemberships = useMemo(
() =>
user?.memberships.map((membership) => ({
organizationId: membership.organizationId,
membershipRole: membership.membershipRole,
businessRole: membership.businessRole
})) ?? [],
[user?.memberships]
);
const form = useAppForm({
defaultValues: {
name: user?.name ?? '',
email: user?.email ?? '',
password: '',
systemRole: user?.systemRole ?? 'user',
memberships: initialMemberships
} as UserFormValues,
validators: {
onSubmit: userSchema
},
onSubmit: async ({ value }) => {
if (!isEdit && (!value.password || value.password.length < 8)) {
toast.error('Password must be at least 8 characters');
return;
}
if (isEdit) {
await updateMutation.mutateAsync({ id: user.id, values: value });
} else {
await createMutation.mutateAsync({
...value,
systemRole: 'user'
});
}
}
});
const { FormTextField } = useFormFields<UserFormValues>();
useEffect(() => {
if (!open) {
return;
}
let cancelled = false;
async function loadOrganizations() {
setIsLoadingOrganizations(true);
try {
const response = await fetch('/api/organizations?scope=manageable');
const data = (await response.json()) as {
organizations?: OrganizationOption[];
message?: string;
};
if (!response.ok) {
throw new Error(data.message ?? 'Unable to load organizations');
}
if (!cancelled) {
setOrganizations(data.organizations ?? []);
}
} catch (error) {
if (!cancelled) {
toast.error(error instanceof Error ? error.message : 'Unable to load organizations');
}
} finally {
if (!cancelled) {
setIsLoadingOrganizations(false);
}
}
}
void loadOrganizations();
return () => {
cancelled = true;
};
}, [open]);
const isPending = createMutation.isPending || updateMutation.isPending;
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className='flex flex-col sm:max-w-2xl'>
<SheetHeader>
<SheetTitle>{isEdit ? 'Edit User' : 'New User'}</SheetTitle>
<SheetDescription>
{isEdit
? 'Update the user details and workspace memberships below.'
: 'Create a user account and assign workspace-specific roles.'}
</SheetDescription>
</SheetHeader>
<div className='flex-1 overflow-auto'>
<form.AppForm>
<form.Form id='user-form-sheet' className='space-y-4'>
<FormTextField name='name' label='Full Name' required placeholder='Jane Doe' />
<FormTextField
name='email'
label='Email'
required
type='email'
placeholder='jane@example.com'
/>
<FormTextField
name='password'
label={isEdit ? 'Password (optional)' : 'Password'}
required={!isEdit}
type='password'
placeholder={isEdit ? 'Leave blank to keep current password' : 'Minimum 8 characters'}
/>
{isEdit && user?.systemRole === 'super_admin' && (
<div className='rounded-lg border border-dashed p-3 text-sm'>
<span className='font-medium'>System Role:</span> Super Admin
</div>
)}
{!isEdit && (
<div className='text-muted-foreground rounded-lg border border-dashed p-3 text-sm'>
New users are created with system role <strong>user</strong>. Creating new
<strong> super_admin</strong> users is intentionally not exposed in this form.
</div>
)}
<form.AppField
name='memberships'
children={(field) => (
<field.FieldSet>
<field.Field>
<div className='text-sm font-medium'>Workspace Memberships *</div>
<div className='text-muted-foreground text-xs'>
One user can have different roles in different workspaces.
</div>
<div className='space-y-2 rounded-lg border p-3'>
{isLoadingOrganizations ? (
<div className='text-muted-foreground text-sm'>Loading workspaces...</div>
) : organizations.length ? (
organizations.map((organization) => {
const membership = field.state.value.find(
(value) => value.organizationId === organization.id
);
const checked = !!membership;
return (
<div
key={organization.id}
className='grid gap-3 rounded-md border p-3 md:grid-cols-[1fr_180px_180px]'
>
<div className='flex items-start gap-3'>
<Checkbox
aria-label={`Select workspace ${organization.name}`}
checked={checked}
onCheckedChange={(value) => {
if (!value) {
field.handleChange(
field.state.value.filter(
(item) => item.organizationId !== organization.id
)
);
field.handleBlur();
return;
}
field.handleChange([
...field.state.value,
{
organizationId: organization.id,
membershipRole: 'user',
businessRole: 'viewer'
}
]);
field.handleBlur();
}}
/>
<div className='flex flex-col'>
<span className='font-medium'>{organization.name}</span>
<span className='text-muted-foreground text-xs'>
Your role here: {organization.role}
</span>
</div>
</div>
<Select
value={membership?.membershipRole ?? 'user'}
disabled={!checked}
onValueChange={(value) => {
field.handleChange(
field.state.value.map((item) =>
item.organizationId === organization.id
? {
...item,
membershipRole: value as 'admin' | 'user'
}
: item
)
);
field.handleBlur();
}}
>
<SelectTrigger aria-label={`Role for ${organization.name}`}>
<SelectValue placeholder='Select role' />
</SelectTrigger>
<SelectContent>
{membershipRoleOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={membership?.businessRole ?? 'viewer'}
disabled={!checked}
onValueChange={(value) => {
field.handleChange(
field.state.value.map((item) =>
item.organizationId === organization.id
? {
...item,
businessRole: value as
| 'it_admin'
| 'helpdesk'
| 'infrastructure'
| 'application'
| 'auditor'
| 'viewer'
}
: item
)
);
field.handleBlur();
}}
>
<SelectTrigger
aria-label={`Business role for ${organization.name}`}
>
<SelectValue placeholder='Select business role' />
</SelectTrigger>
<SelectContent>
{businessRoleOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
})
) : (
<div className='text-muted-foreground text-sm'>
No manageable workspaces available.
</div>
)}
</div>
</field.Field>
<field.FieldError />
</field.FieldSet>
)}
/>
</form.Form>
</form.AppForm>
</div>
<SheetFooter>
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
type='submit'
form='user-form-sheet'
isLoading={isPending}
disabled={isLoadingOrganizations || organizations.length === 0}
>
<Icons.check /> {isEdit ? 'Update User' : 'Create User'}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
export function UserFormSheetTrigger() {
const [open, setOpen] = useState(false);
return (
<>
<Button onClick={() => setOpen(true)}>
<Icons.add className='mr-2 h-4 w-4' /> Add User
</Button>
<UserFormSheet open={open} onOpenChange={setOpen} />
</>
);
}

View File

@@ -0,0 +1,31 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/query-client';
import { searchParamsCache } from '@/lib/searchparams';
import { usersQueryOptions } from '../api/queries';
import { UsersTable } from './users-table';
export default function UserListingPage() {
const page = searchParamsCache.get('page');
const search = searchParamsCache.get('name');
const pageLimit = searchParamsCache.get('perPage');
const roles = searchParamsCache.get('role');
const sort = searchParamsCache.get('sort');
const filters = {
page,
limit: pageLimit,
...(search && { search }),
...(roles && { roles }),
...(sort && { sort })
};
const queryClient = getQueryClient();
void queryClient.prefetchQuery(usersQueryOptions(filters));
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<UsersTable />
</HydrationBoundary>
);
}

View File

@@ -0,0 +1,70 @@
'use client';
import { AlertModal } from '@/components/modal/alert-modal';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import { deleteUserMutation } from '../../api/mutations';
import type { User } from '../../api/types';
import { Icons } from '@/components/icons';
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { useSession } from 'next-auth/react';
import { toast } from 'sonner';
import { UserFormSheet } from '../user-form-sheet';
interface CellActionProps {
data: User;
}
export function CellAction({ data }: CellActionProps) {
const { data: session } = useSession();
const [deleteOpen, setDeleteOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const canEditSuperAdmin = session?.user?.systemRole === 'super_admin';
const isProtectedSuperAdmin = data.systemRole === 'super_admin' && !canEditSuperAdmin;
const deleteMutation = useMutation({
...deleteUserMutation,
onSuccess: () => {
toast.success('User deleted successfully');
setDeleteOpen(false);
},
onError: () => {
toast.error('Failed to delete user');
}
});
return (
<>
<AlertModal
isOpen={deleteOpen}
onClose={() => setDeleteOpen(false)}
onConfirm={() => deleteMutation.mutate(data.id)}
loading={deleteMutation.isPending}
/>
<UserFormSheet user={data} open={editOpen} onOpenChange={setEditOpen} />
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant='ghost' className='h-8 w-8 p-0'>
<span className='sr-only'>Open menu</span>
<Icons.ellipsis className='h-4 w-4' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem onClick={() => setEditOpen(true)} disabled={isProtectedSuperAdmin}>
<Icons.edit className='mr-2 h-4 w-4' /> Update
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDeleteOpen(true)} disabled={isProtectedSuperAdmin}>
<Icons.trash className='mr-2 h-4 w-4' /> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
);
}

View File

@@ -0,0 +1,74 @@
'use client';
import { Badge } from '@/components/ui/badge';
import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header';
import type { User } from '../../api/types';
import { Column, ColumnDef } from '@tanstack/react-table';
import { Icons } from '@/components/icons';
import { CellAction } from './cell-action';
import { ROLE_OPTIONS } from './options';
export const columns: ColumnDef<User>[] = [
{
accessorKey: 'name',
header: ({ column }: { column: Column<User, unknown> }) => (
<DataTableColumnHeader column={column} title='Name' />
),
cell: ({ row }) => (
<div className='flex flex-col'>
<span className='font-medium'>{row.original.name}</span>
<span className='text-muted-foreground text-xs'>{row.original.email}</span>
</div>
),
meta: {
label: 'Name',
placeholder: 'Search users...',
variant: 'text' as const,
icon: Icons.text
},
enableColumnFilter: true
},
{
id: 'role',
accessorFn: (row) => row.systemRole === 'super_admin' ? 'super_admin' : row.activeMembershipRole ?? 'user',
enableSorting: true,
header: ({ column }: { column: Column<User, unknown> }) => (
<DataTableColumnHeader column={column} title='Role' />
),
cell: ({ row }) => {
const role =
row.original.systemRole === 'super_admin' ? 'super_admin' : row.original.activeMembershipRole;
return (
<Badge variant={role === 'admin' ? 'default' : 'outline'} className='capitalize'>
{role?.replace('_', ' ') ?? 'user'}
</Badge>
);
},
enableColumnFilter: true,
meta: {
label: 'roles',
variant: 'multiSelect' as const,
options: ROLE_OPTIONS
}
},
{
id: 'organizations',
accessorFn: (row) => row.organizations.map((organization) => organization.name).join(', '),
header: ({ column }: { column: Column<User, unknown> }) => (
<DataTableColumnHeader column={column} title='Organizations' />
),
cell: ({ row }) => (
<div className='flex flex-wrap gap-1'>
{row.original.memberships.map((membership) => (
<Badge key={membership.organizationId} variant='secondary'>
{membership.organizationName} ({membership.membershipRole}, {membership.businessRole.replace('_', ' ')})
</Badge>
))}
</div>
)
},
{
id: 'actions',
cell: ({ row }) => <CellAction data={row.original} />
}
];

View File

@@ -0,0 +1,61 @@
'use client';
import { DataTable } from '@/components/ui/table/data-table';
import { DataTableToolbar } from '@/components/ui/table/data-table-toolbar';
import { useDataTable } from '@/hooks/use-data-table';
import { useSuspenseQuery } from '@tanstack/react-query';
import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs';
import { getSortingStateParser } from '@/lib/parsers';
import { usersQueryOptions } from '../../api/queries';
import { columns } from './columns';
const columnIds = columns.map((c) => c.id).filter(Boolean) as string[];
export function UsersTable() {
const [params] = useQueryStates({
page: parseAsInteger.withDefault(1),
perPage: parseAsInteger.withDefault(10),
name: parseAsString,
role: parseAsString,
sort: getSortingStateParser(columnIds).withDefault([])
});
const filters = {
page: params.page,
limit: params.perPage,
...(params.name && { search: params.name }),
...(params.role && { roles: params.role }),
...(params.sort.length > 0 && { sort: JSON.stringify(params.sort) })
};
const { data } = useSuspenseQuery(usersQueryOptions(filters));
const pageCount = Math.ceil(data.total_users / params.perPage);
const { table } = useDataTable({
data: data.users,
columns,
pageCount,
shallow: true,
debounceMs: 500,
initialState: {
columnPinning: { right: ['actions'] }
}
});
return (
<DataTable table={table}>
<DataTableToolbar table={table} />
</DataTable>
);
}
export function UsersTableSkeleton() {
return (
<div className='flex flex-1 animate-pulse flex-col gap-4'>
<div className='bg-muted h-10 w-full rounded' />
<div className='bg-muted h-96 w-full rounded-lg' />
<div className='bg-muted h-10 w-full rounded' />
</div>
);
}

View File

@@ -0,0 +1,5 @@
export const ROLE_OPTIONS = [
{ value: 'admin', label: 'Organization Admin' },
{ value: 'user', label: 'User' },
{ value: 'super_admin', label: 'Super Admin' }
];

View File

@@ -0,0 +1,41 @@
import type { InfobarContent } from '@/components/ui/infobar';
export const usersInfoContent: InfobarContent = {
title: 'Users — React Query + nuqs Pattern',
sections: [
{
title: 'Overview',
description:
'This page demonstrates client-side data fetching with React Query combined with nuqs URL search params — as an alternative to the Products page which uses server-side RSC fetching. Both patterns use the same DataTable, useDataTable hook, and nuqs URL state.',
links: [
{
title: 'TanStack Query SSR Docs',
url: 'https://tanstack.com/query/latest/docs/framework/react/guides/advanced-ssr'
}
]
},
{
title: 'Server Prefetch + Client Hydration',
description:
'The server component reads search params via searchParamsCache, builds filters, and calls queryClient.prefetchQuery(). The dehydrated state is passed to HydrationBoundary so the client starts with cached data. The client component reads the same search params via useQueryState and calls useSuspenseQuery with matching filters.',
links: []
},
{
title: 'URL State with nuqs',
description:
'Pagination, search, and role filters are synced to the URL via nuqs. The useDataTable hook manages the TanStack Table state and debounces filter changes before updating the URL. When the URL changes, React Query automatically refetches because the query key includes the filters.',
links: [
{
title: 'nuqs Documentation',
url: 'https://nuqs.47ng.com'
}
]
},
{
title: 'Products vs Users Pattern',
description:
'Products: searchParams → RSC fetch → pass data as props to client table. Users: searchParams → server prefetch → HydrationBoundary → client useSuspenseQuery. The Users pattern enables background refetching, cache sharing across components, and optimistic mutations.',
links: []
}
]
};

View File

@@ -0,0 +1,26 @@
import * as z from 'zod';
export const userSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Please enter a valid email'),
password: z.string().optional(),
systemRole: z.enum(['super_admin', 'user']),
memberships: z
.array(
z.object({
organizationId: z.string().min(1),
membershipRole: z.enum(['admin', 'user']),
businessRole: z.enum([
'it_admin',
'helpdesk',
'infrastructure',
'application',
'auditor',
'viewer'
])
})
)
.min(1, 'Select at least one organization')
});
export type UserFormValues = z.infer<typeof userSchema>;