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|null $remember_token
* @property string $language
* @property int $admin_role_id
* @property bool $root_admin
* @property bool $use_totp
* @property string|null $totp_secret

View file

@ -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.
*

View file

@ -16,7 +16,7 @@ export default (filters?: Filters): Promise<Database[]> => {
}
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)
))

View file

@ -16,7 +16,7 @@ export default (filters?: Filters): Promise<Location[]> => {
}
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)
))

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 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 {

View file

@ -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<Values>, include: string[] = []): Promise<User> => {

View file

@ -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 = () => (

View file

@ -12,10 +12,12 @@ export default ({ selected }: { selected: Database | null }) => {
const onSearch = (query: string): Promise<void> => {
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 (
<SearchableSelect
id="database"
name="Database"
id={'databaseId'}
name={'databaseId'}
label={'Database'}
placeholder={'Select a database...'}
items={databases}
selected={database}
setSelected={setDatabase}
@ -42,7 +46,7 @@ export default ({ selected }: { selected: Database | null }) => {
nullable
>
{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}
</Option>
))}

View file

@ -12,10 +12,12 @@ export default ({ selected }: { selected: Location | null }) => {
const onSearch = (query: string): Promise<void> => {
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 (
<SearchableSelect
id="location"
name="Location"
id={'locationId'}
name={'locationId'}
label={'Location'}
placeholder={'Select a location...'}
items={locations}
selected={location}
setSelected={setLocation}
@ -42,7 +46,7 @@ export default ({ selected }: { selected: Location | null }) => {
nullable
>
{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}
</Option>
))}

View file

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

View file

@ -37,7 +37,7 @@ export default () => {
<FlashMessageRender byKey={'user:create'} css={tw`mb-4`}/>
<InformationContainer title={'Create User'} onSubmit={submit}/>
<InformationContainer title={'Create User'} onSubmit={submit} role={null}/>
</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 { 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<Values>) => 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<Values>) => {
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
/>
</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 css={tw`w-full flex flex-row items-center mt-6`}>
@ -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
>
<div css={tw`flex`}>
@ -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);

View file

@ -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 = <T extends IdObj>({ selectId, id, item, active, isHighligh
interface SearchableSelectProps<T> {
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<T> {
onSearch: (query: string) => Promise<void>;
onSelect: (item: T | null) => void;
getSelectedText: (item: T | null) => string;
getSelectedText: (item: T | null) => string | undefined;
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 [ expanded, setExpanded ] = useState(false);
@ -144,7 +147,7 @@ export const SearchableSelect = <T extends IdObj>({ 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 = <T extends IdObj>({ 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 = <T extends IdObj>({ 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 = <T extends IdObj>({ id, name, selected, setSelec
}));
return (
<div>
<Label htmlFor={id + '-select-label'}>{name}</Label>
<div className={className}>
<Label htmlFor={id + '-select-label'}>{label}</Label>
<div css={tw`relative mt-1`}>
<InputSpinner visible={loading}>
<Input
ref={searchInput}
type="text"
className="ignoreReadOnly"
type={'search'}
id={id}
name={id}
name={name}
value={inputText}
readOnly={!expanded}
onFocus={onFocus}
@ -259,17 +261,29 @@ export const SearchableSelect = <T extends IdObj>({ id, name, selected, setSelec
search(e.currentTarget.value);
}}
onKeyDown={handleInputKeydown}
className={'ignoreReadOnly'}
placeholder={placeholder}
/>
</InputSpinner>
<div css={tw`absolute inset-y-0 right-0 flex items-center pr-2 ml-3 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">
<div css={[ tw`absolute inset-y-0 right-0 flex items-center pr-2 ml-3`, !expanded && tw`pointer-events-none` ]}>
{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"/>
</svg>
</div>
<Dropdown ref={itemsList} expanded={expanded}>
{ items === null || items.length < 1 ?
{items === null || items.length < 1 ?
items === null || inputText.length < 2 ?
<div css={tw`flex flex-row items-center h-10 px-3`}>
<p css={tw`text-sm`}>Please type 2 or more characters.</p>

View file

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