Merge branch 'develop' into cputhreads

This commit is contained in:
Dane Everitt 2020-04-03 13:48:06 -07:00 committed by GitHub
commit 78d6e59fc5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 1883 additions and 1323 deletions

View file

@ -33,6 +33,7 @@ return PhpCsFixer\Config::create()
'new_with_braces' => false,
'no_alias_functions' => true,
'no_multiline_whitespace_before_semicolons' => true,
'no_superfluous_phpdoc_tags' => false,
'no_unreachable_default_argument_value' => true,
'no_useless_return' => true,
'not_operator_with_successor_space' => true,

View file

@ -18,11 +18,10 @@ class SendPowerRequest extends ClientApiRequest
case 'start':
return Permission::ACTION_CONTROL_START;
case 'stop':
case 'kill':
return Permission::ACTION_CONTROL_STOP;
case 'restart':
return Permission::ACTION_CONTROL_RESTART;
case 'kill':
return Permission::ACTION_CONTROL_KILL;
}
return '__invalid';

View file

@ -20,7 +20,6 @@ class Permission extends Validable
const ACTION_CONTROL_START = 'control.start';
const ACTION_CONTROL_STOP = 'control.stop';
const ACTION_CONTROL_RESTART = 'control.restart';
const ACTION_CONTROL_KILL = 'control.kill';
const ACTION_DATABASE_READ = 'database.read';
const ACTION_DATABASE_CREATE = 'database.create';
@ -111,7 +110,6 @@ class Permission extends Validable
'start' => 'Allows a user to start the server if it is stopped.',
'stop' => 'Allows a user to stop a server if it is running.',
'restart' => 'Allows a user to perform a server restart. This allows them to start the server if it is offline, but not put the server in a completely stopped state.',
'kill' => 'Allows a user to terminate a server process.',
],
],

View file

@ -61,6 +61,10 @@ class Server extends Validable
*/
const RESOURCE_NAME = 'server';
const STATUS_INSTALLING = 0;
const STATUS_INSTALLED = 1;
const STATUS_INSTALL_FAILED = 2;
/**
* The table associated with the model.
*

View file

@ -2,7 +2,6 @@
namespace Pterodactyl\Repositories\Wings;
use BadMethodCallException;
use Webmozart\Assert\Assert;
use Pterodactyl\Models\Server;
use GuzzleHttp\Exception\TransferException;
@ -13,7 +12,6 @@ class DaemonServerRepository extends DaemonRepository
/**
* Returns details about a server from the Daemon instance.
*
* @return array
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
*/
public function getDetails(): array
@ -89,10 +87,20 @@ class DaemonServerRepository extends DaemonRepository
/**
* Reinstall a server on the daemon.
*
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
*/
public function reinstall(): void
{
throw new BadMethodCallException('Method is not implemented.');
Assert::isInstanceOf($this->server, Server::class);
try {
$this->getHttpClient()->post(sprintf(
'/api/servers/%s/reinstall', $this->server->uuid
));
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
}
/**

View file

@ -3,11 +3,9 @@
namespace Pterodactyl\Services\Servers;
use Pterodactyl\Models\Server;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Database\ConnectionInterface;
use Pterodactyl\Repositories\Wings\DaemonServerRepository;
use Pterodactyl\Contracts\Repository\ServerRepositoryInterface;
use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException;
class ReinstallServerService
{
@ -44,28 +42,23 @@ class ReinstallServerService
}
/**
* @param int|\Pterodactyl\Models\Server $server
* Reinstall a server on the remote daemon.
*
* @throws \Pterodactyl\Exceptions\DisplayException
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
* @param \Pterodactyl\Models\Server $server
* @return \Pterodactyl\Models\Server
*
* @throws \Throwable
*/
public function reinstall($server)
public function reinstall(Server $server)
{
if (! $server instanceof Server) {
$server = $this->repository->find($server);
}
$this->database->beginTransaction();
$this->database->transaction(function () use ($server) {
$this->repository->withoutFreshModel()->update($server->id, [
'installed' => 0,
], true, true);
'installed' => Server::STATUS_INSTALLING,
]);
try {
$this->daemonServerRepository->setServer($server)->reinstall();
$this->database->commit();
} catch (RequestException $exception) {
throw new DaemonConnectionException($exception);
}
});
return $server->refresh();
}
}

View file

@ -15,16 +15,16 @@
"ext-mbstring": "*",
"ext-pdo_mysql": "*",
"ext-zip": "*",
"appstract/laravel-blade-directives": "^1.6",
"aws/aws-sdk-php": "^3.110",
"cakephp/chronos": "^1.2",
"doctrine/dbal": "^2.9",
"appstract/laravel-blade-directives": "^1.8",
"aws/aws-sdk-php": "^3.134",
"cakephp/chronos": "^1.3",
"doctrine/dbal": "^2.10",
"fideloper/proxy": "^4.2",
"guzzlehttp/guzzle": "^6.3",
"guzzlehttp/guzzle": "^6.5",
"hashids/hashids": "^4.0",
"laracasts/utilities": "^3.0",
"laravel/framework": "^6.0.0",
"laravel/helpers": "^1.1",
"laracasts/utilities": "^3.1",
"laravel/framework": "^6.18",
"laravel/helpers": "^1.2",
"laravel/tinker": "^1.0",
"lcobucci/jwt": "^3.3",
"matriphe/iso-639": "^1.2",
@ -32,18 +32,18 @@
"predis/predis": "^1.1",
"prologue/alerts": "^0.4",
"s1lentium/iptools": "^1.1",
"spatie/laravel-fractal": "^5.6",
"staudenmeir/belongs-to-through": "^2.6",
"symfony/yaml": "^4.0",
"webmozart/assert": "^1.5"
"spatie/laravel-fractal": "^5.7",
"staudenmeir/belongs-to-through": "^2.9",
"symfony/yaml": "^4.4",
"webmozart/assert": "^1.7"
},
"require-dev": {
"barryvdh/laravel-debugbar": "^3.2",
"barryvdh/laravel-ide-helper": "^2.6",
"codedungeon/phpunit-result-printer": "0.25.1",
"friendsofphp/php-cs-fixer": "^2.15.1",
"laravel/dusk": "^5.5",
"php-mock/php-mock-phpunit": "^2.4",
"friendsofphp/php-cs-fixer": "^2.16.1",
"laravel/dusk": "^5.11",
"php-mock/php-mock-phpunit": "^2.6",
"phpunit/phpunit": "^7"
},
"autoload": {

2321
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,41 +1,27 @@
import React, { useMemo } from 'react';
import { ServerContext } from '@/state/server';
import React from 'react';
import { usePermissions } from '@/plugins/usePermissions';
interface Props {
action: string | string[];
matchAny?: boolean;
renderOnError?: React.ReactNode | null;
children: React.ReactNode;
}
const Can = ({ action, renderOnError, children }: Props) => {
const userPermissions = ServerContext.useStoreState(state => state.server.permissions);
const actions = Array.isArray(action) ? action : [ action ];
const Can = ({ action, matchAny = false, renderOnError, children }: Props) => {
const can = usePermissions(action);
const missingPermissionCount = useMemo(() => {
if (userPermissions[0] === '*') {
return 0;
if (matchAny) {
console.log('Can.tsx', can);
}
return actions.filter(permission => {
return !(
// Allows checking for any permission matching a name, for example files.*
// will return if the user has any permission under the file.XYZ namespace.
(
permission.endsWith('.*')
&& permission !== 'websocket.*'
&& userPermissions.filter(p => p.startsWith(permission.split('.')[0])).length > 0
)
// Otherwise just check if the entire permission exists in the array or not.
|| userPermissions.indexOf(permission) >= 0);
}).length;
}, [ action, userPermissions ]);
return (
<>
{missingPermissionCount > 0 ?
renderOnError
:
{
((matchAny && can.filter(p => p).length > 0) || (!matchAny && can.every(p => p))) ?
children
:
renderOnError
}
</>
);

View file

@ -4,6 +4,9 @@ import * as TerminalFit from 'xterm/lib/addons/fit/fit';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import { ServerContext } from '@/state/server';
import styled from 'styled-components';
import Can from '@/components/elements/Can';
import { usePermissions } from '@/plugins/usePermissions';
import classNames from 'classnames';
const theme = {
background: 'transparent',
@ -52,6 +55,7 @@ export default () => {
const useRef = useCallback(node => setTerminalElement(node), []);
const terminal = useMemo(() => new Terminal({ ...terminalProps }), []);
const { connected, instance } = ServerContext.useStoreState(state => state.socket);
const [ canSendCommands ] = usePermissions([ 'control.console']);
const handleConsoleOutput = (line: string, prelude = false) => terminal.writeln(
(prelude ? TERMINAL_PRELUDE : '') + line.replace(/(?:\r\n|\r|\n)$/im, '') + '\u001b[0m',
@ -121,7 +125,9 @@ export default () => {
<div className={'text-xs font-mono relative'}>
<SpinnerOverlay visible={!connected} size={'large'}/>
<div
className={'rounded-t p-2 bg-black w-full'}
className={classNames('rounded-t p-2 bg-black w-full', {
'rounded-b': !canSendCommands,
})}
style={{
minHeight: '16rem',
maxHeight: '32rem',
@ -129,6 +135,7 @@ export default () => {
>
<TerminalDiv id={'terminal'} ref={useRef}/>
</div>
{canSendCommands &&
<div className={'rounded-b bg-neutral-900 text-neutral-100 flex'}>
<div className={'flex-no-shrink p-2 font-bold'}>$</div>
<div className={'w-full'}>
@ -140,6 +147,7 @@ export default () => {
/>
</div>
</div>
}
</div>
);
};

View file

@ -9,6 +9,7 @@ import { faMicrochip } from '@fortawesome/free-solid-svg-icons/faMicrochip';
import { bytesToHuman } from '@/helpers';
import SuspenseSpinner from '@/components/elements/SuspenseSpinner';
import TitledGreyBox from '@/components/elements/TitledGreyBox';
import Can from '@/components/elements/Can';
type PowerAction = 'start' | 'stop' | 'restart' | 'kill';
@ -109,7 +110,9 @@ export default () => {
&nbsp;{cpu.toFixed(2)} %
</p>
</TitledGreyBox>
<Can action={[ 'control.start', 'control.stop', 'control.restart' ]} matchAny={true}>
<div className={'grey-box justify-center'}>
<Can action={'control.start'}>
<button
className={'btn btn-secondary btn-xs mr-2'}
disabled={status !== 'offline'}
@ -120,6 +123,8 @@ export default () => {
>
Start
</button>
</Can>
<Can action={'control.restart'}>
<button
className={'btn btn-secondary btn-xs mr-2'}
onClick={e => {
@ -129,8 +134,12 @@ export default () => {
>
Restart
</button>
</Can>
<Can action={'control.stop'}>
<StopOrKillButton onPress={action => sendPowerCommand(action)}/>
</Can>
</div>
</Can>
</div>
<div className={'flex-1 mx-4 mr-4'}>
<SuspenseSpinner>

View file

@ -1,5 +1,4 @@
import React, { useState } from 'react';
import { ServerDatabase } from '@/api/server/getServerDatabases';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faDatabase } from '@fortawesome/free-solid-svg-icons/faDatabase';
import { faTrashAlt } from '@fortawesome/free-solid-svg-icons/faTrashAlt';
@ -16,6 +15,7 @@ import { ServerContext } from '@/state/server';
import deleteServerDatabase from '@/api/server/deleteServerDatabase';
import { httpErrorToHuman } from '@/api/http';
import RotatePasswordButton from '@/components/server/databases/RotatePasswordButton';
import Can from '@/components/elements/Can';
interface Props {
databaseId: string | number;
@ -24,10 +24,10 @@ interface Props {
}
export default ({ databaseId, className, onDelete }: Props) => {
const [visible, setVisible] = useState(false);
const [ visible, setVisible ] = useState(false);
const database = ServerContext.useStoreState(state => state.databases.items.find(item => item.id === databaseId));
const appendDatabase = ServerContext.useStoreActions(actions => actions.databases.appendDatabase);
const [connectionVisible, setConnectionVisible] = useState(false);
const [ connectionVisible, setConnectionVisible ] = useState(false);
const { addFlash, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const server = ServerContext.useStoreState(state => state.server.data!);
@ -38,7 +38,7 @@ export default ({ databaseId, className, onDelete }: Props) => {
const schema = object().shape({
confirm: string()
.required('The database name must be provided.')
.oneOf([database.name.split('_', 2)[1], database.name], 'The database name must be provided.'),
.oneOf([ database.name.split('_', 2)[1], database.name ], 'The database name must be provided.'),
});
const submit = (values: { confirm: string }, { setSubmitting }: FormikHelpers<{ confirm: string }>) => {
@ -73,7 +73,10 @@ export default ({ databaseId, className, onDelete }: Props) => {
visible={visible}
dismissable={!isSubmitting}
showSpinnerOverlay={isSubmitting}
onDismissed={() => { setVisible(false); resetForm(); }}
onDismissed={() => {
setVisible(false);
resetForm();
}}
>
<FlashMessageRender byKey={'delete-database-modal'} className={'mb-6'}/>
<h3 className={'mb-6'}>Confirm database deletion</h3>
@ -113,10 +116,12 @@ export default ({ databaseId, className, onDelete }: Props) => {
<Modal visible={connectionVisible} onDismissed={() => setConnectionVisible(false)}>
<FlashMessageRender byKey={'database-connection-modal'} className={'mb-6'}/>
<h3 className={'mb-6'}>Database connection details</h3>
<Can action={'database.view_password'}>
<div>
<label className={'input-dark-label'}>Password</label>
<input type={'text'} className={'input-dark'} readOnly={true} value={database.password}/>
</div>
</Can>
<div className={'mt-6'}>
<label className={'input-dark-label'}>JBDC Connection String</label>
<input
@ -127,7 +132,9 @@ export default ({ databaseId, className, onDelete }: Props) => {
/>
</div>
<div className={'mt-6 text-right'}>
<Can action={'database.update'}>
<RotatePasswordButton databaseId={database.id} onUpdate={appendDatabase}/>
</Can>
<button className={'btn btn-sm btn-secondary'} onClick={() => setConnectionVisible(false)}>
Close
</button>
@ -156,9 +163,11 @@ export default ({ databaseId, className, onDelete }: Props) => {
<button className={'btn btn-sm btn-secondary mr-2'} onClick={() => setConnectionVisible(true)}>
<FontAwesomeIcon icon={faEye} fixedWidth={true}/>
</button>
<Can action={'database.delete'}>
<button className={'btn btn-sm btn-secondary btn-red'} onClick={() => setVisible(true)}>
<FontAwesomeIcon icon={faTrashAlt} fixedWidth={true}/>
</button>
</Can>
</div>
</div>
</React.Fragment>

View file

@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import getServerDatabases, { ServerDatabase } from '@/api/server/getServerDatabases';
import getServerDatabases from '@/api/server/getServerDatabases';
import { ServerContext } from '@/state/server';
import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationStore } from '@/state';
@ -9,6 +9,7 @@ import DatabaseRow from '@/components/server/databases/DatabaseRow';
import Spinner from '@/components/elements/Spinner';
import { CSSTransition } from 'react-transition-group';
import CreateDatabaseButton from '@/components/server/databases/CreateDatabaseButton';
import Can from '@/components/elements/Can';
export default () => {
const [ loading, setLoading ] = useState(true);
@ -41,7 +42,7 @@ export default () => {
<Spinner size={'large'} centered={true}/>
:
<CSSTransition classNames={'fade'} timeout={250}>
<React.Fragment>
<>
{databases.length > 0 ?
databases.map((database, index) => (
<DatabaseRow
@ -54,18 +55,20 @@ export default () => {
:
<p className={'text-center text-sm text-neutral-400'}>
{server.featureLimits.databases > 0 ?
`It looks like you have no databases. Click the button below to create one now.`
`It looks like you have no databases.`
:
`Databases cannot be created for this server.`
}
</p>
}
<Can action={'database.create'}>
{server.featureLimits.databases > 0 &&
<div className={'mt-6 flex justify-end'}>
<CreateDatabaseButton onCreated={appendDatabase}/>
</div>
}
</React.Fragment>
</Can>
</>
</CSSTransition>
}
</div>

View file

@ -13,7 +13,8 @@ import { join } from 'path';
import deleteFile from '@/api/server/files/deleteFile';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import copyFile from '@/api/server/files/copyFile';
import http, { httpErrorToHuman } from '@/api/http';
import { httpErrorToHuman } from '@/api/http';
import Can from '@/components/elements/Can';
type ModalType = 'rename' | 'move';
@ -118,9 +119,13 @@ export default ({ uuid }: { uuid: string }) => {
<CSSTransition timeout={250} in={menuVisible} unmountOnExit={true} classNames={'fade'}>
<div
ref={menu}
onClick={e => { e.stopPropagation(); setMenuVisible(false); }}
onClick={e => {
e.stopPropagation();
setMenuVisible(false);
}}
className={'absolute bg-white p-2 rounded border border-neutral-700 shadow-lg text-neutral-500 min-w-48'}
>
<Can action={'file.update'}>
<div
onClick={() => setModal('rename')}
className={'hover:text-neutral-700 p-2 flex items-center hover:bg-neutral-100 rounded'}
@ -135,6 +140,8 @@ export default ({ uuid }: { uuid: string }) => {
<FontAwesomeIcon icon={faLevelUpAlt} className={'text-xs'}/>
<span className={'ml-2'}>Move</span>
</div>
</Can>
<Can action={'file.create'}>
<div
onClick={() => doCopy()}
className={'hover:text-neutral-700 p-2 flex items-center hover:bg-neutral-100 rounded'}
@ -142,6 +149,7 @@ export default ({ uuid }: { uuid: string }) => {
<FontAwesomeIcon icon={faCopy} className={'text-xs'}/>
<span className={'ml-2'}>Copy</span>
</div>
</Can>
<div
className={'hover:text-neutral-700 p-2 flex items-center hover:bg-neutral-100 rounded'}
onClick={() => doDownload()}
@ -149,6 +157,7 @@ export default ({ uuid }: { uuid: string }) => {
<FontAwesomeIcon icon={faFileDownload} className={'text-xs'}/>
<span className={'ml-2'}>Download</span>
</div>
<Can action={'file.delete'}>
<div
onClick={() => doDeletion()}
className={'hover:text-red-700 p-2 flex items-center hover:bg-red-100 rounded'}
@ -156,6 +165,7 @@ export default ({ uuid }: { uuid: string }) => {
<FontAwesomeIcon icon={faTrashAlt} className={'text-xs'}/>
<span className={'ml-2'}>Delete</span>
</div>
</Can>
</div>
</CSSTransition>
</div>

View file

@ -2,7 +2,7 @@ import React, { lazy, useEffect, useState } from 'react';
import { ServerContext } from '@/state/server';
import getFileContents from '@/api/server/files/getFileContents';
import useRouter from 'use-react-router';
import { Actions, useStoreState } from 'easy-peasy';
import { Actions, useStoreActions, useStoreState } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import { httpErrorToHuman } from '@/api/http';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
@ -10,6 +10,8 @@ import saveFileContents from '@/api/server/files/saveFileContents';
import FileManagerBreadcrumbs from '@/components/server/files/FileManagerBreadcrumbs';
import { useParams } from 'react-router';
import FileNameModal from '@/components/server/files/FileNameModal';
import Can from '@/components/elements/Can';
import FlashMessageRender from '@/components/FlashMessageRender';
const LazyAceEditor = lazy(() => import(/* webpackChunkName: "editor" */'@/components/elements/AceEditor'));
@ -21,15 +23,20 @@ export default () => {
const [ modalVisible, setModalVisible ] = useState(false);
const { id, uuid } = ServerContext.useStoreState(state => state.server.data!);
const addError = useStoreState((state: Actions<ApplicationStore>) => state.flashes.addError);
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
let fetchFileContent: null | (() => Promise<string>) = null;
if (action !== 'new') {
useEffect(() => {
setLoading(true);
clearFlashes('files:view');
getFileContents(uuid, hash.replace(/^#/, ''))
.then(setContent)
.catch(error => console.error(error))
.catch(error => {
console.error(error);
addError({ key: 'files:view', message: httpErrorToHuman(error) });
})
.then(() => setLoading(false));
}, [ uuid, hash ]);
}
@ -40,8 +47,8 @@ export default () => {
}
setLoading(true);
fetchFileContent()
.then(content => {
clearFlashes('files:view');
fetchFileContent().then(content => {
return saveFileContents(uuid, name || hash.replace(/^#/, ''), content);
})
.then(() => {
@ -54,13 +61,14 @@ export default () => {
})
.catch(error => {
console.error(error);
addError({ message: httpErrorToHuman(error), key: 'files' });
addError({ message: httpErrorToHuman(error), key: 'files:view' });
})
.then(() => setLoading(false));
};
return (
<div className={'mt-10 mb-4'}>
<FlashMessageRender byKey={'files:view'} className={'mb-4'}/>
<FileManagerBreadcrumbs withinFileEditor={true} isNewFile={action !== 'edit'}/>
<FileNameModal
visible={modalVisible}
@ -83,13 +91,17 @@ export default () => {
</div>
<div className={'flex justify-end mt-4'}>
{action === 'edit' ?
<Can action={'file.update'}>
<button className={'btn btn-primary btn-sm'} onClick={() => save()}>
Save Content
</button>
</Can>
:
<Can action={'file.create'}>
<button className={'btn btn-primary btn-sm'} onClick={() => setModalVisible(true)}>
Create File
</button>
</Can>
}
</div>
</div>

View file

@ -11,6 +11,7 @@ import FileManagerBreadcrumbs from '@/components/server/files/FileManagerBreadcr
import { FileObject } from '@/api/server/files/loadDirectory';
import NewDirectoryButton from '@/components/server/files/NewDirectoryButton';
import { Link } from 'react-router-dom';
import Can from '@/components/elements/Can';
const sortFiles = (files: FileObject[]): FileObject[] => {
return files.sort((a, b) => a.name.localeCompare(b.name))
@ -34,7 +35,6 @@ export default () => {
console.error(error.message, { error });
addError({ message: httpErrorToHuman(error), key: 'files' });
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ directory ]);
return (
@ -78,12 +78,17 @@ export default () => {
</React.Fragment>
</CSSTransition>
}
<Can action={'file.create'}>
<div className={'flex justify-end mt-8'}>
<NewDirectoryButton/>
<Link to={`/server/${id}/files/new${window.location.hash}`} className={'btn btn-sm btn-primary'}>
<Link
to={`/server/${id}/files/new${window.location.hash}`}
className={'btn btn-sm btn-primary'}
>
New File
</Link>
</div>
</Can>
</React.Fragment>
}
</React.Fragment>

View file

@ -9,6 +9,7 @@ import { httpErrorToHuman } from '@/api/http';
import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import EditScheduleModal from '@/components/server/schedules/EditScheduleModal';
import Can from '@/components/elements/Can';
export default ({ match, history }: RouteComponentProps) => {
const { uuid } = ServerContext.useStoreState(state => state.server.data!);
@ -35,9 +36,8 @@ export default ({ match, history }: RouteComponentProps) => {
<>
{
schedules.length === 0 ?
<p className={'text-sm text-neutral-400'}>
There are no schedules configured for this server. Click the button below to get
started.
<p className={'text-sm text-center text-neutral-400'}>
There are no schedules configured for this server.
</p>
:
schedules.map(schedule => (
@ -54,21 +54,23 @@ export default ({ match, history }: RouteComponentProps) => {
</a>
))
}
<Can action={'schedule.create'}>
<div className={'mt-8 flex justify-end'}>
{visible && <EditScheduleModal
appear={true}
visible={true}
onScheduleUpdated={schedule => setSchedules(s => [...(s || []), schedule])}
onScheduleUpdated={schedule => setSchedules(s => [ ...(s || []), schedule ])}
onDismissed={() => setVisible(false)}
/>}
<button
type={'button'}
className={'btn btn-lg btn-primary'}
className={'btn btn-sm btn-primary'}
onClick={() => setVisible(true)}
>
Create schedule
</button>
</div>
</Can>
</>
}
</div>

View file

@ -13,6 +13,7 @@ import ScheduleTaskRow from '@/components/server/schedules/ScheduleTaskRow';
import EditScheduleModal from '@/components/server/schedules/EditScheduleModal';
import NewTaskButton from '@/components/server/schedules/NewTaskButton';
import DeleteScheduleButton from '@/components/server/schedules/DeleteScheduleButton';
import Can from '@/components/elements/Can';
interface Params {
id: string;
@ -93,15 +94,17 @@ export default ({ match, history, location: { state } }: RouteComponentProps<Par
</>
:
<p className={'text-sm text-neutral-400'}>
There are no tasks configured for this schedule. Consider adding a new one using the
button below.
There are no tasks configured for this schedule.
</p>
}
<div className={'mt-8 flex justify-end'}>
<Can action={'schedule.delete'}>
<DeleteScheduleButton
scheduleId={schedule.id}
onDeleted={() => history.push(`/server/${id}/schedules`)}
/>
</Can>
<Can action={'schedule.update'}>
<button className={'btn btn-primary btn-sm mr-4'} onClick={() => setShowEditModal(true)}>
Edit
</button>
@ -111,6 +114,7 @@ export default ({ match, history, location: { state } }: RouteComponentProps<Par
...s!, tasks: [ ...s!.tasks, task ],
}))}
/>
</Can>
</div>
</>
}

View file

@ -13,6 +13,7 @@ import { httpErrorToHuman } from '@/api/http';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import TaskDetailsModal from '@/components/server/schedules/TaskDetailsModal';
import { faPencilAlt } from '@fortawesome/free-solid-svg-icons/faPencilAlt';
import Can from '@/components/elements/Can';
interface Props {
schedule: number;
@ -75,6 +76,7 @@ export default ({ schedule, task, onTaskUpdated, onTaskRemoved }: Props) => {
</p>
</div>
}
<Can action={'schedule.update'}>
<button
type={'button'}
aria-label={'Edit scheduled task'}
@ -83,6 +85,8 @@ export default ({ schedule, task, onTaskUpdated, onTaskRemoved }: Props) => {
>
<FontAwesomeIcon icon={faPencilAlt}/>
</button>
</Can>
<Can action={'schedule.update'}>
<button
type={'button'}
aria-label={'Delete scheduled task'}
@ -91,6 +95,7 @@ export default ({ schedule, task, onTaskUpdated, onTaskRemoved }: Props) => {
>
<FontAwesomeIcon icon={faTrashAlt}/>
</button>
</Can>
</div>
);
};

View file

@ -6,6 +6,7 @@ import { ApplicationStore } from '@/state';
import { UserData } from '@/state/user';
import RenameServerBox from '@/components/server/settings/RenameServerBox';
import FlashMessageRender from '@/components/FlashMessageRender';
import Can from '@/components/elements/Can';
export default () => {
const user = useStoreState<ApplicationStore, UserData>(state => state.user.data!);
@ -15,7 +16,8 @@ export default () => {
<div className={'my-10 mb-6'}>
<FlashMessageRender byKey={'settings'} className={'mb-4'}/>
<div className={'md:flex'}>
<TitledGreyBox title={'SFTP Details'} className={'w-full md:flex-1 md:mr-6'}>
<Can action={'file.sftp'}>
<TitledGreyBox title={'SFTP Details'} className={'w-full md:flex-1 md:max-w-1/2 md:mr-6'}>
<div>
<label className={'input-dark-label'}>Server Address</label>
<input
@ -52,9 +54,12 @@ export default () => {
</div>
</div>
</TitledGreyBox>
<div className={'w-full mt-6 md:flex-1 md:mt-0'}>
</Can>
<Can action={'settings.rename'}>
<div className={'w-full mt-6 md:flex-1 md:max-w-1/2 md:mt-0'}>
<RenameServerBox/>
</div>
</Can>
</div>
</div>
);

View file

@ -14,6 +14,8 @@ import createOrUpdateSubuser from '@/api/server/users/createOrUpdateSubuser';
import { ServerContext } from '@/state/server';
import { httpErrorToHuman } from '@/api/http';
import FlashMessageRender from '@/components/FlashMessageRender';
import Can from '@/components/elements/Can';
import { usePermissions } from '@/plugins/usePermissions';
type Props = {
subuser?: Subuser;
@ -25,21 +27,32 @@ interface Values {
}
const PermissionLabel = styled.label`
${tw`flex items-center border border-transparent rounded p-2 cursor-pointer`};
${tw`flex items-center border border-transparent rounded p-2`};
text-transform: none;
&:not(.disabled) {
${tw`cursor-pointer`};
&:hover {
${tw`border-neutral-500 bg-neutral-800`};
}
}
`;
const EditSubuserModal = forwardRef<HTMLHeadingElement, Props>(({ subuser, ...props }, ref) => {
const { values, isSubmitting, setFieldValue } = useFormikContext<Values>();
const [ canEditUser ] = usePermissions([ 'user.update' ]);
const permissions = useStoreState((state: ApplicationStore) => state.permissions.data);
return (
<Modal {...props} showSpinnerOverlay={isSubmitting}>
<h3 ref={ref}>{subuser ? `Modify permissions for ${subuser.email}` : 'Create new subuser'}</h3>
<h3 ref={ref}>
{subuser ?
`${canEditUser ? 'Modify' : 'View'} permissions for ${subuser.email}`
:
'Create new subuser'
}
</h3>
<FlashMessageRender byKey={'user:edit'} className={'mt-4'}/>
{!subuser &&
<div className={'mt-6'}>
@ -50,13 +63,14 @@ const EditSubuserModal = forwardRef<HTMLHeadingElement, Props>(({ subuser, ...pr
/>
</div>
}
<div className={'mt-6'}>
<div className={'my-6'}>
{Object.keys(permissions).filter(key => key !== 'websocket').map((key, index) => (
<TitledGreyBox
key={key}
title={
<div className={'flex items-center'}>
<p className={'text-sm uppercase flex-1'}>{key}</p>
{canEditUser &&
<input
type={'checkbox'}
onClick={e => {
@ -78,6 +92,7 @@ const EditSubuserModal = forwardRef<HTMLHeadingElement, Props>(({ subuser, ...pr
}
}}
/>
}
</div>
}
className={index !== 0 ? 'mt-4' : undefined}
@ -87,9 +102,11 @@ const EditSubuserModal = forwardRef<HTMLHeadingElement, Props>(({ subuser, ...pr
</p>
{Object.keys(permissions[key].keys).map((pkey, index) => (
<PermissionLabel
key={`permission_${key}_${pkey}`}
htmlFor={`permission_${key}_${pkey}`}
className={classNames('transition-colors duration-75', {
'mt-2': index !== 0,
disabled: !canEditUser,
})}
>
<div className={'p-2'}>
@ -98,6 +115,7 @@ const EditSubuserModal = forwardRef<HTMLHeadingElement, Props>(({ subuser, ...pr
name={'permissions'}
value={`${key}.${pkey}`}
className={'w-5 h-5 mr-2'}
disabled={!canEditUser}
/>
</div>
<div className={'flex-1'}>
@ -115,11 +133,13 @@ const EditSubuserModal = forwardRef<HTMLHeadingElement, Props>(({ subuser, ...pr
</TitledGreyBox>
))}
</div>
<div className={'mt-6 pb-6 flex justify-end'}>
<Can action={subuser ? 'user.update' : 'user.delete'}>
<div className={'pb-6 flex justify-end'}>
<button className={'btn btn-primary btn-sm'} type={'submit'}>
{subuser ? 'Save' : 'Invite User'}
</button>
</div>
</Can>
</Modal>
);
});

View file

@ -7,6 +7,7 @@ import EditSubuserModal from '@/components/server/users/EditSubuserModal';
import { faUnlockAlt } from '@fortawesome/free-solid-svg-icons/faUnlockAlt';
import { faUserLock } from '@fortawesome/free-solid-svg-icons/faUserLock';
import classNames from 'classnames';
import Can from '@/components/elements/Can';
interface Props {
subuser: Subuser;
@ -58,7 +59,9 @@ export default ({ subuser }: Props) => {
>
<FontAwesomeIcon icon={faPencilAlt}/>
</button>
<Can action={'user.delete'}>
<RemoveSubuserButton subuser={subuser}/>
</Can>
</div>
);
};

View file

@ -8,6 +8,7 @@ import UserRow from '@/components/server/users/UserRow';
import FlashMessageRender from '@/components/FlashMessageRender';
import getServerSubusers from '@/api/server/users/getServerSubusers';
import { httpErrorToHuman } from '@/api/http';
import Can from '@/components/elements/Can';
export default () => {
const [ loading, setLoading ] = useState(true);
@ -42,7 +43,7 @@ export default () => {
}
return (
<div className={'my-10'}>
<div className={'mt-10 mb-6'}>
<FlashMessageRender byKey={'users'} className={'mb-4'}/>
{!subusers.length ?
<p className={'text-center text-sm text-neutral-400'}>
@ -53,9 +54,11 @@ export default () => {
<UserRow key={subuser.uuid} subuser={subuser}/>
))
}
<Can action={'user.create'}>
<div className={'flex justify-end mt-6'}>
<AddSubuserButton/>
</div>
</Can>
</div>
);
};

View file

@ -5,7 +5,7 @@ export function bytesToHuman (bytes: number): string {
const i = Math.floor(Math.log(bytes) / Math.log(1000));
// @ts-ignore
return `${(bytes / Math.pow(1000, i)).toFixed(2) * 1} ${['Bytes', 'kB', 'MB', 'GB', 'TB'][i]}`;
return `${(bytes / Math.pow(1000, i)).toFixed(2) * 1} ${[ 'Bytes', 'kB', 'MB', 'GB', 'TB' ][i]}`;
}
export const bytesToMegabytes = (bytes: number) => Math.floor(bytes / 1000 / 1000);

View file

@ -0,0 +1,12 @@
import { useRef } from 'react';
import isEqual from 'lodash-es/isEqual';
export const useDeepMemo = <T, K> (fn: () => T, key: K): T => {
const ref = useRef<{ key: K, value: T }>();
if (!ref.current || !isEqual(key, ref.current.key)) {
ref.current = { key, value: fn() };
}
return ref.current.value;
};

View file

@ -0,0 +1,25 @@
import { ServerContext } from '@/state/server';
import { useDeepMemo } from '@/plugins/useDeepMemo';
export const usePermissions = (action: string | string[]): boolean[] => {
const userPermissions = ServerContext.useStoreState(state => state.server.permissions);
return useDeepMemo(() => {
if (userPermissions[0] === '*') {
return Array(Array.isArray(action) ? action.length : 1).fill(true);
}
return (Array.isArray(action) ? action : [ action ])
.map(permission => (
// Allows checking for any permission matching a name, for example files.*
// will return if the user has any permission under the file.XYZ namespace.
(
permission.endsWith('.*') &&
permission !== 'websocket.*' &&
userPermissions.filter(p => p.startsWith(permission.split('.')[0])).length > 0
) ||
// Otherwise just check if the entire permission exists in the array or not.
userPermissions.indexOf(permission) >= 0
));
}, [ action, userPermissions ]);
};

View file

@ -47,7 +47,7 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>)
<Can action={'user.*'}>
<NavLink to={`${match.url}/users`}>Users</NavLink>
</Can>
<Can action={'settings.*'}>
<Can action={['settings.*', 'file.sftp']} matchAny={true}>
<NavLink to={`${match.url}/settings`}>Settings</NavLink>
</Can>
</div>

View file

@ -2,7 +2,7 @@ import { action, Action } from 'easy-peasy';
export type SubuserPermission =
'websocket.*' |
'control.console' | 'control.start' | 'control.stop' | 'control.restart' | 'control.kill' |
'control.console' | 'control.start' | 'control.stop' | 'control.restart' |
'user.create' | 'user.read' | 'user.update' | 'user.delete' |
'file.create' | 'file.read' | 'file.update' | 'file.delete' | 'file.archive' | 'file.sftp' |
'allocation.read' | 'allocation.update' |

View file

@ -4,7 +4,7 @@
transition: opacity 250ms ease;
& > .modal-container {
@apply .relative .w-full .max-w-md .m-auto .flex-col .flex;
@apply .relative .w-full .max-w-1/2 .m-auto .flex-col .flex;
/*&.top {
margin-top: 10%;

View file

@ -30,25 +30,25 @@
<div class="form-group">
<label for="email" class="control-label">Email</label>
<div>
<input readonly type="email" name="email" value="{{ $user->email }}" class="form-control form-autocomplete-stop">
<input type="email" name="email" value="{{ $user->email }}" class="form-control form-autocomplete-stop">
</div>
</div>
<div class="form-group">
<label for="registered" class="control-label">Username</label>
<div>
<input readonly type="text" name="username" value="{{ $user->username }}" class="form-control form-autocomplete-stop">
<input type="text" name="username" value="{{ $user->username }}" class="form-control form-autocomplete-stop">
</div>
</div>
<div class="form-group">
<label for="registered" class="control-label">Client First Name</label>
<div>
<input readonly type="text" name="name_first" value="{{ $user->name_first }}" class="form-control form-autocomplete-stop">
<input type="text" name="name_first" value="{{ $user->name_first }}" class="form-control form-autocomplete-stop">
</div>
</div>
<div class="form-group">
<label for="registered" class="control-label">Client Last Name</label>
<div>
<input readonly type="text" name="name_last" value="{{ $user->name_last }}" class="form-control form-autocomplete-stop">
<input type="text" name="name_last" value="{{ $user->name_last }}" class="form-control form-autocomplete-stop">
</div>
</div>
<div class="form-group">
@ -80,7 +80,7 @@
<div class="form-group no-margin-bottom">
<label for="password" class="control-label">Password <span class="field-optional"></span></label>
<div>
<input readonly type="password" id="password" name="password" class="form-control form-autocomplete-stop">
<input type="password" id="password" name="password" class="form-control form-autocomplete-stop">
<p class="text-muted small">Leave blank to keep this user's password the same. User will not receive any notification if password is changed.</p>
</div>
</div>

View file

@ -120,6 +120,52 @@ let colors = {
'green-900': 'hsl(125, 97%, 14%)',
};
/*
|-------------------------------------------------------------------------------
| Sizes
|-------------------------------------------------------------------------------
|
| Here you can specify the sizes that should be available to all of the height,
| width, padding, and margin utilities.
|
*/
let sizes = {
'auto': 'auto',
'0': '0',
'px': '1px',
'1': '0.25rem',
'2': '0.5rem',
'3': '0.75rem',
'4': '1rem',
'5': '1.25rem',
'6': '1.5rem',
'8': '2rem',
'10': '2.5rem',
'12': '3rem',
'16': '4rem',
'20': '5rem',
'24': '6rem',
'28': '7rem',
'32': '8rem',
'48': '12rem',
'64': '16rem',
'96': '24rem',
'1/2': '50%',
'1/3': '33.33333%',
'2/3': '66.66667%',
'1/4': '25%',
'3/4': '75%',
'1/5': '20%',
'2/5': '40%',
'3/5': '60%',
'4/5': '80%',
'1/6': '16.66667%',
'5/6': '83.33333%',
'full': '100%',
'screen': '100vw',
};
module.exports = {
/*
@ -452,38 +498,7 @@ module.exports = {
|
*/
width: {
'auto': 'auto',
'px': '1px',
'1': '0.25rem',
'2': '0.5rem',
'3': '0.75rem',
'4': '1rem',
'5': '1.25rem',
'6': '1.5rem',
'8': '2rem',
'10': '2.5rem',
'12': '3rem',
'16': '4rem',
'24': '6rem',
'32': '8rem',
'48': '12rem',
'64': '16rem',
'96': '24rem',
'1/2': '50%',
'1/3': '33.33333%',
'2/3': '66.66667%',
'1/4': '25%',
'3/4': '75%',
'1/5': '20%',
'2/5': '40%',
'3/5': '60%',
'4/5': '80%',
'1/6': '16.66667%',
'5/6': '83.33333%',
'full': '100%',
'screen': '100vw',
},
width: { ...sizes },
/*
|-----------------------------------------------------------------------------
@ -500,26 +515,7 @@ module.exports = {
|
*/
height: {
'auto': 'auto',
'px': '1px',
'1': '0.25rem',
'2': '0.5rem',
'3': '0.75rem',
'4': '1rem',
'5': '1.25rem',
'6': '1.5rem',
'8': '2rem',
'10': '2.5rem',
'12': '3rem',
'16': '4rem',
'24': '6rem',
'32': '8rem',
'48': '12rem',
'64': '16rem',
'full': '100%',
'screen': '100vh',
},
height: { ...sizes },
/*
|-----------------------------------------------------------------------------
@ -535,25 +531,7 @@ module.exports = {
|
*/
minWidth: {
'0': '0',
'1': '0.25rem',
'2': '0.5rem',
'3': '0.75rem',
'4': '1rem',
'5': '1.25rem',
'6': '1.5rem',
'8': '2rem',
'10': '2.5rem',
'12': '3rem',
'16': '4rem',
'24': '6rem',
'32': '8rem',
'48': '12rem',
'64': '16rem',
'96': '24rem',
'full': '100%',
},
minWidth: { ...sizes },
/*
|-----------------------------------------------------------------------------
@ -569,11 +547,7 @@ module.exports = {
|
*/
minHeight: {
'0': '0',
'full': '100%',
'screen': '100vh',
},
minHeight: { ...sizes },
/*
|-----------------------------------------------------------------------------
@ -590,19 +564,7 @@ module.exports = {
|
*/
maxWidth: {
'xxs': '10rem',
'xs': '20rem',
'sm': '30rem',
'md': '40rem',
'lg': '50rem',
'xl': '60rem',
'2xl': '70rem',
'3xl': '80rem',
'4xl': '90rem',
'5xl': '100rem',
'full': '100%',
},
maxWidth: { ...sizes },
/*
|-----------------------------------------------------------------------------
@ -618,10 +580,7 @@ module.exports = {
|
*/
maxHeight: {
'full': '100%',
'screen': '100vh',
},
maxHeight: { ...sizes },
/*
|-----------------------------------------------------------------------------
@ -638,23 +597,7 @@ module.exports = {
|
*/
padding: {
'px': '1px',
'0': '0',
'1': '0.25rem',
'2': '0.5rem',
'3': '0.75rem',
'4': '1rem',
'5': '1.25rem',
'6': '1.5rem',
'8': '2rem',
'10': '2.5rem',
'12': '3rem',
'16': '4rem',
'20': '5rem',
'24': '6rem',
'32': '8rem',
},
padding: { ...sizes },
/*
|-----------------------------------------------------------------------------
@ -671,33 +614,7 @@ module.exports = {
|
*/
margin: {
'n-12': '-3rem',
'n-10': '-2.5rem',
'n-8': '-2rem',
'n-6': '-1.5rem',
'n-4': '-1rem',
'n-3': '-0.75rem',
'n-2': '-0.5rem',
'n-1': '-0.25rem',
'n-px': '-1px',
'auto': 'auto',
'px': '1px',
'0': '0',
'1': '0.25rem',
'2': '0.5rem',
'3': '0.75rem',
'4': '1rem',
'5': '1.25rem',
'6': '1.5rem',
'8': '2rem',
'10': '2.5rem',
'12': '3rem',
'16': '4rem',
'20': '5rem',
'24': '6rem',
'32': '8rem',
},
margin: { ...sizes },
/*
|-----------------------------------------------------------------------------
@ -714,23 +631,7 @@ module.exports = {
|
*/
negativeMargin: {
'px': '1px',
'0': '0',
'1': '0.25rem',
'2': '0.5rem',
'3': '0.75rem',
'4': '1rem',
'5': '1.25rem',
'6': '1.5rem',
'8': '2rem',
'10': '2.5rem',
'12': '3rem',
'16': '4rem',
'20': '5rem',
'24': '6rem',
'32': '8rem',
},
negativeMargin: { ...sizes },
/*
|-----------------------------------------------------------------------------