init
This commit is contained in:
27
src/features/users/api/mutations.ts
Normal file
27
src/features/users/api/mutations.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
17
src/features/users/api/queries.ts
Normal file
17
src/features/users/api/queries.ts
Normal 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)
|
||||
});
|
||||
36
src/features/users/api/service.ts
Normal file
36
src/features/users/api/service.ts
Normal 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'
|
||||
});
|
||||
}
|
||||
65
src/features/users/api/types.ts
Normal file
65
src/features/users/api/types.ts
Normal 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';
|
||||
}>;
|
||||
};
|
||||
383
src/features/users/components/user-form-sheet.tsx
Normal file
383
src/features/users/components/user-form-sheet.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
31
src/features/users/components/user-listing.tsx
Normal file
31
src/features/users/components/user-listing.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
src/features/users/components/users-table/cell-action.tsx
Normal file
70
src/features/users/components/users-table/cell-action.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
74
src/features/users/components/users-table/columns.tsx
Normal file
74
src/features/users/components/users-table/columns.tsx
Normal 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} />
|
||||
}
|
||||
];
|
||||
61
src/features/users/components/users-table/index.tsx
Normal file
61
src/features/users/components/users-table/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
src/features/users/components/users-table/options.tsx
Normal file
5
src/features/users/components/users-table/options.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
export const ROLE_OPTIONS = [
|
||||
{ value: 'admin', label: 'Organization Admin' },
|
||||
{ value: 'user', label: 'User' },
|
||||
{ value: 'super_admin', label: 'Super Admin' }
|
||||
];
|
||||
41
src/features/users/info-content.ts
Normal file
41
src/features/users/info-content.ts
Normal 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: []
|
||||
}
|
||||
]
|
||||
};
|
||||
26
src/features/users/schemas/user.ts
Normal file
26
src/features/users/schemas/user.ts
Normal 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>;
|
||||
Reference in New Issue
Block a user