81
common/resources/client/admin/users/ban-user-dialog.tsx
Executable file
81
common/resources/client/admin/users/ban-user-dialog.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
52
common/resources/client/admin/users/create-user-page.tsx
Executable file
52
common/resources/client/admin/users/create-user-page.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
118
common/resources/client/admin/users/crupdate-user-form.tsx
Executable file
118
common/resources/client/admin/users/crupdate-user-form.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
39
common/resources/client/admin/users/requests/create-user.ts
Executable file
39
common/resources/client/admin/users/requests/create-user.ts
Executable 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);
|
||||
}
|
||||
39
common/resources/client/admin/users/requests/update-user.ts
Executable file
39
common/resources/client/admin/users/requests/update-user.ts
Executable 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);
|
||||
}
|
||||
36
common/resources/client/admin/users/requests/use-ban-user.ts
Executable file
36
common/resources/client/admin/users/requests/use-ban-user.ts
Executable 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);
|
||||
}
|
||||
32
common/resources/client/admin/users/requests/use-impersonate-user.ts
Executable file
32
common/resources/client/admin/users/requests/use-impersonate-user.ts
Executable 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);
|
||||
}
|
||||
23
common/resources/client/admin/users/requests/use-unban-user.ts
Executable file
23
common/resources/client/admin/users/requests/use-unban-user.ts
Executable 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);
|
||||
}
|
||||
140
common/resources/client/admin/users/update-user-page.tsx
Executable file
140
common/resources/client/admin/users/update-user-page.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
188
common/resources/client/admin/users/user-datatable-columns.tsx
Executable file
188
common/resources/client/admin/users/user-datatable-columns.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
63
common/resources/client/admin/users/user-datatable-filters.ts
Executable file
63
common/resources/client/admin/users/user-datatable-filters.ts
Executable 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},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
52
common/resources/client/admin/users/user-datatable.tsx
Executable file
52
common/resources/client/admin/users/user-datatable.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user