From 25feeaa9f58f4f83cde7c5c0710faa72d9013613 Mon Sep 17 00:00:00 2001 From: Matthew Penner Date: Sun, 25 Jul 2021 15:51:39 -0600 Subject: [PATCH] ui(admin): add role select for user management --- app/Models/User.php | 1 + .../Api/Application/UserTransformer.php | 22 +++++++- .../api/admin/databases/searchDatabases.ts | 2 +- .../api/admin/locations/searchLocations.ts | 2 +- .../scripts/api/admin/roles/searchRoles.ts | 24 ++++++++ resources/scripts/api/admin/users/getUsers.ts | 14 ++++- .../scripts/api/admin/users/updateUser.ts | 2 +- resources/scripts/components/GlobalStyles.tsx | 7 +++ .../components/admin/nodes/DatabaseSelect.tsx | 22 +++++--- .../components/admin/nodes/LocationSelect.tsx | 22 +++++--- .../components/admin/servers/OwnerSelect.tsx | 8 ++- .../admin/users/NewUserContainer.tsx | 2 +- .../components/admin/users/RoleSelect.tsx | 55 +++++++++++++++++++ .../admin/users/UserEditContainer.tsx | 21 ++++--- .../components/elements/SearchableSelect.tsx | 48 ++++++++++------ .../components/elements/SelectField.tsx | 2 + 16 files changed, 202 insertions(+), 52 deletions(-) create mode 100644 resources/scripts/api/admin/roles/searchRoles.ts create mode 100644 resources/scripts/components/admin/users/RoleSelect.tsx diff --git a/app/Models/User.php b/app/Models/User.php index 20f45c097..883fa6bf4 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -28,6 +28,7 @@ use Pterodactyl\Notifications\SendPasswordReset as ResetPasswordNotification; * @property string $password * @property string|null $remember_token * @property string $language + * @property int $admin_role_id * @property bool $root_admin * @property bool $use_totp * @property string|null $totp_secret diff --git a/app/Transformers/Api/Application/UserTransformer.php b/app/Transformers/Api/Application/UserTransformer.php index c97ffe49b..0146a0caa 100644 --- a/app/Transformers/Api/Application/UserTransformer.php +++ b/app/Transformers/Api/Application/UserTransformer.php @@ -12,7 +12,7 @@ class UserTransformer extends BaseTransformer * * @var array */ - protected $availableIncludes = ['servers']; + protected $availableIncludes = ['role', 'servers']; /** * Return the resource name for the JSONAPI output. @@ -39,12 +39,32 @@ class UserTransformer extends BaseTransformer 'root_admin' => (bool) $model->root_admin, '2fa' => (bool) $model->use_totp, 'avatar_url' => $model->avatarURL(), + 'admin_role_id' => $model->admin_role_id, 'role_name' => $model->adminRoleName(), 'created_at' => $this->formatTimestamp($model->created_at), 'updated_at' => $this->formatTimestamp($model->updated_at), ]; } + /** + * Return the role associated with this user. + * + * @return \League\Fractal\Resource\Item|\League\Fractal\Resource\NullResource + * + * @throws \Illuminate\Contracts\Container\BindingResolutionException + * @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException + */ + public function includeRole(User $user) + { + if (!$this->authorize(AdminAcl::RESOURCE_ROLES)) { + return $this->null(); + } + + $user->loadMissing('adminRole'); + + return $this->item($user->getRelation('adminRole'), $this->makeTransformer(AdminRoleTransformer::class), 'admin_role'); + } + /** * Return the servers associated with this user. * diff --git a/resources/scripts/api/admin/databases/searchDatabases.ts b/resources/scripts/api/admin/databases/searchDatabases.ts index cb8b86692..99533b5dc 100644 --- a/resources/scripts/api/admin/databases/searchDatabases.ts +++ b/resources/scripts/api/admin/databases/searchDatabases.ts @@ -16,7 +16,7 @@ export default (filters?: Filters): Promise => { } return new Promise((resolve, reject) => { - http.get('/api/application/databases', { params: { ...params } }) + http.get('/api/application/databases', { params }) .then(response => resolve( (response.data.data || []).map(rawDataToDatabase) )) diff --git a/resources/scripts/api/admin/locations/searchLocations.ts b/resources/scripts/api/admin/locations/searchLocations.ts index e87078bf0..6edc50e62 100644 --- a/resources/scripts/api/admin/locations/searchLocations.ts +++ b/resources/scripts/api/admin/locations/searchLocations.ts @@ -16,7 +16,7 @@ export default (filters?: Filters): Promise => { } return new Promise((resolve, reject) => { - http.get('/api/application/locations', { params: { ...params } }) + http.get('/api/application/locations', { params }) .then(response => resolve( (response.data.data || []).map(rawDataToLocation) )) diff --git a/resources/scripts/api/admin/roles/searchRoles.ts b/resources/scripts/api/admin/roles/searchRoles.ts new file mode 100644 index 000000000..2e7bf5030 --- /dev/null +++ b/resources/scripts/api/admin/roles/searchRoles.ts @@ -0,0 +1,24 @@ +import http from '@/api/http'; +import { Role, rawDataToRole } from '@/api/admin/roles/getRoles'; + +interface Filters { + name?: string; +} + +export default (filters?: Filters): Promise => { + const params = {}; + if (filters !== undefined) { + Object.keys(filters).forEach(key => { + // @ts-ignore + params['filter[' + key + ']'] = filters[key]; + }); + } + + return new Promise((resolve, reject) => { + http.get('/api/application/roles', { params }) + .then(response => resolve( + (response.data.data || []).map(rawDataToRole) + )) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/admin/users/getUsers.ts b/resources/scripts/api/admin/users/getUsers.ts index a081c6927..5f11da48d 100644 --- a/resources/scripts/api/admin/users/getUsers.ts +++ b/resources/scripts/api/admin/users/getUsers.ts @@ -2,6 +2,8 @@ import http, { FractalResponseData, getPaginationSet, PaginatedResult } from '@/ import { useContext } from 'react'; import useSWR from 'swr'; import { createContext } from '@/api/admin'; +import { rawDataToDatabase } from '@/api/admin/databases/getDatabases'; +import { Role } from '@/api/admin/roles/getRoles'; export interface User { id: number; @@ -12,13 +14,17 @@ export interface User { firstName: string; lastName: string; language: string; + adminRoleId: number | null; rootAdmin: boolean; tfa: boolean; avatarURL: string; - roleId: number | null; roleName: string | null; createdAt: Date; updatedAt: Date; + + relationships: { + role: Role | undefined; + }; } export const rawDataToUser = ({ attributes }: FractalResponseData): User => ({ @@ -30,13 +36,17 @@ export const rawDataToUser = ({ attributes }: FractalResponseData): User => ({ firstName: attributes.first_name, lastName: attributes.last_name, language: attributes.language, + adminRoleId: attributes.admin_role_id, rootAdmin: attributes.root_admin, tfa: attributes['2fa'], avatarURL: attributes.avatar_url, - roleId: attributes.role_id, roleName: attributes.role_name, createdAt: new Date(attributes.created_at), updatedAt: new Date(attributes.updated_at), + + relationships: { + role: attributes.relationships?.role !== undefined && attributes.relationships?.role.object !== 'null_resource' ? rawDataToDatabase(attributes.relationships.role as FractalResponseData) : undefined, + }, }); export interface Filters { diff --git a/resources/scripts/api/admin/users/updateUser.ts b/resources/scripts/api/admin/users/updateUser.ts index e13f2143f..7baf7c708 100644 --- a/resources/scripts/api/admin/users/updateUser.ts +++ b/resources/scripts/api/admin/users/updateUser.ts @@ -7,7 +7,7 @@ export interface Values { firstName: string; lastName: string; password: string; - roleId: number | null; + adminRoleId: number | null; } export default (id: number, values: Partial, include: string[] = []): Promise => { diff --git a/resources/scripts/components/GlobalStyles.tsx b/resources/scripts/components/GlobalStyles.tsx index 43f613b1f..a68289f7a 100644 --- a/resources/scripts/components/GlobalStyles.tsx +++ b/resources/scripts/components/GlobalStyles.tsx @@ -71,6 +71,13 @@ const CustomStyles = createGlobalStyle` ::-webkit-scrollbar-corner { background: transparent; } + + input[type="search"]::-webkit-search-decoration, + input[type="search"]::-webkit-search-cancel-button, + input[type="search"]::-webkit-search-results-button, + input[type="search"]::-webkit-search-results-decoration { + display: none; + } `; const GlobalStyles = () => ( diff --git a/resources/scripts/components/admin/nodes/DatabaseSelect.tsx b/resources/scripts/components/admin/nodes/DatabaseSelect.tsx index eb19231b6..b6381fbff 100644 --- a/resources/scripts/components/admin/nodes/DatabaseSelect.tsx +++ b/resources/scripts/components/admin/nodes/DatabaseSelect.tsx @@ -12,10 +12,12 @@ export default ({ selected }: { selected: Database | null }) => { const onSearch = (query: string): Promise => { return new Promise((resolve, reject) => { - searchDatabases({ name: query }).then((databases) => { - setDatabases(databases); - return resolve(); - }).catch(reject); + searchDatabases({ name: query }) + .then(databases => { + setDatabases(databases); + return resolve(); + }) + .catch(reject); }); }; @@ -24,14 +26,16 @@ export default ({ selected }: { selected: Database | null }) => { context.setFieldValue('databaseHostId', database?.id || null); }; - const getSelectedText = (database: Database | null): string => { - return database?.name || ''; + const getSelectedText = (database: Database | null): string | undefined => { + return database?.name; }; return ( { nullable > {databases?.map(d => ( - ))} diff --git a/resources/scripts/components/admin/nodes/LocationSelect.tsx b/resources/scripts/components/admin/nodes/LocationSelect.tsx index c0e0670b8..44a76bc10 100644 --- a/resources/scripts/components/admin/nodes/LocationSelect.tsx +++ b/resources/scripts/components/admin/nodes/LocationSelect.tsx @@ -12,10 +12,12 @@ export default ({ selected }: { selected: Location | null }) => { const onSearch = (query: string): Promise => { return new Promise((resolve, reject) => { - searchLocations({ short: query }).then((locations) => { - setLocations(locations); - return resolve(); - }).catch(reject); + searchLocations({ short: query }) + .then(locations => { + setLocations(locations); + return resolve(); + }) + .catch(reject); }); }; @@ -24,14 +26,16 @@ export default ({ selected }: { selected: Location | null }) => { context.setFieldValue('locationId', location?.id || null); }; - const getSelectedText = (location: Location | null): string => { - return location?.short || ''; + const getSelectedText = (location: Location | null): string | undefined => { + return location?.short; }; return ( { nullable > {locations?.map(d => ( - ))} diff --git a/resources/scripts/components/admin/servers/OwnerSelect.tsx b/resources/scripts/components/admin/servers/OwnerSelect.tsx index 603a52403..f04fdcfcd 100644 --- a/resources/scripts/components/admin/servers/OwnerSelect.tsx +++ b/resources/scripts/components/admin/servers/OwnerSelect.tsx @@ -30,8 +30,10 @@ export default ({ selected }: { selected: User | null }) => { return ( { nullable > {users?.map(d => ( - ))} diff --git a/resources/scripts/components/admin/users/NewUserContainer.tsx b/resources/scripts/components/admin/users/NewUserContainer.tsx index 7086efd16..a28449f57 100644 --- a/resources/scripts/components/admin/users/NewUserContainer.tsx +++ b/resources/scripts/components/admin/users/NewUserContainer.tsx @@ -37,7 +37,7 @@ export default () => { - + ); }; diff --git a/resources/scripts/components/admin/users/RoleSelect.tsx b/resources/scripts/components/admin/users/RoleSelect.tsx new file mode 100644 index 000000000..7547945fb --- /dev/null +++ b/resources/scripts/components/admin/users/RoleSelect.tsx @@ -0,0 +1,55 @@ +import React, { useState } from 'react'; +import { useFormikContext } from 'formik'; +import { Role } from '@/api/admin/roles/getRoles'; +import searchRoles from '@/api/admin/roles/searchRoles'; +import SearchableSelect, { Option } from '@/components/elements/SearchableSelect'; + +export default ({ selected }: { selected: Role | null }) => { + const context = useFormikContext(); + + const [ role, setRole ] = useState(selected); + const [ roles, setRoles ] = useState(null); + + const onSearch = (query: string): Promise => { + return new Promise((resolve, reject) => { + searchRoles({ name: query }) + .then(roles => { + setRoles(roles); + return resolve(); + }) + .catch(reject); + }); + }; + + const onSelect = (role: Role | null) => { + setRole(role); + context.setFieldValue('adminRoleId', role?.id || null); + }; + + const getSelectedText = (role: Role | null): string | undefined => { + return role?.name; + }; + + return ( + + {roles?.map(d => ( + + ))} + + ); +}; diff --git a/resources/scripts/components/admin/users/UserEditContainer.tsx b/resources/scripts/components/admin/users/UserEditContainer.tsx index 06c12d012..d051c039f 100644 --- a/resources/scripts/components/admin/users/UserEditContainer.tsx +++ b/resources/scripts/components/admin/users/UserEditContainer.tsx @@ -12,9 +12,11 @@ import AdminBox from '@/components/admin/AdminBox'; import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; import { Form, Formik, FormikHelpers } from 'formik'; import { object, string } from 'yup'; -import Field from '@/components/elements/Field'; -import Button from '@/components/elements/Button'; +import { Role } from '@/api/admin/roles/getRoles'; import updateUser, { Values } from '@/api/admin/users/updateUser'; +import Button from '@/components/elements/Button'; +import Field from '@/components/elements/Field'; +import RoleSelect from '@/components/admin/users/RoleSelect'; import UserDeleteButton from '@/components/admin/users/UserDeleteButton'; interface ctx { @@ -37,9 +39,11 @@ export interface Params { onSubmit: (values: Values, helpers: FormikHelpers) => void; exists?: boolean; + + role: Role | null; } -export function InformationContainer ({ title, initialValues, children, onSubmit, exists }: Params) { +export function InformationContainer ({ title, initialValues, children, onSubmit, exists, role }: Params) { const submit = (values: Values, helpers: FormikHelpers) => { onSubmit(values, helpers); }; @@ -51,7 +55,7 @@ export function InformationContainer ({ title, initialValues, children, onSubmit firstName: '', lastName: '', password: '', - roleId: 0, + adminRoleId: 0, }; } @@ -126,7 +130,9 @@ export function InformationContainer ({ title, initialValues, children, onSubmit /> -
+
+ +
@@ -180,10 +186,11 @@ function EditInformationContainer () { email: user.email, firstName: user.firstName, lastName: user.lastName, - roleId: user.roleId, + adminRoleId: user.adminRoleId, password: '', }} onSubmit={submit} + role={user?.relationships.role || null} exists >
@@ -208,7 +215,7 @@ function UserEditContainer () { useEffect(() => { clearFlashes('user'); - getUser(Number(match.params?.id)) + getUser(Number(match.params?.id), [ 'role' ]) .then(user => setUser(user)) .catch(error => { console.error(error); diff --git a/resources/scripts/components/elements/SearchableSelect.tsx b/resources/scripts/components/elements/SearchableSelect.tsx index 485ddeb77..a8ef62e2a 100644 --- a/resources/scripts/components/elements/SearchableSelect.tsx +++ b/resources/scripts/components/elements/SearchableSelect.tsx @@ -1,9 +1,9 @@ -import React, { createRef, ReactElement, useEffect, useState } from 'react'; import { debounce } from 'debounce'; +import React, { createRef, ReactElement, useEffect, useState } from 'react'; import tw, { styled } from 'twin.macro'; import Input from '@/components/elements/Input'; -import Label from '@/components/elements/Label'; import InputSpinner from '@/components/elements/InputSpinner'; +import Label from '@/components/elements/Label'; const Dropdown = styled.div<{ expanded: boolean }>` ${tw`absolute z-10 w-full mt-1 rounded-md shadow-lg bg-neutral-900`}; @@ -69,7 +69,9 @@ export const Option = ({ selectId, id, item, active, isHighligh interface SearchableSelectProps { id: string; name: string; - nullable: boolean; + label: string; + placeholder?: string; + nullable?: boolean; selected: T | null; setSelected: (item: T | null) => void; @@ -80,12 +82,13 @@ interface SearchableSelectProps { onSearch: (query: string) => Promise; onSelect: (item: T | null) => void; - getSelectedText: (item: T | null) => string; + getSelectedText: (item: T | null) => string | undefined; children: React.ReactNode; + className?: string; } -export const SearchableSelect = ({ id, name, selected, setSelected, items, setItems, onSearch, onSelect, getSelectedText, children }: SearchableSelectProps) => { +export const SearchableSelect = ({ id, name, label, placeholder, selected, setSelected, items, setItems, onSearch, onSelect, getSelectedText, children, className }: SearchableSelectProps) => { const [ loading, setLoading ] = useState(false); const [ expanded, setExpanded ] = useState(false); @@ -144,7 +147,7 @@ export const SearchableSelect = ({ id, name, selected, setSelec return; } - const item = items.find((item) => item.id === highlighted); + const item = items.find(i => i.id === highlighted); if (!item) { return; } @@ -169,7 +172,7 @@ export const SearchableSelect = ({ id, name, selected, setSelec if (e.key === 'Enter') { e.preventDefault(); - const item = items.find((item) => item.id === highlighted); + const item = items.find(i => i.id === highlighted); if (!item) { return; } @@ -210,10 +213,10 @@ export const SearchableSelect = ({ id, name, selected, setSelec onBlur(); }; - window.addEventListener('click', clickHandler); + window.addEventListener('mousedown', clickHandler); window.addEventListener('contextmenu', contextmenuHandler); return () => { - window.removeEventListener('click', clickHandler); + window.removeEventListener('mousedown', clickHandler); window.removeEventListener('contextmenu', contextmenuHandler); }; }, [ expanded ]); @@ -240,17 +243,16 @@ export const SearchableSelect = ({ id, name, selected, setSelec })); return ( -
- +
+
({ id, name, selected, setSelec search(e.currentTarget.value); }} onKeyDown={handleInputKeydown} + className={'ignoreReadOnly'} + placeholder={placeholder} /> -
-
+ {inputText !== '' && expanded && + { + e.preventDefault(); + setInputText(''); + }} + > + + + } +
- { items === null || items.length < 1 ? + {items === null || items.length < 1 ? items === null || inputText.length < 2 ?

Please type 2 or more characters.

diff --git a/resources/scripts/components/elements/SelectField.tsx b/resources/scripts/components/elements/SelectField.tsx index f1ade2e6a..dc251006c 100644 --- a/resources/scripts/components/elements/SelectField.tsx +++ b/resources/scripts/components/elements/SelectField.tsx @@ -224,12 +224,14 @@ interface Props { name: string; label?: string; description?: string; + placeholder?: string; validate?: (value: any) => undefined | string | Promise; options: Array