2022-08-29 18:09:28 +00:00
|
|
|
import styles from './UsersPage.module.scss';
|
2022-09-01 18:11:36 +00:00
|
|
|
import { useEffect, useRef, useState } from 'react';
|
2022-08-29 18:09:28 +00:00
|
|
|
import useSWR from 'swr';
|
|
|
|
import useUser from '../../lib/hooks/useUser';
|
|
|
|
import { PaginatedResponse } from '../../lib/types/paginated-response.interface';
|
|
|
|
import { UserListItem } from '../../lib/types/users.interfaces';
|
|
|
|
import { Container } from '../common/Container/Container';
|
|
|
|
import { Header } from '../common/Header/Header';
|
|
|
|
import avatar from '../../public/avatar.png';
|
|
|
|
import Image from 'next/image';
|
|
|
|
import { UPLOADS_URL } from '../../lib/constants';
|
|
|
|
import { Paginator } from '../common/Paginator/Paginator';
|
2022-09-01 18:11:36 +00:00
|
|
|
import { Dropdown } from '../common/Dropdown/Dropdown';
|
|
|
|
import toast from 'react-hot-toast';
|
2022-09-12 19:31:58 +00:00
|
|
|
import { publishJSON } from '../../lib/utils/fetch';
|
2022-09-01 18:11:36 +00:00
|
|
|
import { useForm } from '../../lib/hooks/useForm';
|
|
|
|
import { ModalProps } from '../../lib/types/modal.interface';
|
|
|
|
import { Button } from '../common/Button/Button';
|
|
|
|
import { FormControl } from '../common/Form/FormControl/FormControl';
|
|
|
|
import { FormWrapper } from '../common/Form/FormWrapper/FormWrapper';
|
|
|
|
import Modal from '../common/Modal/Modal/Modal';
|
|
|
|
import ModalBody from '../common/Modal/ModalBody/ModalBody';
|
|
|
|
import ModalFooter from '../common/Modal/ModalFooter/ModalFooter';
|
|
|
|
import ModalHeader from '../common/Modal/ModalHeader/ModalHeader';
|
|
|
|
import ModalService from '../common/Modal/services/ModalService';
|
|
|
|
import { Privilege } from '../../lib/types/privilege.interface';
|
|
|
|
import useHasPrivileges from '../../lib/hooks/useHasPrivileges';
|
2022-08-29 18:09:28 +00:00
|
|
|
|
2022-09-01 18:11:36 +00:00
|
|
|
function getSelectValues(selectElement: HTMLSelectElement) {
|
|
|
|
const result = [];
|
|
|
|
const options = selectElement && selectElement.options;
|
|
|
|
|
|
|
|
for (let i = 0, iLen = options.length; i < iLen; i++) {
|
|
|
|
const opt = options[i];
|
|
|
|
|
|
|
|
if (opt.selected) {
|
|
|
|
result.push(opt.value || opt.text);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return result;
|
|
|
|
}
|
|
|
|
|
|
|
|
const PrivilegeEditor = ({
|
|
|
|
user,
|
|
|
|
onChange,
|
|
|
|
}: {
|
|
|
|
user: UserListItem;
|
|
|
|
onChange: (privvy: Privilege[]) => void;
|
|
|
|
}) => {
|
2022-09-09 14:37:42 +00:00
|
|
|
const [userPrivvy, setUserPrivvy] = useState(user.privileges || []);
|
2022-09-01 18:11:36 +00:00
|
|
|
const [selectionAvailable, setSelectionAvailable] = useState<string[]>([]);
|
|
|
|
const [selectionExisting, setSelectionExisting] = useState<string[]>([]);
|
|
|
|
const [availablePrivileges, setAvailablePrivileges] = useState<Privilege[]>(
|
|
|
|
[]
|
|
|
|
);
|
|
|
|
const { data } = useSWR<Privilege[]>('/api/admin/privileges');
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
setAvailablePrivileges(
|
|
|
|
(data || []).filter(
|
2022-09-11 09:53:48 +00:00
|
|
|
({ id }) => !(userPrivvy || []).some((privvy) => privvy.id === id)
|
2022-09-01 18:11:36 +00:00
|
|
|
)
|
|
|
|
);
|
2022-09-09 14:37:42 +00:00
|
|
|
}, [user, data, userPrivvy]);
|
2022-09-01 18:11:36 +00:00
|
|
|
|
|
|
|
return (
|
|
|
|
<>
|
|
|
|
<div className={styles.privEditor}>
|
|
|
|
<h3>Privileges</h3>
|
|
|
|
<div className={styles.privInner}>
|
|
|
|
<div className={styles.described}>
|
|
|
|
<h4>Available</h4>
|
|
|
|
<select
|
|
|
|
multiple
|
|
|
|
value={selectionAvailable}
|
|
|
|
onChange={(e) => setSelectionAvailable(getSelectValues(e.target))}
|
|
|
|
>
|
|
|
|
{availablePrivileges.map(({ name, id }) => (
|
|
|
|
<option value={id} key={id}>
|
|
|
|
{name}
|
|
|
|
</option>
|
|
|
|
))}
|
|
|
|
</select>
|
|
|
|
</div>
|
|
|
|
<div className={styles.privControls}>
|
|
|
|
<Button
|
|
|
|
type="button"
|
|
|
|
disabled={!selectionAvailable.length}
|
|
|
|
onClick={() => {
|
|
|
|
// Add privileges
|
|
|
|
const toAdd = availablePrivileges.filter(({ id }) =>
|
|
|
|
selectionAvailable.includes(id.toString())
|
|
|
|
);
|
2022-09-11 09:53:48 +00:00
|
|
|
const changed = [...(userPrivvy || []), ...toAdd];
|
|
|
|
setUserPrivvy(changed);
|
2022-09-01 18:11:36 +00:00
|
|
|
setSelectionAvailable([]);
|
2022-09-11 09:53:48 +00:00
|
|
|
onChange(changed);
|
2022-09-01 18:11:36 +00:00
|
|
|
}}
|
|
|
|
>
|
|
|
|
>>
|
|
|
|
</Button>
|
|
|
|
<Button
|
|
|
|
type="button"
|
|
|
|
disabled={!selectionExisting.length}
|
|
|
|
onClick={() => {
|
|
|
|
// Remove privileges
|
2022-09-11 09:53:48 +00:00
|
|
|
const removed = (userPrivvy || []).filter(
|
|
|
|
({ id }) => !selectionExisting.includes(id.toString())
|
2022-09-09 14:37:42 +00:00
|
|
|
);
|
2022-09-11 09:53:48 +00:00
|
|
|
setSelectionExisting([]);
|
|
|
|
onChange(removed);
|
|
|
|
setUserPrivvy(removed);
|
2022-09-01 18:11:36 +00:00
|
|
|
}}
|
|
|
|
>
|
|
|
|
<<
|
|
|
|
</Button>
|
|
|
|
</div>
|
|
|
|
<div className={styles.described}>
|
|
|
|
<h4>Current</h4>
|
|
|
|
<select
|
|
|
|
multiple
|
|
|
|
value={selectionExisting}
|
|
|
|
onChange={(e) => setSelectionExisting(getSelectValues(e.target))}
|
|
|
|
>
|
2022-09-11 09:53:48 +00:00
|
|
|
{(userPrivvy || []).map(({ name, id }) => (
|
2022-09-01 18:11:36 +00:00
|
|
|
<option value={id} key={id}>
|
|
|
|
{name}
|
|
|
|
</option>
|
|
|
|
))}
|
|
|
|
</select>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</>
|
|
|
|
);
|
|
|
|
};
|
|
|
|
|
|
|
|
const EditUserModal = ({
|
|
|
|
close,
|
|
|
|
modalRef,
|
|
|
|
privPriv,
|
|
|
|
user,
|
|
|
|
update,
|
|
|
|
}: ModalProps<{
|
|
|
|
user: UserListItem;
|
|
|
|
update: () => void;
|
|
|
|
privPriv: boolean;
|
|
|
|
}>) => {
|
|
|
|
const formRef = useRef<HTMLFormElement>(null);
|
|
|
|
|
|
|
|
const { formData, handleInputChange, handleSubmit } = useForm<
|
|
|
|
Partial<UserListItem>
|
|
|
|
>(user, async () => {
|
|
|
|
// Save privileges
|
|
|
|
if (privPriv) {
|
|
|
|
await toast.promise(
|
|
|
|
publishJSON(`/api/admin/users/${user.id}/privileges`, 'PUT', {
|
|
|
|
privileges: (user.privileges || []).map(({ id }) => id),
|
|
|
|
}),
|
|
|
|
{
|
|
|
|
loading: 'Saving privileges...',
|
|
|
|
success: 'Privileges saved!',
|
|
|
|
error: 'Failed to save privileges.',
|
|
|
|
}
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Save user data
|
|
|
|
toast
|
|
|
|
.promise(publishJSON(`/api/admin/users/${user.id}`, 'PATCH', formData), {
|
|
|
|
loading: 'Saving user...',
|
|
|
|
success: 'User saved!',
|
|
|
|
error: (err) => `Saving the user failed: ${err.message}`,
|
|
|
|
})
|
|
|
|
.then((data) => {
|
|
|
|
if (data) {
|
|
|
|
close(true);
|
|
|
|
update();
|
|
|
|
}
|
|
|
|
});
|
|
|
|
return false;
|
|
|
|
});
|
|
|
|
|
|
|
|
return (
|
|
|
|
<Modal modalRef={modalRef}>
|
|
|
|
<ModalHeader>
|
|
|
|
<h3>Edit user details</h3>
|
|
|
|
</ModalHeader>
|
|
|
|
<ModalBody>
|
|
|
|
<FormWrapper>
|
|
|
|
<form ref={formRef} onSubmit={handleSubmit} autoComplete="off">
|
|
|
|
<FormControl>
|
|
|
|
<label htmlFor="display_name">Display name</label>
|
|
|
|
<input
|
|
|
|
id="display_name"
|
|
|
|
type="text"
|
|
|
|
name="display_name"
|
|
|
|
value={formData.display_name}
|
|
|
|
onChange={handleInputChange}
|
|
|
|
/>
|
|
|
|
</FormControl>
|
|
|
|
<FormControl>
|
|
|
|
<label htmlFor="username">Username</label>
|
|
|
|
<input
|
|
|
|
id="username"
|
|
|
|
type="text"
|
|
|
|
name="username"
|
|
|
|
value={formData.username}
|
|
|
|
onChange={handleInputChange}
|
|
|
|
/>
|
|
|
|
</FormControl>
|
|
|
|
<FormControl>
|
|
|
|
<label htmlFor="email">Email</label>
|
|
|
|
<input
|
|
|
|
id="email"
|
|
|
|
name="email"
|
|
|
|
type="email"
|
|
|
|
value={formData.email}
|
|
|
|
onChange={handleInputChange}
|
|
|
|
/>
|
|
|
|
</FormControl>
|
|
|
|
<FormControl inline={true}>
|
|
|
|
<label htmlFor="activated">Activated</label>
|
|
|
|
<input
|
|
|
|
id="activated"
|
|
|
|
name="activated"
|
|
|
|
type="checkbox"
|
|
|
|
checked={formData.activated}
|
|
|
|
onChange={handleInputChange}
|
|
|
|
/>
|
|
|
|
</FormControl>
|
|
|
|
{privPriv && (
|
|
|
|
<PrivilegeEditor
|
|
|
|
user={user}
|
|
|
|
onChange={(privileges: Privilege[]) =>
|
|
|
|
(formData.privileges = privileges)
|
|
|
|
}
|
|
|
|
></PrivilegeEditor>
|
|
|
|
)}
|
|
|
|
</form>
|
|
|
|
</FormWrapper>
|
|
|
|
</ModalBody>
|
|
|
|
<ModalFooter>
|
|
|
|
<Button onClick={() => close(true)}>Cancel</Button>
|
|
|
|
<Button onClick={() => handleSubmit()} variant="primary">
|
|
|
|
Submit
|
|
|
|
</Button>
|
|
|
|
</ModalFooter>
|
|
|
|
</Modal>
|
|
|
|
);
|
|
|
|
};
|
|
|
|
|
|
|
|
const UserCard = ({
|
|
|
|
user,
|
|
|
|
update,
|
|
|
|
privPriv,
|
|
|
|
}: {
|
|
|
|
user: UserListItem;
|
|
|
|
update: () => void;
|
|
|
|
privPriv: boolean;
|
|
|
|
}) => (
|
2022-08-29 18:09:28 +00:00
|
|
|
<div className={styles.userCard}>
|
|
|
|
<div className={styles.pictureWrapper}>
|
|
|
|
{user.picture ? (
|
|
|
|
<Image
|
|
|
|
src={`${UPLOADS_URL}/${user.picture.file}`}
|
|
|
|
width={128}
|
|
|
|
height={128}
|
|
|
|
alt=""
|
|
|
|
/>
|
|
|
|
) : (
|
|
|
|
<Image src={avatar} alt="" width={128} height={128} />
|
|
|
|
)}
|
|
|
|
</div>
|
|
|
|
<div className={styles.userInfo}>
|
2022-09-01 18:11:36 +00:00
|
|
|
<div className={styles.titleWrap}>
|
|
|
|
<h2>
|
|
|
|
{user.display_name}{' '}
|
|
|
|
<span className={styles.username}>@{user.username}</span>
|
|
|
|
</h2>
|
|
|
|
<Dropdown opens="right">
|
|
|
|
<button
|
|
|
|
onClick={() =>
|
|
|
|
ModalService.open(EditUserModal, { user, update, privPriv })
|
|
|
|
}
|
|
|
|
>
|
|
|
|
Edit user
|
|
|
|
</button>
|
|
|
|
{!user.activated && (
|
|
|
|
<button
|
|
|
|
onClick={() => {
|
|
|
|
toast.promise(
|
|
|
|
publishJSON(`/api/admin/users/${user.id}/activation`, 'POST'),
|
|
|
|
{
|
|
|
|
loading: 'Sending email...',
|
|
|
|
success: 'Email sent!',
|
|
|
|
error: 'Failed to send activation email.',
|
|
|
|
}
|
|
|
|
);
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
Send activation email
|
|
|
|
</button>
|
|
|
|
)}
|
|
|
|
{user.activated && (
|
|
|
|
<button
|
|
|
|
onClick={() =>
|
|
|
|
toast.promise(
|
|
|
|
publishJSON(`/api/admin/users/${user.id}/password`, 'POST'),
|
|
|
|
{
|
|
|
|
loading: 'Sending email...',
|
|
|
|
success: 'Email sent!',
|
|
|
|
error: 'Failed to send password email.',
|
|
|
|
}
|
|
|
|
)
|
|
|
|
}
|
|
|
|
>
|
|
|
|
Send password email
|
|
|
|
</button>
|
|
|
|
)}
|
|
|
|
{user.picture && (
|
|
|
|
<button
|
|
|
|
onClick={() =>
|
|
|
|
toast
|
|
|
|
.promise(
|
|
|
|
publishJSON(`/api/admin/users/${user.id}/avatar`, 'DELETE'),
|
|
|
|
{
|
|
|
|
loading: 'Deleting avatar...',
|
|
|
|
success: 'Avatar deleted!',
|
|
|
|
error: 'Failed to delete user avatar.',
|
|
|
|
}
|
|
|
|
)
|
|
|
|
.then(update)
|
|
|
|
}
|
|
|
|
>
|
|
|
|
Delete avatar
|
|
|
|
</button>
|
|
|
|
)}
|
|
|
|
</Dropdown>
|
|
|
|
</div>
|
2022-08-29 18:09:28 +00:00
|
|
|
<dl>
|
|
|
|
<dt>UUID</dt>
|
|
|
|
<dd>{user.uuid}</dd>
|
|
|
|
<dt>Email</dt>
|
|
|
|
<dd>{user.email}</dd>
|
2022-09-10 06:29:17 +00:00
|
|
|
{user.privileges && user.privileges.length > 0 && (
|
2022-09-01 18:11:36 +00:00
|
|
|
<>
|
|
|
|
<dt>Privileges</dt>
|
|
|
|
<dd>
|
|
|
|
{user.privileges.map((privilege) => privilege.name).join(', ')}
|
|
|
|
</dd>
|
|
|
|
</>
|
|
|
|
)}
|
2022-08-29 18:09:28 +00:00
|
|
|
<dt>Activated</dt>
|
|
|
|
<dd>{user.activated ? 'Yes' : <b>NOT ACTIVATED</b>}</dd>
|
|
|
|
<dt>Registered</dt>
|
|
|
|
<dd>{new Date(user.created_at).toDateString()}</dd>
|
|
|
|
</dl>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
|
|
|
|
const UserList = ({
|
|
|
|
pageIndex,
|
|
|
|
searchTerm,
|
2022-09-01 18:11:36 +00:00
|
|
|
privPriv,
|
2022-08-29 18:09:28 +00:00
|
|
|
setPage,
|
|
|
|
}: {
|
|
|
|
pageIndex: number;
|
|
|
|
searchTerm: string;
|
2022-09-01 18:11:36 +00:00
|
|
|
privPriv: boolean;
|
2022-08-29 18:09:28 +00:00
|
|
|
setPage: (page: number) => void;
|
|
|
|
}) => {
|
2022-09-01 18:11:36 +00:00
|
|
|
const { data, mutate } = useSWR<PaginatedResponse<UserListItem>>(
|
2022-08-29 18:09:28 +00:00
|
|
|
`/api/admin/users?page=${pageIndex}${searchTerm ? `&q=${searchTerm}` : ''}`
|
|
|
|
);
|
|
|
|
|
|
|
|
return data ? (
|
|
|
|
<>
|
2022-09-01 18:11:36 +00:00
|
|
|
{data?.list?.length && (
|
|
|
|
<div className={styles.userList}>
|
|
|
|
{data.list.map((user) => (
|
|
|
|
<UserCard
|
|
|
|
user={user}
|
|
|
|
key={user.uuid}
|
|
|
|
update={mutate}
|
|
|
|
privPriv={privPriv}
|
|
|
|
/>
|
|
|
|
))}
|
|
|
|
<Paginator setPage={setPage} pagination={data.pagination}></Paginator>
|
|
|
|
</div>
|
|
|
|
)}
|
2022-08-29 18:09:28 +00:00
|
|
|
</>
|
|
|
|
) : (
|
|
|
|
<span>Nothing found</span>
|
|
|
|
);
|
|
|
|
};
|
|
|
|
|
|
|
|
export const UsersPage = () => {
|
|
|
|
const { user } = useUser({ redirectTo: '/login' });
|
|
|
|
const [pageIndex, setPageIndex] = useState(1);
|
|
|
|
const [searchTerm, setSearchTerm] = useState('');
|
2022-09-01 18:11:36 +00:00
|
|
|
const privPriv = useHasPrivileges(user, 'admin:user:privilege');
|
2022-08-29 18:09:28 +00:00
|
|
|
|
|
|
|
return (
|
|
|
|
<>
|
|
|
|
<Header user={user}></Header>
|
|
|
|
<Container>
|
|
|
|
<h1>Users</h1>
|
2022-09-10 09:54:45 +00:00
|
|
|
<FormWrapper>
|
|
|
|
<FormControl>
|
|
|
|
<input
|
|
|
|
value={searchTerm}
|
|
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
|
|
placeholder="Search usernames, display names, emails, UUIDs.."
|
|
|
|
/>
|
|
|
|
</FormControl>
|
|
|
|
</FormWrapper>
|
|
|
|
<br />
|
2022-08-29 18:09:28 +00:00
|
|
|
<UserList
|
|
|
|
pageIndex={pageIndex}
|
|
|
|
searchTerm={searchTerm}
|
2022-09-01 18:11:36 +00:00
|
|
|
privPriv={privPriv}
|
2022-08-29 18:09:28 +00:00
|
|
|
setPage={setPageIndex}
|
|
|
|
/>
|
|
|
|
</Container>
|
|
|
|
</>
|
|
|
|
);
|
|
|
|
};
|