From 19f2a820ebc3c02c7721d0d5cbfa80d6a154ca24 Mon Sep 17 00:00:00 2001 From: Evert Prants Date: Thu, 1 Sep 2022 21:11:36 +0300 Subject: [PATCH] more work on admin --- components/OAuth2Page/OAuth2Page.tsx | 35 +- components/UsersPage/UsersPage.module.scss | 36 ++ components/UsersPage/UsersPage.tsx | 353 +++++++++++++++++++- components/common/Button/Button.module.scss | 64 ++-- components/common/Button/Button.tsx | 2 +- components/common/Form/Form.module.scss | 1 + lib/utils/swr-fetcher.ts | 16 + 7 files changed, 439 insertions(+), 68 deletions(-) diff --git a/components/OAuth2Page/OAuth2Page.tsx b/components/OAuth2Page/OAuth2Page.tsx index b25831b..d35f647 100644 --- a/components/OAuth2Page/OAuth2Page.tsx +++ b/components/OAuth2Page/OAuth2Page.tsx @@ -27,6 +27,7 @@ import { FormControl } from '../common/Form/FormControl/FormControl'; import toast from 'react-hot-toast'; import { Button } from '../common/Button/Button'; import userHasPrivileges from '../../lib/utils/has-privileges'; +import { publishJSON } from '../../lib/utils/swr-fetcher'; const LINK_NAMES = { redirect_uri: 'Redirect URI', @@ -93,25 +94,13 @@ const EditClientModal = ({ >(client || {}, () => { toast .promise( - fetch( + publishJSON( client ? `/api/admin/oauth2/clients/${client.id}` : '/api/admin/oauth2/clients', - { - method: client ? 'PATCH' : 'POST', - headers: { - 'content-type': 'application/json', - }, - body: JSON.stringify({ activated: true, ...formData }), - } - ) - .then((res) => res.json()) - .then(async (data) => { - if (data.error) { - throw data; - } - return data; - }), + client ? 'PATCH' : 'POST', + { activated: true, ...formData } + ), { loading: 'Saving client...', success: 'Client saved!', @@ -226,16 +215,10 @@ const EditClientModal = ({ onClick={() => toast .promise( - fetch(`/api/admin/oauth2/clients/${client!.id}/new-secret`, { - method: 'POST', - }) - .then((res) => res.json()) - .then(async (data) => { - if (data.error) { - throw data; - } - return data; - }), + publishJSON( + `/api/admin/oauth2/clients/${client!.id}/new-secret`, + 'POST' + ), { loading: 'Generating new secret...', success: 'New secret generated.', diff --git a/components/UsersPage/UsersPage.module.scss b/components/UsersPage/UsersPage.module.scss index e078944..f6bf6d4 100644 --- a/components/UsersPage/UsersPage.module.scss +++ b/components/UsersPage/UsersPage.module.scss @@ -6,6 +6,11 @@ padding: 0.5rem; box-shadow: 0 0 8px rgba(0, 0, 0, 0.25); + .titleWrap { + display: flex; + justify-content: space-between; + } + .pictureWrapper { overflow: hidden; } @@ -13,6 +18,7 @@ .userInfo { display: flex; flex-direction: column; + flex-grow: 1; h2 { padding: 0; @@ -29,3 +35,33 @@ flex-direction: column; gap: 1rem; } + +.privEditor { + display: flex; + flex-direction: column; + + .privInner { + display: flex; + + .described { + display: flex; + flex-direction: column; + flex-grow: 1; + h4 { + margin-bottom: 0.5rem; + } + + select { + min-height: 120px; + } + } + + .privControls { + display: flex; + flex-direction: column; + justify-content: center; + margin: 0 0.5rem; + gap: 0.25rem; + } + } +} diff --git a/components/UsersPage/UsersPage.tsx b/components/UsersPage/UsersPage.tsx index 81f2dec..f73deea 100644 --- a/components/UsersPage/UsersPage.tsx +++ b/components/UsersPage/UsersPage.tsx @@ -1,5 +1,5 @@ import styles from './UsersPage.module.scss'; -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import useSWR from 'swr'; import useUser from '../../lib/hooks/useUser'; import { PaginatedResponse } from '../../lib/types/paginated-response.interface'; @@ -10,8 +10,255 @@ import avatar from '../../public/avatar.png'; import Image from 'next/image'; import { UPLOADS_URL } from '../../lib/constants'; import { Paginator } from '../common/Paginator/Paginator'; +import { Dropdown } from '../common/Dropdown/Dropdown'; +import toast from 'react-hot-toast'; +import { publishJSON } from '../../lib/utils/swr-fetcher'; +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'; -const UserCard = ({ user }: { user: UserListItem }) => ( +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; +}) => { + const [toggle, setToggle] = useState(false); + const [selectionAvailable, setSelectionAvailable] = useState([]); + const [selectionExisting, setSelectionExisting] = useState([]); + const [availablePrivileges, setAvailablePrivileges] = useState( + [] + ); + const { data } = useSWR('/api/admin/privileges'); + + useEffect(() => { + setAvailablePrivileges( + (data || []).filter( + ({ id }) => !(user.privileges || []).some((privvy) => privvy.id === id) + ) + ); + }, [user, data, toggle]); + + return ( + <> +
+

Privileges

+
+
+

Available

+ +
+
+ + +
+
+

Current

+ +
+
+
+ + ); +}; + +const EditUserModal = ({ + close, + modalRef, + privPriv, + user, + update, +}: ModalProps<{ + user: UserListItem; + update: () => void; + privPriv: boolean; +}>) => { + const formRef = useRef(null); + + const { formData, handleInputChange, handleSubmit } = useForm< + Partial + >(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 ( + + +

Edit user details

+
+ + +
+ + + + + + + + + + + + + + + + + {privPriv && ( + + (formData.privileges = privileges) + } + > + )} +
+
+
+ + + + +
+ ); +}; + +const UserCard = ({ + user, + update, + privPriv, +}: { + user: UserListItem; + update: () => void; + privPriv: boolean; +}) => (
{user.picture ? ( @@ -26,15 +273,84 @@ const UserCard = ({ user }: { user: UserListItem }) => ( )}
-

- {user.display_name}{' '} - @{user.username} -

+
+

+ {user.display_name}{' '} + @{user.username} +

+ + + {!user.activated && ( + + )} + {user.activated && ( + + )} + {user.picture && ( + + )} + +
UUID
{user.uuid}
Email
{user.email}
+ {user.privileges?.length && ( + <> +
Privileges
+
+ {user.privileges.map((privilege) => privilege.name).join(', ')} +
+ + )}
Activated
{user.activated ? 'Yes' : NOT ACTIVATED}
Registered
@@ -47,24 +363,33 @@ const UserCard = ({ user }: { user: UserListItem }) => ( const UserList = ({ pageIndex, searchTerm, + privPriv, setPage, }: { pageIndex: number; searchTerm: string; + privPriv: boolean; setPage: (page: number) => void; }) => { - const { data } = useSWR>( + const { data, mutate } = useSWR>( `/api/admin/users?page=${pageIndex}${searchTerm ? `&q=${searchTerm}` : ''}` ); return data ? ( <> -
- {data.list.map((user) => ( - - ))} - -
+ {data?.list?.length && ( +
+ {data.list.map((user) => ( + + ))} + +
+ )} ) : ( Nothing found @@ -75,6 +400,7 @@ export const UsersPage = () => { const { user } = useUser({ redirectTo: '/login' }); const [pageIndex, setPageIndex] = useState(1); const [searchTerm, setSearchTerm] = useState(''); + const privPriv = useHasPrivileges(user, 'admin:user:privilege'); return ( <> @@ -84,6 +410,7 @@ export const UsersPage = () => { diff --git a/components/common/Button/Button.module.scss b/components/common/Button/Button.module.scss index 5fa9d92..7e5c9b2 100644 --- a/components/common/Button/Button.module.scss +++ b/components/common/Button/Button.module.scss @@ -5,6 +5,10 @@ cursor: pointer; transition: background linear 0.33s; + &[disabled] { + cursor: default; + } + &.default { border: 1px solid #b9b9b9; background: linear-gradient( @@ -13,21 +17,23 @@ rgb(241 241 241) 100% ); - &:hover, - &:focus-visible { - background: linear-gradient( - 180deg, - rgb(255 255 255) 0%, - rgb(250 250 250) 100% - ); - } + &:not([disabled]) { + &:hover, + &:focus-visible { + background: linear-gradient( + 180deg, + rgb(255 255 255) 0%, + rgb(250 250 250) 100% + ); + } - &:active { - background: linear-gradient( - 180deg, - rgb(241 241 241) 0%, - rgb(246 246 246) 100% - ); + &:active { + background: linear-gradient( + 180deg, + rgb(241 241 241) 0%, + rgb(246 246 246) 100% + ); + } } } &.primary { @@ -38,21 +44,23 @@ rgba(59, 190, 255, 1) 100% ); - &:hover, - &:focus-visible { - background: linear-gradient( - 180deg, - rgb(145 220 255) 0%, - rgb(84 198 255) 100% - ); - } + &:not([disabled]) { + &:hover, + &:focus-visible { + background: linear-gradient( + 180deg, + rgb(145 220 255) 0%, + rgb(84 198 255) 100% + ); + } - &:active { - background: linear-gradient( - 180deg, - rgb(77, 196, 255) 0%, - rgb(124, 213, 255) 100% - ); + &:active { + background: linear-gradient( + 180deg, + rgb(77, 196, 255) 0%, + rgb(124, 213, 255) 100% + ); + } } } &.secondary { diff --git a/components/common/Button/Button.tsx b/components/common/Button/Button.tsx index 0d9ac20..986c194 100644 --- a/components/common/Button/Button.tsx +++ b/components/common/Button/Button.tsx @@ -10,7 +10,7 @@ export const Button = ({ variant?: 'default' | 'primary' | 'secondary'; onClick: MouseEventHandler; children?: React.ReactNode; -}) => { +} & JSX.IntrinsicElements['button']) => { return (