first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

View File

@@ -0,0 +1,81 @@
import {Dialog} from '@common/ui/overlays/dialog/dialog';
import {DialogHeader} from '@common/ui/overlays/dialog/dialog-header';
import {Trans} from '@common/i18n/trans';
import {DialogBody} from '@common/ui/overlays/dialog/dialog-body';
import {DialogFooter} from '@common/ui/overlays/dialog/dialog-footer';
import {Button} from '@common/ui/buttons/button';
import {useDialogContext} from '@common/ui/overlays/dialog/dialog-context';
import {Form} from '@common/ui/forms/form';
import {useForm} from 'react-hook-form';
import {
BanUserPayload,
useBanUser,
} from '@common/admin/users/requests/use-ban-user';
import {FormDatePicker} from '@common/ui/forms/input-field/date/date-picker/date-picker';
import {User} from '@common/auth/user';
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
import {useTrans} from '@common/i18n/use-trans';
import {message} from '@common/i18n/message';
import {FormSwitch} from '@common/ui/forms/toggle/switch';
interface Props {
user: User;
}
export function BanUserDialog({user}: Props) {
const {trans} = useTrans();
const {close, formId} = useDialogContext();
const form = useForm<BanUserPayload>({
defaultValues: {
permanent: true,
},
});
const isPermanent = form.watch('permanent');
const banUser = useBanUser(form, user.id);
return (
<Dialog>
<DialogHeader>
<Trans message="Suspend “:name“" values={{name: user.display_name}} />
</DialogHeader>
<DialogBody>
<Form
id={formId}
form={form}
onSubmit={values =>
banUser.mutate(values, {onSuccess: () => close()})
}
>
<FormDatePicker
name="ban_until"
label={<Trans message="Suspend until" />}
disabled={isPermanent}
/>
<FormSwitch name="permanent" className="mt-12">
<Trans message="Permanent" />
</FormSwitch>
<FormTextField
className="mt-24"
name="comment"
inputElementType="textarea"
maxLength={250}
label={<Trans message="Reason" />}
placeholder={trans(message('Optional'))}
/>
</Form>
</DialogBody>
<DialogFooter>
<Button onClick={() => close()}>
<Trans message="Cancel" />
</Button>
<Button
form={formId}
variant="flat"
color="primary"
type="submit"
disabled={banUser.isPending}
>
<Trans message="Suspend" />
</Button>
</DialogFooter>
</Dialog>
);
}

View File

@@ -0,0 +1,52 @@
import {useForm} from 'react-hook-form';
import React from 'react';
import {FormTextField} from '../../ui/forms/input-field/text-field/text-field';
import {CreateUserPayload, useCreateUser} from './requests/create-user';
import {CrupdateUserForm} from './crupdate-user-form';
import {FileUploadProvider} from '../../uploads/uploader/file-upload-provider';
import {Trans} from '../../i18n/trans';
import {FormImageSelector} from '@common/ui/images/image-selector';
export function CreateUserPage() {
const form = useForm<CreateUserPayload>();
const createUser = useCreateUser(form);
const avatarManager = (
<FileUploadProvider>
<FormImageSelector
name="avatar"
diskPrefix="avatars"
variant="avatar"
stretchPreview
label={<Trans message="Profile image" />}
previewSize="w-90 h-90"
showRemoveButton
/>
</FileUploadProvider>
);
return (
<CrupdateUserForm
onSubmit={newValues => {
createUser.mutate(newValues);
}}
form={form}
title={<Trans message="Add new user" />}
isLoading={createUser.isPending}
avatarManager={avatarManager}
>
<FormTextField
className="mb-30"
name="email"
type="email"
label={<Trans message="Email" />}
/>
<FormTextField
className="mb-30"
name="password"
type="password"
label={<Trans message="Password" />}
/>
</CrupdateUserForm>
);
}

View File

@@ -0,0 +1,118 @@
import {FieldValues, SubmitHandler, UseFormReturn} from 'react-hook-form';
import clsx from 'clsx';
import {ReactNode} from 'react';
import {Link} from 'react-router-dom';
import {useValueLists} from '../../http/value-lists';
import {FormTextField} from '../../ui/forms/input-field/text-field/text-field';
import {FormSwitch} from '../../ui/forms/toggle/switch';
import {FormFileSizeField} from '../../ui/forms/input-field/file-size-field';
import {LinkStyle} from '../../ui/buttons/external-link';
import {FormPermissionSelector} from '../../auth/ui/permission-selector';
import {Trans} from '../../i18n/trans';
import {FormChipField} from '../../ui/forms/input-field/chip-field/form-chip-field';
import {Item} from '../../ui/forms/listbox/item';
import {CrupdateResourceLayout} from '../crupdate-resource-layout';
import {useSettings} from '../../core/settings/use-settings';
interface Props<T extends FieldValues> {
onSubmit: SubmitHandler<T>;
form: UseFormReturn<T>;
title: ReactNode;
subTitle?: ReactNode;
isLoading: boolean;
avatarManager: ReactNode;
resendEmailButton?: ReactNode;
children?: ReactNode;
}
export function CrupdateUserForm<T extends FieldValues>({
onSubmit,
form,
title,
subTitle,
isLoading,
avatarManager,
resendEmailButton,
children,
}: Props<T>) {
const {require_email_confirmation} = useSettings();
const {data: valueLists} = useValueLists(['roles', 'permissions']);
return (
<CrupdateResourceLayout
onSubmit={onSubmit}
form={form}
title={title}
subTitle={subTitle}
isLoading={isLoading}
>
<div className="mb-40 flex items-start gap-40 md:gap-80">
{avatarManager}
<div className="flex-auto">
{children}
<FormTextField
className="mb-30"
name="first_name"
label={<Trans message="First name" />}
/>
<FormTextField
name="last_name"
label={<Trans message="Last name" />}
/>
</div>
</div>
<div className="mb-30 border-b border-t pb-30 pt-30">
<FormSwitch
className={clsx(resendEmailButton && 'mb-30')}
disabled={!require_email_confirmation}
name="email_verified_at"
description={
<Trans message="Whether email address has been confirmed. User will not be able to login until address is confirmed, unless confirmation is disabled from settings page." />
}
>
<Trans message="Email confirmed" />
</FormSwitch>
{resendEmailButton}
</div>
<FormFileSizeField
className="mb-30"
name="available_space"
label={<Trans message="Allowed storage space" />}
description={
<Trans
values={{
a: parts => (
<Link
className={LinkStyle}
target="_blank"
to="/admin/settings/uploading"
>
{parts}
</Link>
),
}}
message="Total storage space all user uploads are allowed to take up. If left empty, this value will be inherited from any roles or subscriptions user has, or from 'Available space' setting in <a>Uploading</a> settings page."
/>
}
/>
<FormChipField
className="mb-30"
name="roles"
label={<Trans message="Roles" />}
suggestions={valueLists?.roles}
>
{chip => (
<Item key={chip.id} value={chip.id}>
{chip.name}
</Item>
)}
</FormChipField>
<div className="mt-30 border-t pt-30">
<div className="mb-10 text-sm">
<Trans message="Permissions" />
</div>
<FormPermissionSelector name="permissions" />
</div>
</CrupdateResourceLayout>
);
}

View File

@@ -0,0 +1,39 @@
import {useMutation} from '@tanstack/react-query';
import {UseFormReturn} from 'react-hook-form';
import {User} from '../../../auth/user';
import {BackendResponse} from '../../../http/backend-response/backend-response';
import {toast} from '../../../ui/toast/toast';
import {apiClient, queryClient} from '../../../http/query-client';
import {DatatableDataQueryKey} from '../../../datatable/requests/paginated-resources';
import {onFormQueryError} from '../../../errors/on-form-query-error';
import {message} from '../../../i18n/message';
import {useNavigate} from '../../../utils/hooks/use-navigate';
interface Response extends BackendResponse {
user: User;
}
export interface CreateUserPayload
extends Omit<Partial<User>, 'email_verified_at'> {
email_verified_at?: boolean;
}
export function useCreateUser(form: UseFormReturn<CreateUserPayload>) {
const navigate = useNavigate();
return useMutation({
mutationFn: (props: CreateUserPayload) => createUser(props),
onSuccess: () => {
toast(message('User created'));
queryClient.invalidateQueries({queryKey: DatatableDataQueryKey('users')});
navigate('/admin/users');
},
onError: r => onFormQueryError(r, form),
});
}
function createUser(payload: CreateUserPayload): Promise<Response> {
if (payload.roles) {
payload.roles = payload.roles.map(r => r.id) as any;
}
return apiClient.post('users', payload).then(r => r.data);
}

View File

@@ -0,0 +1,39 @@
import {useMutation} from '@tanstack/react-query';
import {UseFormReturn} from 'react-hook-form';
import {User} from '@common/auth/user';
import {BackendResponse} from '@common/http/backend-response/backend-response';
import {toast} from '@common/ui/toast/toast';
import {apiClient, queryClient} from '@common/http/query-client';
import {onFormQueryError} from '@common/errors/on-form-query-error';
import {message} from '@common/i18n/message';
import {useNavigate} from '@common/utils/hooks/use-navigate';
interface Response extends BackendResponse {
user: User;
}
export interface UpdateUserPayload
extends Omit<Partial<User>, 'email_verified_at'> {
email_verified_at?: boolean;
id: number;
}
export function useUpdateUser(form: UseFormReturn<UpdateUserPayload>) {
const navigate = useNavigate();
return useMutation({
mutationFn: (props: UpdateUserPayload) => updateUser(props),
onSuccess: (response, props) => {
toast(message('User updated'));
queryClient.invalidateQueries({queryKey: ['users']});
navigate('/admin/users');
},
onError: r => onFormQueryError(r, form),
});
}
function updateUser({id, ...other}: UpdateUserPayload): Promise<Response> {
if (other.roles) {
other.roles = other.roles.map(r => r.id) as any;
}
return apiClient.put(`users/${id}`, other).then(r => r.data);
}

View File

@@ -0,0 +1,36 @@
import {useMutation} from '@tanstack/react-query';
import {UseFormReturn} from 'react-hook-form';
import {User} from '@common/auth/user';
import {BackendResponse} from '@common/http/backend-response/backend-response';
import {toast} from '@common/ui/toast/toast';
import {apiClient, queryClient} from '@common/http/query-client';
import {onFormQueryError} from '@common/errors/on-form-query-error';
import {message} from '@common/i18n/message';
interface Response extends BackendResponse {
user: User;
}
export interface BanUserPayload {
ban_until?: string;
permanent?: boolean;
comment?: string;
}
export function useBanUser(
form: UseFormReturn<BanUserPayload>,
userId: number,
) {
return useMutation({
mutationFn: (payload: BanUserPayload) => banUser(userId, payload),
onSuccess: async () => {
toast(message('User suspended'));
await queryClient.invalidateQueries({queryKey: ['users']});
},
onError: r => onFormQueryError(r, form),
});
}
function banUser(userId: number, payload: BanUserPayload): Promise<Response> {
return apiClient.post(`users/${userId}/ban`, payload).then(r => r.data);
}

View File

@@ -0,0 +1,32 @@
import {useMutation} from '@tanstack/react-query';
import {toast} from '@common/ui/toast/toast';
import {apiClient} from '@common/http/query-client';
import {message} from '@common/i18n/message';
import {BackendResponse} from '@common/http/backend-response/backend-response';
import {User} from '@common/auth/user';
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
interface Response extends BackendResponse {
user: User;
}
interface Payload {
userId: string | number;
}
export function useImpersonateUser() {
return useMutation({
mutationFn: (payload: Payload) => impersonateUser(payload),
onSuccess: async response => {
toast(message(`Impersonating User "${response.user.display_name}"`));
window.location.href = '/';
},
onError: r => showHttpErrorToast(r),
});
}
function impersonateUser(payload: Payload) {
return apiClient
.post<Response>(`admin/users/impersonate/${payload.userId}`, payload)
.then(r => r.data);
}

View File

@@ -0,0 +1,23 @@
import {useMutation} from '@tanstack/react-query';
import {BackendResponse} from '@common/http/backend-response/backend-response';
import {toast} from '@common/ui/toast/toast';
import {apiClient, queryClient} from '@common/http/query-client';
import {message} from '@common/i18n/message';
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
interface Response extends BackendResponse {}
export function useUnbanUser(userId: number) {
return useMutation({
mutationFn: () => unbanUser(userId),
onSuccess: () => {
toast(message('User unsuspended'));
queryClient.invalidateQueries({queryKey: ['users']});
},
onError: r => showHttpErrorToast(r),
});
}
function unbanUser(userId: number): Promise<Response> {
return apiClient.delete(`users/${userId}/unban`).then(r => r.data);
}

View File

@@ -0,0 +1,140 @@
import {useForm} from 'react-hook-form';
import {useParams} from 'react-router-dom';
import React, {useEffect} from 'react';
import {useUser} from '../../auth/ui/use-user';
import {UpdateUserPayload, useUpdateUser} from './requests/update-user';
import {Button} from '../../ui/buttons/button';
import {useResendVerificationEmail} from '../../auth/requests/use-resend-verification-email';
import {useUploadAvatar} from '../../auth/ui/account-settings/avatar/upload-avatar';
import {useRemoveAvatar} from '../../auth/ui/account-settings/avatar/remove-avatar';
import {CrupdateUserForm} from './crupdate-user-form';
import {User} from '../../auth/user';
import {Trans} from '../../i18n/trans';
import {FullPageLoader} from '../../ui/progress/full-page-loader';
import {useSettings} from '../../core/settings/use-settings';
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
import {FileUploadProvider} from '@common/uploads/uploader/file-upload-provider';
import {FormImageSelector} from '@common/ui/images/image-selector';
import {queryClient} from '@common/http/query-client';
import {ReportIcon} from '@common/icons/material/Report';
export function UpdateUserPage() {
const form = useForm<UpdateUserPayload>();
const {require_email_confirmation} = useSettings();
const {userId} = useParams();
const updateUser = useUpdateUser(form);
const resendConfirmationEmail = useResendVerificationEmail();
const {data, isLoading} = useUser(userId!, {
with: ['subscriptions', 'roles', 'permissions', 'bans'],
});
const banReason = data?.user.bans?.[0]?.comment;
useEffect(() => {
if (data?.user && !form.getValues().id) {
form.reset({
first_name: data.user.first_name,
last_name: data.user.last_name,
roles: data.user.roles,
permissions: data.user.permissions,
id: data.user.id,
email_verified_at: Boolean(data.user.email_verified_at),
available_space: data.user.available_space,
avatar: data.user.avatar,
});
}
}, [data?.user, form]);
if (isLoading) {
return <FullPageLoader />;
}
const resendEmailButton = (
<Button
size="xs"
variant="outline"
color="primary"
disabled={
!require_email_confirmation ||
resendConfirmationEmail.isPending ||
data?.user?.email_verified_at != null
}
onClick={() => {
resendConfirmationEmail.mutate({email: data!.user.email});
}}
>
<Trans message="Resend email" />
</Button>
);
return (
<CrupdateUserForm
onSubmit={newValues => {
updateUser.mutate(newValues);
}}
form={form}
title={
<Trans values={{email: data?.user.email}} message="Edit “:email“" />
}
subTitle={
banReason && (
<div className="flex items-center gap-4 text-sm text-danger">
<ReportIcon />
<div>
<Trans
message="Suspended: :reason"
values={{reason: banReason}}
/>
</div>
</div>
)
}
isLoading={updateUser.isPending}
avatarManager={
<AvatarSection
user={data!.user}
onChange={() => {
queryClient.invalidateQueries({queryKey: ['users']});
}}
/>
}
resendEmailButton={resendEmailButton}
>
<FormTextField
className="mb-30"
name="password"
type="password"
label={<Trans message="New password" />}
/>
</CrupdateUserForm>
);
}
interface AvatarSectionProps {
user: User;
onChange: () => void;
}
function AvatarSection({user, onChange}: AvatarSectionProps) {
const uploadAvatar = useUploadAvatar({user});
const removeAvatar = useRemoveAvatar({user});
return (
<FileUploadProvider>
<FormImageSelector
name="avatar"
diskPrefix="avatars"
variant="avatar"
stretchPreview
label={<Trans message="Profile image" />}
previewSize="w-90 h-90"
showRemoveButton
onChange={url => {
if (url) {
uploadAvatar.mutate({url});
} else {
removeAvatar.mutate();
}
onChange();
}}
/>
</FileUploadProvider>
);
}

View File

@@ -0,0 +1,188 @@
import {ColumnConfig} from '@common/datatable/column-config';
import {User} from '@common/auth/user';
import {Trans} from '@common/i18n/trans';
import {NameWithAvatar} from '@common/datatable/column-templates/name-with-avatar';
import {CheckIcon} from '@common/icons/material/Check';
import {CloseIcon} from '@common/icons/material/Close';
import {ChipList} from '@common/ui/forms/input-field/chip-field/chip-list';
import {Chip} from '@common/ui/forms/input-field/chip-field/chip';
import {Link} from 'react-router-dom';
import clsx from 'clsx';
import {FormattedDate} from '@common/i18n/formatted-date';
import {Tooltip} from '@common/ui/tooltip/tooltip';
import {IconButton} from '@common/ui/buttons/icon-button';
import {EditIcon} from '@common/icons/material/Edit';
import {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';
import {PersonOffIcon} from '@common/icons/material/PersonOff';
import {BanUserDialog} from '@common/admin/users/ban-user-dialog';
import React from 'react';
import {useUnbanUser} from '@common/admin/users/requests/use-unban-user';
import {ConfirmationDialog} from '@common/ui/overlays/dialog/confirmation-dialog';
import {useImpersonateUser} from '@common/admin/users/requests/use-impersonate-user';
import {LoginIcon} from '@common/icons/material/Login';
export const userDatatableColumns: ColumnConfig<User>[] = [
{
key: 'name',
allowsSorting: true,
sortingKey: 'email',
width: 'flex-3 min-w-200',
visibleInMode: 'all',
header: () => <Trans message="User" />,
body: user => (
<NameWithAvatar
image={user.avatar}
label={user.display_name}
description={user.email}
/>
),
},
{
key: 'subscribed',
header: () => <Trans message="Subscribed" />,
width: 'w-96',
body: user =>
user.subscriptions?.length ? (
<CheckIcon className="text-positive icon-md" />
) : (
<CloseIcon className="text-danger icon-md" />
),
},
{
key: 'roles',
header: () => <Trans message="Roles" />,
body: user => (
<ChipList radius="rounded" size="xs">
{user?.roles?.map(role => (
<Chip key={role.id} selectable>
<Link
className={clsx('capitalize')}
target="_blank"
to={`/admin/roles/${role.id}/edit`}
>
<Trans message={role.name} />
</Link>
</Chip>
))}
</ChipList>
),
},
{
key: 'firstName',
allowsSorting: true,
header: () => <Trans message="First name" />,
body: user => user.first_name,
},
{
key: 'lastName',
allowsSorting: true,
header: () => <Trans message="Last name" />,
body: user => user.last_name,
},
{
key: 'createdAt',
allowsSorting: true,
width: 'w-96',
header: () => <Trans message="Created at" />,
body: user => (
<time>
<FormattedDate date={user.created_at} />
</time>
),
},
{
key: 'actions',
header: () => <Trans message="Actions" />,
width: 'w-128 flex-shrink-0',
hideHeader: true,
align: 'end',
visibleInMode: 'all',
body: user => (
<div className="text-muted">
<Link to={`${user.id}/edit`}>
<Tooltip label={<Trans message="Edit user" />}>
<IconButton size="md">
<EditIcon />
</IconButton>
</Tooltip>
</Link>
{user.banned_at ? (
<UnbanButton user={user} />
) : (
<DialogTrigger type="modal">
<Tooltip label={<Trans message="Suspend user" />}>
<IconButton size="md">
<PersonOffIcon />
</IconButton>
</Tooltip>
<BanUserDialog user={user} />
</DialogTrigger>
)}
<ImpersonateButton user={user} />
</div>
),
},
];
interface UnbanButtonProps {
user: User;
}
function UnbanButton({user}: UnbanButtonProps) {
const unban = useUnbanUser(user.id);
return (
<DialogTrigger
type="modal"
onClose={confirmed => {
if (confirmed) {
unban.mutate();
}
}}
>
<Tooltip label={<Trans message="Remove suspension" />}>
<IconButton size="md" color="danger">
<PersonOffIcon />
</IconButton>
</Tooltip>
<ConfirmationDialog
isDanger
title={
<Trans message="Suspend “:name“" values={{name: user.display_name}} />
}
body={
<Trans message="Are you sure you want to remove suspension from this user?" />
}
confirm={<Trans message="Unsuspend" />}
/>
</DialogTrigger>
);
}
interface ImpersonateButtonProps {
user: User;
}
function ImpersonateButton({user}: ImpersonateButtonProps) {
const impersonate = useImpersonateUser();
return (
<DialogTrigger type="modal">
<Tooltip label={<Trans message="Login as user" />}>
<IconButton size="md">
<LoginIcon />
</IconButton>
</Tooltip>
<ConfirmationDialog
title={
<Trans
message="Login as “:name“"
values={{name: user.display_name}}
/>
}
isLoading={impersonate.isPending}
body={<Trans message="Are you sure you want to login as this user?" />}
confirm={<Trans message="Login" />}
onConfirm={() => {
impersonate.mutate({userId: user.id});
}}
/>
</DialogTrigger>
);
}

View File

@@ -0,0 +1,63 @@
import {
BackendFilter,
FilterControlType,
FilterOperator,
} from '../../datatable/filters/backend-filter';
import {
createdAtFilter,
updatedAtFilter,
} from '../../datatable/filters/timestamp-filters';
import {message} from '../../i18n/message';
export const UserDatatableFilters: BackendFilter[] = [
{
key: 'email_verified_at',
label: message('Email'),
description: message('Email verification status'),
defaultOperator: FilterOperator.ne,
control: {
type: FilterControlType.Select,
defaultValue: '01',
options: [
{
key: '01',
label: message('is confirmed'),
value: {value: null, operator: FilterOperator.ne},
},
{
key: '02',
label: message('is not confirmed'),
value: {value: null, operator: FilterOperator.eq},
},
],
},
},
createdAtFilter({
description: message('Date user registered or was created'),
}),
updatedAtFilter({
description: message('Date user was last updated'),
}),
{
key: 'subscriptions',
label: message('Subscription'),
description: message('Whether user is subscribed or not'),
defaultOperator: FilterOperator.eq,
control: {
type: FilterControlType.Select,
defaultValue: '01',
options: [
{
key: '01',
label: message('is subscribed'),
value: {value: '*', operator: FilterOperator.has},
},
{
key: '02',
label: message('is not subscribed'),
value: {value: '*', operator: FilterOperator.doesntHave},
},
],
},
},
];

View File

@@ -0,0 +1,52 @@
import React, {Fragment} from 'react';
import {Link} from 'react-router-dom';
import {UserDatatableFilters} from './user-datatable-filters';
import {DataTablePage} from '../../datatable/page/data-table-page';
import {Trans} from '../../i18n/trans';
import {DeleteSelectedItemsAction} from '../../datatable/page/delete-selected-items-action';
import {DataTableEmptyStateMessage} from '../../datatable/page/data-table-emty-state-message';
import teamSvg from '../roles/team.svg';
import {DataTableAddItemButton} from '../../datatable/data-table-add-item-button';
import {DataTableExportCsvButton} from '../../datatable/csv-export/data-table-export-csv-button';
import {useSettings} from '../../core/settings/use-settings';
import {userDatatableColumns} from '@common/admin/users/user-datatable-columns';
export function UserDatatable() {
const {billing} = useSettings();
const filteredColumns = !billing.enable
? userDatatableColumns.filter(c => c.key !== 'subscribed')
: userDatatableColumns;
return (
<Fragment>
<DataTablePage
endpoint="users"
title={<Trans message="Users" />}
filters={UserDatatableFilters}
columns={filteredColumns}
actions={<Actions />}
queryParams={{with: 'subscriptions,bans'}}
selectedActions={<DeleteSelectedItemsAction />}
emptyStateMessage={
<DataTableEmptyStateMessage
image={teamSvg}
title={<Trans message="No users have been created yet" />}
filteringTitle={<Trans message="No matching users" />}
/>
}
/>
</Fragment>
);
}
function Actions() {
return (
<Fragment>
<DataTableExportCsvButton endpoint="users/csv/export" />
<DataTableAddItemButton elementType={Link} to="new">
<Trans message="Add new user" />
</DataTableAddItemButton>
</Fragment>
);
}