ui(admin): add role select for user management

This commit is contained in:
Matthew Penner 2021-07-25 15:51:39 -06:00
parent 58f0bbbb9b
commit 25feeaa9f5
16 changed files with 202 additions and 52 deletions

View File

@ -28,6 +28,7 @@ use Pterodactyl\Notifications\SendPasswordReset as ResetPasswordNotification;
* @property string $password * @property string $password
* @property string|null $remember_token * @property string|null $remember_token
* @property string $language * @property string $language
* @property int $admin_role_id
* @property bool $root_admin * @property bool $root_admin
* @property bool $use_totp * @property bool $use_totp
* @property string|null $totp_secret * @property string|null $totp_secret

View File

@ -12,7 +12,7 @@ class UserTransformer extends BaseTransformer
* *
* @var array * @var array
*/ */
protected $availableIncludes = ['servers']; protected $availableIncludes = ['role', 'servers'];
/** /**
* Return the resource name for the JSONAPI output. * Return the resource name for the JSONAPI output.
@ -39,12 +39,32 @@ class UserTransformer extends BaseTransformer
'root_admin' => (bool) $model->root_admin, 'root_admin' => (bool) $model->root_admin,
'2fa' => (bool) $model->use_totp, '2fa' => (bool) $model->use_totp,
'avatar_url' => $model->avatarURL(), 'avatar_url' => $model->avatarURL(),
'admin_role_id' => $model->admin_role_id,
'role_name' => $model->adminRoleName(), 'role_name' => $model->adminRoleName(),
'created_at' => $this->formatTimestamp($model->created_at), 'created_at' => $this->formatTimestamp($model->created_at),
'updated_at' => $this->formatTimestamp($model->updated_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. * Return the servers associated with this user.
* *

View File

@ -16,7 +16,7 @@ export default (filters?: Filters): Promise<Database[]> => {
} }
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
http.get('/api/application/databases', { params: { ...params } }) http.get('/api/application/databases', { params })
.then(response => resolve( .then(response => resolve(
(response.data.data || []).map(rawDataToDatabase) (response.data.data || []).map(rawDataToDatabase)
)) ))

View File

@ -16,7 +16,7 @@ export default (filters?: Filters): Promise<Location[]> => {
} }
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
http.get('/api/application/locations', { params: { ...params } }) http.get('/api/application/locations', { params })
.then(response => resolve( .then(response => resolve(
(response.data.data || []).map(rawDataToLocation) (response.data.data || []).map(rawDataToLocation)
)) ))

View File

@ -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<Role[]> => {
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);
});
};

View File

@ -2,6 +2,8 @@ import http, { FractalResponseData, getPaginationSet, PaginatedResult } from '@/
import { useContext } from 'react'; import { useContext } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { createContext } from '@/api/admin'; import { createContext } from '@/api/admin';
import { rawDataToDatabase } from '@/api/admin/databases/getDatabases';
import { Role } from '@/api/admin/roles/getRoles';
export interface User { export interface User {
id: number; id: number;
@ -12,13 +14,17 @@ export interface User {
firstName: string; firstName: string;
lastName: string; lastName: string;
language: string; language: string;
adminRoleId: number | null;
rootAdmin: boolean; rootAdmin: boolean;
tfa: boolean; tfa: boolean;
avatarURL: string; avatarURL: string;
roleId: number | null;
roleName: string | null; roleName: string | null;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
relationships: {
role: Role | undefined;
};
} }
export const rawDataToUser = ({ attributes }: FractalResponseData): User => ({ export const rawDataToUser = ({ attributes }: FractalResponseData): User => ({
@ -30,13 +36,17 @@ export const rawDataToUser = ({ attributes }: FractalResponseData): User => ({
firstName: attributes.first_name, firstName: attributes.first_name,
lastName: attributes.last_name, lastName: attributes.last_name,
language: attributes.language, language: attributes.language,
adminRoleId: attributes.admin_role_id,
rootAdmin: attributes.root_admin, rootAdmin: attributes.root_admin,
tfa: attributes['2fa'], tfa: attributes['2fa'],
avatarURL: attributes.avatar_url, avatarURL: attributes.avatar_url,
roleId: attributes.role_id,
roleName: attributes.role_name, roleName: attributes.role_name,
createdAt: new Date(attributes.created_at), createdAt: new Date(attributes.created_at),
updatedAt: new Date(attributes.updated_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 { export interface Filters {

View File

@ -7,7 +7,7 @@ export interface Values {
firstName: string; firstName: string;
lastName: string; lastName: string;
password: string; password: string;
roleId: number | null; adminRoleId: number | null;
} }
export default (id: number, values: Partial<Values>, include: string[] = []): Promise<User> => { export default (id: number, values: Partial<Values>, include: string[] = []): Promise<User> => {

View File

@ -71,6 +71,13 @@ const CustomStyles = createGlobalStyle`
::-webkit-scrollbar-corner { ::-webkit-scrollbar-corner {
background: transparent; 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 = () => ( const GlobalStyles = () => (

View File

@ -12,10 +12,12 @@ export default ({ selected }: { selected: Database | null }) => {
const onSearch = (query: string): Promise<void> => { const onSearch = (query: string): Promise<void> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
searchDatabases({ name: query }).then((databases) => { searchDatabases({ name: query })
setDatabases(databases); .then(databases => {
return resolve(); setDatabases(databases);
}).catch(reject); return resolve();
})
.catch(reject);
}); });
}; };
@ -24,14 +26,16 @@ export default ({ selected }: { selected: Database | null }) => {
context.setFieldValue('databaseHostId', database?.id || null); context.setFieldValue('databaseHostId', database?.id || null);
}; };
const getSelectedText = (database: Database | null): string => { const getSelectedText = (database: Database | null): string | undefined => {
return database?.name || ''; return database?.name;
}; };
return ( return (
<SearchableSelect <SearchableSelect
id="database" id={'databaseId'}
name="Database" name={'databaseId'}
label={'Database'}
placeholder={'Select a database...'}
items={databases} items={databases}
selected={database} selected={database}
setSelected={setDatabase} setSelected={setDatabase}
@ -42,7 +46,7 @@ export default ({ selected }: { selected: Database | null }) => {
nullable nullable
> >
{databases?.map(d => ( {databases?.map(d => (
<Option key={d.id} selectId="database" id={d.id} item={d} active={d.id === database?.id}> <Option key={d.id} selectId={'databaseId'} id={d.id} item={d} active={d.id === database?.id}>
{d.name} {d.name}
</Option> </Option>
))} ))}

View File

@ -12,10 +12,12 @@ export default ({ selected }: { selected: Location | null }) => {
const onSearch = (query: string): Promise<void> => { const onSearch = (query: string): Promise<void> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
searchLocations({ short: query }).then((locations) => { searchLocations({ short: query })
setLocations(locations); .then(locations => {
return resolve(); setLocations(locations);
}).catch(reject); return resolve();
})
.catch(reject);
}); });
}; };
@ -24,14 +26,16 @@ export default ({ selected }: { selected: Location | null }) => {
context.setFieldValue('locationId', location?.id || null); context.setFieldValue('locationId', location?.id || null);
}; };
const getSelectedText = (location: Location | null): string => { const getSelectedText = (location: Location | null): string | undefined => {
return location?.short || ''; return location?.short;
}; };
return ( return (
<SearchableSelect <SearchableSelect
id="location" id={'locationId'}
name="Location" name={'locationId'}
label={'Location'}
placeholder={'Select a location...'}
items={locations} items={locations}
selected={location} selected={location}
setSelected={setLocation} setSelected={setLocation}
@ -42,7 +46,7 @@ export default ({ selected }: { selected: Location | null }) => {
nullable nullable
> >
{locations?.map(d => ( {locations?.map(d => (
<Option key={d.id} selectId="location" id={d.id} item={d} active={d.id === location?.id}> <Option key={d.id} selectId={'locationId'} id={d.id} item={d} active={d.id === location?.id}>
{d.short} {d.short}
</Option> </Option>
))} ))}

View File

@ -30,8 +30,10 @@ export default ({ selected }: { selected: User | null }) => {
return ( return (
<SearchableSelect <SearchableSelect
id="user" id={'ownerId'}
name="Owner" name={'ownerId'}
label={'Owner'}
placeholder={'Select a user...'}
items={users} items={users}
selected={user} selected={user}
setSelected={setUser} setSelected={setUser}
@ -42,7 +44,7 @@ export default ({ selected }: { selected: User | null }) => {
nullable nullable
> >
{users?.map(d => ( {users?.map(d => (
<Option key={d.id} selectId="user" id={d.id} item={d} active={d.id === user?.id}> <Option key={d.id} selectId={'ownerId'} id={d.id} item={d} active={d.id === user?.id}>
{d.username} {d.username}
</Option> </Option>
))} ))}

View File

@ -37,7 +37,7 @@ export default () => {
<FlashMessageRender byKey={'user:create'} css={tw`mb-4`}/> <FlashMessageRender byKey={'user:create'} css={tw`mb-4`}/>
<InformationContainer title={'Create User'} onSubmit={submit}/> <InformationContainer title={'Create User'} onSubmit={submit} role={null}/>
</AdminContentBlock> </AdminContentBlock>
); );
}; };

View File

@ -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<Role | null>(selected);
const [ roles, setRoles ] = useState<Role[] | null>(null);
const onSearch = (query: string): Promise<void> => {
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 (
<SearchableSelect
id={'adminRoleId'}
name={'adminRoleId'}
label={'Role'}
placeholder={'Select a role...'}
items={roles}
selected={role}
setSelected={setRole}
setItems={setRoles}
onSearch={onSearch}
onSelect={onSelect}
getSelectedText={getSelectedText}
nullable
>
{roles?.map(d => (
<Option key={d.id} selectId={'adminRoleId'} id={d.id} item={d} active={d.id === role?.id}>
{d.name}
</Option>
))}
</SearchableSelect>
);
};

View File

@ -12,9 +12,11 @@ import AdminBox from '@/components/admin/AdminBox';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import { Form, Formik, FormikHelpers } from 'formik'; import { Form, Formik, FormikHelpers } from 'formik';
import { object, string } from 'yup'; import { object, string } from 'yup';
import Field from '@/components/elements/Field'; import { Role } from '@/api/admin/roles/getRoles';
import Button from '@/components/elements/Button';
import updateUser, { Values } from '@/api/admin/users/updateUser'; 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'; import UserDeleteButton from '@/components/admin/users/UserDeleteButton';
interface ctx { interface ctx {
@ -37,9 +39,11 @@ export interface Params {
onSubmit: (values: Values, helpers: FormikHelpers<Values>) => void; onSubmit: (values: Values, helpers: FormikHelpers<Values>) => void;
exists?: boolean; 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<Values>) => { const submit = (values: Values, helpers: FormikHelpers<Values>) => {
onSubmit(values, helpers); onSubmit(values, helpers);
}; };
@ -51,7 +55,7 @@ export function InformationContainer ({ title, initialValues, children, onSubmit
firstName: '', firstName: '',
lastName: '', lastName: '',
password: '', password: '',
roleId: 0, adminRoleId: 0,
}; };
} }
@ -126,7 +130,9 @@ export function InformationContainer ({ title, initialValues, children, onSubmit
/> />
</div> </div>
<div css={tw`md:w-full md:flex md:flex-col md:ml-4 mt-6 md:mt-0`}/> <div css={tw`md:w-full md:flex md:flex-col md:ml-4 mt-6 md:mt-0`}>
<RoleSelect selected={role}/>
</div>
</div> </div>
<div css={tw`w-full flex flex-row items-center mt-6`}> <div css={tw`w-full flex flex-row items-center mt-6`}>
@ -180,10 +186,11 @@ function EditInformationContainer () {
email: user.email, email: user.email,
firstName: user.firstName, firstName: user.firstName,
lastName: user.lastName, lastName: user.lastName,
roleId: user.roleId, adminRoleId: user.adminRoleId,
password: '', password: '',
}} }}
onSubmit={submit} onSubmit={submit}
role={user?.relationships.role || null}
exists exists
> >
<div css={tw`flex`}> <div css={tw`flex`}>
@ -208,7 +215,7 @@ function UserEditContainer () {
useEffect(() => { useEffect(() => {
clearFlashes('user'); clearFlashes('user');
getUser(Number(match.params?.id)) getUser(Number(match.params?.id), [ 'role' ])
.then(user => setUser(user)) .then(user => setUser(user))
.catch(error => { .catch(error => {
console.error(error); console.error(error);

View File

@ -1,9 +1,9 @@
import React, { createRef, ReactElement, useEffect, useState } from 'react';
import { debounce } from 'debounce'; import { debounce } from 'debounce';
import React, { createRef, ReactElement, useEffect, useState } from 'react';
import tw, { styled } from 'twin.macro'; import tw, { styled } from 'twin.macro';
import Input from '@/components/elements/Input'; import Input from '@/components/elements/Input';
import Label from '@/components/elements/Label';
import InputSpinner from '@/components/elements/InputSpinner'; import InputSpinner from '@/components/elements/InputSpinner';
import Label from '@/components/elements/Label';
const Dropdown = styled.div<{ expanded: boolean }>` const Dropdown = styled.div<{ expanded: boolean }>`
${tw`absolute z-10 w-full mt-1 rounded-md shadow-lg bg-neutral-900`}; ${tw`absolute z-10 w-full mt-1 rounded-md shadow-lg bg-neutral-900`};
@ -69,7 +69,9 @@ export const Option = <T extends IdObj>({ selectId, id, item, active, isHighligh
interface SearchableSelectProps<T> { interface SearchableSelectProps<T> {
id: string; id: string;
name: string; name: string;
nullable: boolean; label: string;
placeholder?: string;
nullable?: boolean;
selected: T | null; selected: T | null;
setSelected: (item: T | null) => void; setSelected: (item: T | null) => void;
@ -80,12 +82,13 @@ interface SearchableSelectProps<T> {
onSearch: (query: string) => Promise<void>; onSearch: (query: string) => Promise<void>;
onSelect: (item: T | null) => void; onSelect: (item: T | null) => void;
getSelectedText: (item: T | null) => string; getSelectedText: (item: T | null) => string | undefined;
children: React.ReactNode; children: React.ReactNode;
className?: string;
} }
export const SearchableSelect = <T extends IdObj>({ id, name, selected, setSelected, items, setItems, onSearch, onSelect, getSelectedText, children }: SearchableSelectProps<T>) => { export const SearchableSelect = <T extends IdObj>({ id, name, label, placeholder, selected, setSelected, items, setItems, onSearch, onSelect, getSelectedText, children, className }: SearchableSelectProps<T>) => {
const [ loading, setLoading ] = useState(false); const [ loading, setLoading ] = useState(false);
const [ expanded, setExpanded ] = useState(false); const [ expanded, setExpanded ] = useState(false);
@ -144,7 +147,7 @@ export const SearchableSelect = <T extends IdObj>({ id, name, selected, setSelec
return; return;
} }
const item = items.find((item) => item.id === highlighted); const item = items.find(i => i.id === highlighted);
if (!item) { if (!item) {
return; return;
} }
@ -169,7 +172,7 @@ export const SearchableSelect = <T extends IdObj>({ id, name, selected, setSelec
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
const item = items.find((item) => item.id === highlighted); const item = items.find(i => i.id === highlighted);
if (!item) { if (!item) {
return; return;
} }
@ -210,10 +213,10 @@ export const SearchableSelect = <T extends IdObj>({ id, name, selected, setSelec
onBlur(); onBlur();
}; };
window.addEventListener('click', clickHandler); window.addEventListener('mousedown', clickHandler);
window.addEventListener('contextmenu', contextmenuHandler); window.addEventListener('contextmenu', contextmenuHandler);
return () => { return () => {
window.removeEventListener('click', clickHandler); window.removeEventListener('mousedown', clickHandler);
window.removeEventListener('contextmenu', contextmenuHandler); window.removeEventListener('contextmenu', contextmenuHandler);
}; };
}, [ expanded ]); }, [ expanded ]);
@ -240,17 +243,16 @@ export const SearchableSelect = <T extends IdObj>({ id, name, selected, setSelec
})); }));
return ( return (
<div> <div className={className}>
<Label htmlFor={id + '-select-label'}>{name}</Label> <Label htmlFor={id + '-select-label'}>{label}</Label>
<div css={tw`relative mt-1`}> <div css={tw`relative mt-1`}>
<InputSpinner visible={loading}> <InputSpinner visible={loading}>
<Input <Input
ref={searchInput} ref={searchInput}
type="text" type={'search'}
className="ignoreReadOnly"
id={id} id={id}
name={id} name={name}
value={inputText} value={inputText}
readOnly={!expanded} readOnly={!expanded}
onFocus={onFocus} onFocus={onFocus}
@ -259,17 +261,29 @@ export const SearchableSelect = <T extends IdObj>({ id, name, selected, setSelec
search(e.currentTarget.value); search(e.currentTarget.value);
}} }}
onKeyDown={handleInputKeydown} onKeyDown={handleInputKeydown}
className={'ignoreReadOnly'}
placeholder={placeholder}
/> />
</InputSpinner> </InputSpinner>
<div css={tw`absolute inset-y-0 right-0 flex items-center pr-2 ml-3 pointer-events-none`}> <div css={[ tw`absolute inset-y-0 right-0 flex items-center pr-2 ml-3`, !expanded && tw`pointer-events-none` ]}>
<svg css={tw`w-5 h-5 text-neutral-400`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> {inputText !== '' && expanded &&
<svg css={tw`w-5 h-5 text-neutral-400 cursor-pointer`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"
onMouseDown={e => {
e.preventDefault();
setInputText('');
}}
>
<path clipRule="evenodd" fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"/>
</svg>
}
<svg css={tw`w-5 h-5 text-neutral-400 pointer-events-none`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path clipRule="evenodd" fillRule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"/> <path clipRule="evenodd" fillRule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"/>
</svg> </svg>
</div> </div>
<Dropdown ref={itemsList} expanded={expanded}> <Dropdown ref={itemsList} expanded={expanded}>
{ items === null || items.length < 1 ? {items === null || items.length < 1 ?
items === null || inputText.length < 2 ? items === null || inputText.length < 2 ?
<div css={tw`flex flex-row items-center h-10 px-3`}> <div css={tw`flex flex-row items-center h-10 px-3`}>
<p css={tw`text-sm`}>Please type 2 or more characters.</p> <p css={tw`text-sm`}>Please type 2 or more characters.</p>

View File

@ -224,12 +224,14 @@ interface Props {
name: string; name: string;
label?: string; label?: string;
description?: string; description?: string;
placeholder?: string;
validate?: (value: any) => undefined | string | Promise<any>; validate?: (value: any) => undefined | string | Promise<any>;
options: Array<Option>; options: Array<Option>;
isMulti?: boolean; isMulti?: boolean;
isSearchable?: boolean; isSearchable?: boolean;
isCreatable?: boolean; isCreatable?: boolean;
isValidNewOption?: (( isValidNewOption?: ((
inputValue: string, inputValue: string,