Add support for deleting a subuser from a server

This commit is contained in:
Dane Everitt 2020-03-27 15:32:33 -07:00
parent a6f46d36ba
commit 1270e51248
No known key found for this signature in database
GPG Key ID: EEA66103B3D71F53
11 changed files with 158 additions and 107 deletions

View File

@ -83,12 +83,13 @@ class SubuserController extends ClientApiController
* Update a given subuser in the system for the server. * Update a given subuser in the system for the server.
* *
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Subusers\UpdateSubuserRequest $request * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Subusers\UpdateSubuserRequest $request
* * @param \Pterodactyl\Models\Server $server
* @return array * @return array
*
* @throws \Pterodactyl\Exceptions\Model\DataValidationException * @throws \Pterodactyl\Exceptions\Model\DataValidationException
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
*/ */
public function update(UpdateSubuserRequest $request) public function update(UpdateSubuserRequest $request, Server $server): array
{ {
$subuser = $request->subuser(); $subuser = $request->subuser();
$this->repository->update($subuser->id, [ $this->repository->update($subuser->id, [
@ -104,9 +105,10 @@ class SubuserController extends ClientApiController
* Removes a subusers from a server's assignment. * Removes a subusers from a server's assignment.
* *
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Subusers\DeleteSubuserRequest $request * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Subusers\DeleteSubuserRequest $request
* @param \Pterodactyl\Models\Server $server
* @return \Illuminate\Http\JsonResponse * @return \Illuminate\Http\JsonResponse
*/ */
public function delete(DeleteSubuserRequest $request) public function delete(DeleteSubuserRequest $request, Server $server)
{ {
$this->repository->delete($request->subuser()->id); $this->repository->delete($request->subuser()->id);

View File

@ -57,7 +57,7 @@ abstract class AbstractSubuserRequest extends ClientApiRequest
} }
return $this->model ?: $this->model = $repository->getUserForServer( return $this->model ?: $this->model = $repository->getUserForServer(
$this->route()->parameter('subuser'), $this->route()->parameter('server')->id $parameters['server']->id, $parameters['subuser']
); );
} }
} }

View File

@ -0,0 +1,9 @@
import http from '@/api/http';
export default (uuid: string, userId: string): Promise<void> => {
return new Promise((resolve, reject) => {
http.delete(`/api/client/servers/${uuid}/users/${userId}`)
.then(() => resolve())
.catch(reject);
});
};

View File

@ -62,7 +62,7 @@ export default () => {
doDeletion(deleteIdentifier); doDeletion(deleteIdentifier);
setDeleteIdentifier(''); setDeleteIdentifier('');
}} }}
onCanceled={() => setDeleteIdentifier('')} onDismissed={() => setDeleteIdentifier('')}
> >
Are you sure you wish to delete this API key? All requests using it will immediately be Are you sure you wish to delete this API key? All requests using it will immediately be
invalidated and will fail. invalidated and will fail.

View File

@ -1,25 +1,25 @@
import React from 'react'; import React from 'react';
import Modal from '@/components/elements/Modal'; import Modal, { RequiredModalProps } from '@/components/elements/Modal';
interface Props { type Props = {
title: string; title: string;
buttonText: string; buttonText: string;
children: string; children: string;
visible: boolean;
onConfirmed: () => void; onConfirmed: () => void;
onCanceled: () => void; showSpinnerOverlay?: boolean;
} } & RequiredModalProps;
const ConfirmationModal = ({ title, children, visible, buttonText, onConfirmed, onCanceled }: Props) => ( const ConfirmationModal = ({ title, appear, children, visible, buttonText, onConfirmed, showSpinnerOverlay, onDismissed }: Props) => (
<Modal <Modal
appear={true} appear={appear || true}
visible={visible} visible={visible}
onDismissed={() => onCanceled()} showSpinnerOverlay={showSpinnerOverlay}
onDismissed={() => onDismissed()}
> >
<h3 className={'mb-6'}>{title}</h3> <h3 className={'mb-6'}>{title}</h3>
<p className={'text-sm'}>{children}</p> <p className={'text-sm'}>{children}</p>
<div className={'flex items-center justify-end mt-8'}> <div className={'flex items-center justify-end mt-8'}>
<button className={'btn btn-secondary btn-sm'} onClick={() => onCanceled()}> <button className={'btn btn-secondary btn-sm'} onClick={() => onDismissed()}>
Cancel Cancel
</button> </button>
<button className={'btn btn-red btn-sm ml-4'} onClick={() => onConfirmed()}> <button className={'btn btn-red btn-sm ml-4'} onClick={() => onConfirmed()}>

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTimes } from '@fortawesome/free-solid-svg-icons/faTimes'; import { faTimes } from '@fortawesome/free-solid-svg-icons/faTimes';
import { CSSTransition } from 'react-transition-group'; import { CSSTransition } from 'react-transition-group';
@ -18,16 +18,22 @@ type Props = RequiredModalProps & {
children: React.ReactNode; children: React.ReactNode;
} }
export default (props: Props) => { export default ({ visible, appear, dismissable, showSpinnerOverlay, closeOnBackground = true, closeOnEscape = true, onDismissed, children }: Props) => {
const [render, setRender] = useState(props.visible); const [render, setRender] = useState(visible);
const isDismissable = useMemo(() => {
return (dismissable || true) && !(showSpinnerOverlay || false);
}, [dismissable, showSpinnerOverlay]);
const handleEscapeEvent = (e: KeyboardEvent) => { const handleEscapeEvent = (e: KeyboardEvent) => {
if (props.dismissable !== false && props.closeOnEscape !== false && e.key === 'Escape') { if (isDismissable && closeOnEscape && e.key === 'Escape') {
setRender(false); setRender(false);
} }
}; };
useEffect(() => setRender(props.visible), [props.visible]); useEffect(() => {
setRender(visible);
}, [visible]);
useEffect(() => { useEffect(() => {
window.addEventListener('keydown', handleEscapeEvent); window.addEventListener('keydown', handleEscapeEvent);
@ -39,13 +45,13 @@ export default (props: Props) => {
<CSSTransition <CSSTransition
timeout={250} timeout={250}
classNames={'fade'} classNames={'fade'}
appear={props.appear} appear={appear}
in={render} in={render}
unmountOnExit={true} unmountOnExit={true}
onExited={() => props.onDismissed()} onExited={() => onDismissed()}
> >
<div className={'modal-mask'} onClick={e => { <div className={'modal-mask'} onClick={e => {
if (props.dismissable !== false && props.closeOnBackground !== false) { if (isDismissable && closeOnBackground) {
e.stopPropagation(); e.stopPropagation();
if (e.target === e.currentTarget) { if (e.target === e.currentTarget) {
setRender(false); setRender(false);
@ -53,12 +59,12 @@ export default (props: Props) => {
} }
}}> }}>
<div className={'modal-container top'}> <div className={'modal-container top'}>
{props.dismissable !== false && {isDismissable &&
<div className={'modal-close-icon'} onClick={() => setRender(false)}> <div className={'modal-close-icon'} onClick={() => setRender(false)}>
<FontAwesomeIcon icon={faTimes}/> <FontAwesomeIcon icon={faTimes}/>
</div> </div>
} }
{props.showSpinnerOverlay && {showSpinnerOverlay &&
<div <div
className={'absolute w-full h-full rounded flex items-center justify-center'} className={'absolute w-full h-full rounded flex items-center justify-center'}
style={{ background: 'hsla(211, 10%, 53%, 0.25)' }} style={{ background: 'hsla(211, 10%, 53%, 0.25)' }}
@ -67,7 +73,7 @@ export default (props: Props) => {
</div> </div>
} }
<div className={'modal-content p-6'}> <div className={'modal-content p-6'}>
{props.children} {children}
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,46 +0,0 @@
import React from 'react';
import { SubuserPermission } from '@/state/server/subusers';
import { useStoreState } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import { useTranslation } from 'react-i18next';
interface Props {
defaultPermissions: SubuserPermission[];
}
export default ({ defaultPermissions }: Props) => {
const { t } = useTranslation('server.users');
const permissions = useStoreState((state: ApplicationStore) => state.permissions.data);
return (
<div>
{
permissions.map(permission => (
<div className={'flex mb-2'} key={permission}>
<input
id={`permission_${permission}`}
type={'checkbox'}
name={'permissions[]'}
value={permission}
defaultChecked={defaultPermissions.indexOf(permission) >= 0}
/>
<label
htmlFor={`permission_${permission}`}
className={'flex-1 ml-3 text-sm text-neutral-200 cursor-pointer'}
>
{permission}
<p className={'text-xs text-neutral-300'} style={{ textTransform: 'none' }}>
{t(`server.users:permissions.${permission.replace('.', '_')}`)}
</p>
</label>
</div>
))
}
<div className={'mt-4 flex justify-end'}>
<button className={'btn btn-primary btn-sm'}>
Save Changes
</button>
</div>
</div>
);
};

View File

@ -0,0 +1,60 @@
import React, { useState } from 'react';
import ConfirmationModal from '@/components/elements/ConfirmationModal';
import { ServerContext } from '@/state/server';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTrashAlt } from '@fortawesome/free-solid-svg-icons/faTrashAlt';
import { Subuser } from '@/state/server/subusers';
import deleteSubuser from '@/api/server/users/deleteSubuser';
import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import { httpErrorToHuman } from '@/api/http';
export default ({ subuser }: { subuser: Subuser }) => {
const [ loading, setLoading ] = useState(false);
const [ showConfirmation, setShowConfirmation ] = useState(false);
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const removeSubuser = ServerContext.useStoreActions(actions => actions.subusers.removeSubuser);
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const doDeletion = () => {
setLoading(true);
clearFlashes('users');
deleteSubuser(uuid, subuser.uuid)
.then(() => {
setLoading(false);
removeSubuser(subuser.uuid);
})
.catch(error => {
console.error(error);
addError({ key: 'users', message: httpErrorToHuman(error) });
setShowConfirmation(false);
});
}
return (
<>
{showConfirmation &&
<ConfirmationModal
title={'Delete this subuser?'}
buttonText={'Yes, remove subuser'}
visible={true}
showSpinnerOverlay={loading}
onConfirmed={() => doDeletion()}
onDismissed={() => setShowConfirmation(false)}
>
Are you sure you wish to remove this subuser? They will have all access to this server revoked
immediately.
</ConfirmationModal>
}
<button
type={'button'}
aria-label={'Delete subuser'}
className={'block text-sm p-2 text-neutral-500 hover:text-red-600 transition-colors duration-150'}
onClick={() => setShowConfirmation(true)}
>
<FontAwesomeIcon icon={faTrashAlt}/>
</button>
</>
);
};

View File

@ -0,0 +1,36 @@
import React, { useState } from 'react';
import { Subuser } from '@/state/server/subusers';
import { ServerContext } from '@/state/server';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPencilAlt } from '@fortawesome/free-solid-svg-icons/faPencilAlt';
import { faTrashAlt } from '@fortawesome/free-solid-svg-icons/faTrashAlt';
import ConfirmationModal from '@/components/elements/ConfirmationModal';
import RemoveSubuserButton from '@/components/server/users/RemoveSubuserButton';
interface Props {
subuser: Subuser;
}
export default ({ subuser }: Props) => {
const appendSubuser = ServerContext.useStoreActions(actions => actions.subusers.appendSubuser);
return (
<div className={'grey-row-box mb-2'}>
<div className={'w-10 h-10 rounded-full bg-white border-2 border-inset border-neutral-800 overflow-hidden'}>
<img className={'f-full h-full'} src={`${subuser.image}?s=400`}/>
</div>
<div className={'ml-4 flex-1'}>
<p className={'text-sm'}>{subuser.email}</p>
</div>
<button
type={'button'}
aria-label={'Edit subuser'}
className={'block text-sm p-2 text-neutral-500 hover:text-neutral-100 transition-colors duration-150 mr-4'}
onClick={() => null}
>
<FontAwesomeIcon icon={faPencilAlt}/>
</button>
<RemoveSubuserButton subuser={subuser}/>
</div>
);
};

View File

@ -4,32 +4,38 @@ import { Actions, useStoreActions, useStoreState } from 'easy-peasy';
import { ApplicationStore } from '@/state'; import { ApplicationStore } from '@/state';
import Spinner from '@/components/elements/Spinner'; import Spinner from '@/components/elements/Spinner';
import AddSubuserButton from '@/components/server/users/AddSubuserButton'; import AddSubuserButton from '@/components/server/users/AddSubuserButton';
import UserRow from '@/components/server/users/UserRow';
import FlashMessageRender from '@/components/FlashMessageRender';
import getServerSubusers from '@/api/server/users/getServerSubusers';
import { httpErrorToHuman } from '@/api/http';
export default () => { export default () => {
const [ loading, setLoading ] = useState(true); const [ loading, setLoading ] = useState(true);
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const subusers = ServerContext.useStoreState(state => state.subusers.data); const subusers = ServerContext.useStoreState(state => state.subusers.data);
const getSubusers = ServerContext.useStoreActions(actions => actions.subusers.getSubusers); const setSubusers = ServerContext.useStoreActions(actions => actions.subusers.setSubusers);
const permissions = useStoreState((state: ApplicationStore) => state.permissions.data); const permissions = useStoreState((state: ApplicationStore) => state.permissions.data);
const getPermissions = useStoreActions((actions: Actions<ApplicationStore>) => actions.permissions.getPermissions); const getPermissions = useStoreActions((actions: Actions<ApplicationStore>) => actions.permissions.getPermissions);
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
useEffect(() => { useEffect(() => {
getPermissions().catch(error => console.error(error)); getPermissions().catch(error => console.error(error));
}, []); }, []);
useEffect(() => { useEffect(() => {
getSubusers(uuid) clearFlashes('users');
.then(() => setLoading(false)) getServerSubusers(uuid)
.then(subusers => {
setSubusers(subusers);
setLoading(false);
})
.catch(error => { .catch(error => {
console.error(error); console.error(error);
addError({ key: 'users', message: httpErrorToHuman(error) });
}); });
}, [ uuid, getSubusers ]); }, []);
useEffect(() => {
setLoading(!subusers);
}, [ subusers ]);
if (loading || !Object.keys(permissions).length) { if (loading || !Object.keys(permissions).length) {
return <Spinner size={'large'} centered={true}/>; return <Spinner size={'large'} centered={true}/>;
@ -37,33 +43,14 @@ export default () => {
return ( return (
<div className={'my-10'}> <div className={'my-10'}>
<FlashMessageRender byKey={'users'} className={'mb-4'}/>
{!subusers.length ? {!subusers.length ?
<p className={'text-center text-sm text-neutral-400'}> <p className={'text-center text-sm text-neutral-400'}>
It looks like you don't have any subusers. It looks like you don't have any subusers.
</p> </p>
: :
subusers.map(subuser => ( subusers.map(subuser => (
<div key={subuser.uuid} className={'flex items-center w-full'}> <UserRow key={subuser.uuid} subuser={subuser}/>
<img
className={'w-10 h-10 rounded-full bg-white border-2 border-inset border-neutral-800'}
src={`${subuser.image}?s=400`}
/>
<div className={'ml-4 flex-1'}>
<p className={'text-sm'}>{subuser.email}</p>
</div>
<div className={'ml-4'}>
<button
className={'btn btn-xs btn-primary'}
>
Edit
</button>
<button
className={'ml-2 btn btn-xs btn-red btn-secondary'}
>
Remove
</button>
</div>
</div>
)) ))
} }
<div className={'flex justify-end mt-6'}> <div className={'flex justify-end mt-6'}>

View File

@ -1,5 +1,4 @@
import { action, Action, thunk, Thunk } from 'easy-peasy'; import { action, Action } from 'easy-peasy';
import getServerSubusers from '@/api/server/users/getServerSubusers';
export type SubuserPermission = export type SubuserPermission =
'websocket.*' | 'websocket.*' |
@ -28,7 +27,7 @@ export interface ServerSubuserStore {
data: Subuser[]; data: Subuser[];
setSubusers: Action<ServerSubuserStore, Subuser[]>; setSubusers: Action<ServerSubuserStore, Subuser[]>;
appendSubuser: Action<ServerSubuserStore, Subuser>; appendSubuser: Action<ServerSubuserStore, Subuser>;
getSubusers: Thunk<ServerSubuserStore, string, any, {}, Promise<void>>; removeSubuser: Action<ServerSubuserStore, string>;
} }
const subusers: ServerSubuserStore = { const subusers: ServerSubuserStore = {
@ -42,10 +41,8 @@ const subusers: ServerSubuserStore = {
state.data = [ ...state.data.filter(user => user.uuid !== payload.uuid), payload ]; state.data = [ ...state.data.filter(user => user.uuid !== payload.uuid), payload ];
}), }),
getSubusers: thunk(async (actions, payload) => { removeSubuser: action((state, payload) => {
const subusers = await getServerSubusers(payload); state.data = [ ...state.data.filter(user => user.uuid !== payload) ];
actions.setSubusers(subusers);
}), }),
}; };