1
1
mirror of https://github.com/pterodactyl/panel.git synced 2024-11-25 10:32:31 +01:00

Merge branch 'develop' into feature/server-mounts

This commit is contained in:
Matthew Penner 2020-07-11 12:29:30 -06:00 committed by GitHub
commit 295f09ca43
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
195 changed files with 5395 additions and 5417 deletions

12
.babel-plugin-macrosrc.js Normal file
View File

@ -0,0 +1,12 @@
module.exports = {
twin: {
preset: 'styled-components',
autoCssProp: true,
config: './tailwind.config.js',
},
styledComponents: {
pure: true,
displayName: false,
fileName: false,
},
};

87
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,87 @@
name: "Release"
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: '12'
- name: Create release branch and bump version
env:
REF: ${{ github.ref }}
run: |
BRANCH=release/${REF:10}
git config --local user.email "ci@pterodactyl.io"
git config --local user.name "Pterodactyl CI"
git checkout -b $BRANCH
git push -u origin $BRANCH
sed -i "s/ 'version' => 'canary',/ 'version' => '${REF:11}',/" config/app.php
git add config/app.php
git commit -m "bump version for release"
git push
- name: Build assets
run: |
yarn install
yarn run build:production
- name: Create release archive
run: |
rm -rf node_modules/ test/ codecov.yml CODE_OF_CONDUCT.md CONTRIBUTING.md phpunit.dusk.xml phpunit.xml Vagrantfile
tar -czf panel.tar.gz *
- name: Extract changelog
id: extract_changelog
env:
REF: ${{ github.ref }}
run: |
sed -n "/^## ${REF:10}/,/^## /{/^## /b;p}" CHANGELOG.md > ./RELEASE_CHANGELOG
echo ::set-output name=version_name::`sed -nr "s/^## (${REF:10} .*)$/\1/p" CHANGELOG.md`
- name: Create checksum and add to changelog
run: |
SUM=`sha256sum panel.tar.gz`
echo -e "\n#### SHA256 Checksum\n\n\`\`\`\n$SUM\n\`\`\`\n" >> ./RELEASE_CHANGELOG
echo $SUM > checksum.txt
- name: Create Release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: ${{ steps.extract_changelog.outputs.version_name }}
body_path: ./RELEASE_CHANGELOG
draft: true
prerelease: ${{ contains(github.ref, 'beta') || contains(github.ref, 'alpha') }}
- name: Upload binary
id: upload-release-archive
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: panel.tar.gz
asset_name: panel.tar.gz
asset_content_type: application/gzip
- name: Upload checksum
id: upload-release-checksum
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./checksum.txt
asset_name: checksum.txt
asset_content_type: text/plain

View File

@ -1,6 +1,9 @@
name: tests
on:
push:
branch-ignore:
- 'master'
- 'release/**'
pull_request:
jobs:
integration_tests:

View File

@ -3,36 +3,9 @@
namespace Pterodactyl\Contracts\Repository;
use Illuminate\Support\Collection;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
interface AllocationRepositoryInterface extends RepositoryInterface
{
/**
* Set an array of allocation IDs to be assigned to a specific server.
*
* @param int|null $server
* @param array $ids
* @return int
*/
public function assignAllocationsToServer(int $server = null, array $ids): int;
/**
* Return all of the allocations for a specific node.
*
* @param int $node
* @return \Illuminate\Support\Collection
*/
public function getAllocationsForNode(int $node): Collection;
/**
* Return all of the allocations for a node in a paginated format.
*
* @param int $node
* @param int $perPage
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
*/
public function getPaginatedAllocationsForNode(int $node, int $perPage = 100): LengthAwarePaginator;
/**
* Return all of the unique IPs that exist for a given node.
*

View File

@ -2,7 +2,6 @@
namespace Pterodactyl\Contracts\Repository;
use Pterodactyl\Models\User;
use Pterodactyl\Models\Server;
use Illuminate\Support\Collection;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
@ -107,16 +106,6 @@ interface ServerRepositoryInterface extends RepositoryInterface, SearchableInter
*/
public function getDaemonServiceData(Server $server, bool $refresh = false): array;
/**
* Return a paginated list of servers that a user can access at a given level.
*
* @param \Pterodactyl\Models\User $user
* @param int $level
* @param bool|int $paginate
* @return \Illuminate\Pagination\LengthAwarePaginator|\Illuminate\Database\Eloquent\Collection
*/
public function filterUserAccessServers(User $user, int $level, $paginate = 25);
/**
* Return a server by UUID.
*

View File

@ -3,11 +3,10 @@
namespace Pterodactyl\Http\Controllers\Api\Application\Nodes;
use Pterodactyl\Models\Node;
use Illuminate\Http\Response;
use Illuminate\Http\JsonResponse;
use Pterodactyl\Models\Allocation;
use Pterodactyl\Services\Allocations\AssignmentService;
use Pterodactyl\Services\Allocations\AllocationDeletionService;
use Pterodactyl\Contracts\Repository\AllocationRepositoryInterface;
use Pterodactyl\Transformers\Api\Application\AllocationTransformer;
use Pterodactyl\Http\Controllers\Api\Application\ApplicationApiController;
use Pterodactyl\Http\Requests\Api\Application\Allocations\GetAllocationsRequest;
@ -26,41 +25,32 @@ class AllocationController extends ApplicationApiController
*/
private $deletionService;
/**
* @var \Pterodactyl\Contracts\Repository\AllocationRepositoryInterface
*/
private $repository;
/**
* AllocationController constructor.
*
* @param \Pterodactyl\Services\Allocations\AssignmentService $assignmentService
* @param \Pterodactyl\Services\Allocations\AllocationDeletionService $deletionService
* @param \Pterodactyl\Contracts\Repository\AllocationRepositoryInterface $repository
*/
public function __construct(
AssignmentService $assignmentService,
AllocationDeletionService $deletionService,
AllocationRepositoryInterface $repository
AllocationDeletionService $deletionService
) {
parent::__construct();
$this->assignmentService = $assignmentService;
$this->deletionService = $deletionService;
$this->repository = $repository;
}
/**
* Return all of the allocations that exist for a given node.
*
* @param \Pterodactyl\Http\Requests\Api\Application\Allocations\GetAllocationsRequest $request
* @param \Pterodactyl\Models\Node $node
* @return array
*/
public function index(GetAllocationsRequest $request): array
public function index(GetAllocationsRequest $request, Node $node): array
{
$allocations = $this->repository->getPaginatedAllocationsForNode(
$request->getModel(Node::class)->id, 50
);
$allocations = $node->allocations()->paginate(50);
return $this->fractal->collection($allocations)
->transformWith($this->getTransformer(AllocationTransformer::class))
@ -71,32 +61,35 @@ class AllocationController extends ApplicationApiController
* Store new allocations for a given node.
*
* @param \Pterodactyl\Http\Requests\Api\Application\Allocations\StoreAllocationRequest $request
* @return \Illuminate\Http\Response
* @param \Pterodactyl\Models\Node $node
* @return \Illuminate\Http\JsonResponse
*
* @throws \Pterodactyl\Exceptions\Service\Allocation\CidrOutOfRangeException
* @throws \Pterodactyl\Exceptions\Service\Allocation\InvalidPortMappingException
* @throws \Pterodactyl\Exceptions\Service\Allocation\PortOutOfRangeException
* @throws \Pterodactyl\Exceptions\Service\Allocation\TooManyPortsInRangeException
*/
public function store(StoreAllocationRequest $request): Response
public function store(StoreAllocationRequest $request, Node $node): JsonResponse
{
$this->assignmentService->handle($request->getModel(Node::class), $request->validated());
$this->assignmentService->handle($node, $request->validated());
return response('', 204);
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
}
/**
* Delete a specific allocation from the Panel.
*
* @param \Pterodactyl\Http\Requests\Api\Application\Allocations\DeleteAllocationRequest $request
* @return \Illuminate\Http\Response
* @param \Pterodactyl\Models\Node $node
* @param \Pterodactyl\Models\Allocation $allocation
* @return \Illuminate\Http\JsonResponse
*
* @throws \Pterodactyl\Exceptions\Service\Allocation\ServerUsingAllocationException
*/
public function delete(DeleteAllocationRequest $request): Response
public function delete(DeleteAllocationRequest $request, Node $node, Allocation $allocation): JsonResponse
{
$this->deletionService->handle($request->getModel(Allocation::class));
$this->deletionService->handle($allocation);
return response('', 204);
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
}
}

View File

@ -10,6 +10,40 @@ use Pterodactyl\Http\Controllers\Api\Application\ApplicationApiController;
abstract class ClientApiController extends ApplicationApiController
{
/**
* Returns only the includes which are valid for the given transformer.
*
* @param \Pterodactyl\Transformers\Api\Client\BaseClientTransformer $transformer
* @param array $merge
* @return string[]
*/
protected function getIncludesForTransformer(BaseClientTransformer $transformer, array $merge = [])
{
$filtered = array_filter($this->parseIncludes(), function ($datum) use ($transformer) {
return in_array($datum, $transformer->getAvailableIncludes());
});
return array_merge($filtered, $merge);
}
/**
* Returns the parsed includes for this request.
*
* @return string[]
*/
protected function parseIncludes()
{
$includes = $this->request->query('include') ?? [];
if (! is_string($includes)) {
return $includes;
}
return array_map(function ($item) {
return trim($item);
}, explode(',', $includes));
}
/**
* Return an instance of an application transformer.
*

View File

@ -3,7 +3,9 @@
namespace Pterodactyl\Http\Controllers\Api\Client;
use Pterodactyl\Models\User;
use Pterodactyl\Models\Server;
use Pterodactyl\Models\Permission;
use Spatie\QueryBuilder\QueryBuilder;
use Pterodactyl\Repositories\Eloquent\ServerRepository;
use Pterodactyl\Transformers\Api\Client\ServerTransformer;
use Pterodactyl\Http\Requests\Api\Client\GetServersRequest;
@ -36,32 +38,36 @@ class ClientController extends ClientApiController
*/
public function index(GetServersRequest $request): array
{
// Check for the filter parameter on the request.
switch ($request->input('filter')) {
case 'all':
$filter = User::FILTER_LEVEL_ALL;
break;
case 'admin':
$filter = User::FILTER_LEVEL_ADMIN;
break;
case 'owner':
$filter = User::FILTER_LEVEL_OWNER;
break;
case 'subuser-of':
default:
$filter = User::FILTER_LEVEL_SUBUSER;
break;
$user = $request->user();
$level = $request->getFilterLevel();
$transformer = $this->getTransformer(ServerTransformer::class);
// Start the query builder and ensure we eager load any requested relationships from the request.
$builder = Server::query()->with($this->getIncludesForTransformer($transformer, ['node']));
if ($level === User::FILTER_LEVEL_OWNER) {
$builder = $builder->where('owner_id', $request->user()->id);
}
// If set to all, display all servers they can access, including those they access as an
// admin. If set to subuser, only return the servers they can access because they are owner,
// or marked as a subuser of the server.
elseif (($level === User::FILTER_LEVEL_ALL && ! $user->root_admin) || $level === User::FILTER_LEVEL_SUBUSER) {
$builder = $builder->whereIn('id', $user->accessibleServers()->pluck('id')->all());
}
// If set to admin, only display the servers a user can access because they are an administrator.
// This means only servers the user would not have access to if they were not an admin (because they
// are not an owner or subuser) are returned.
elseif ($level === User::FILTER_LEVEL_ADMIN && $user->root_admin) {
$builder = $builder->whereNotIn('id', $user->accessibleServers()->pluck('id')->all());
}
$servers = $this->repository
->setSearchTerm($request->input('query'))
->filterUserAccessServers(
$request->user(), $filter, config('pterodactyl.paginate.frontend.servers')
);
$builder = QueryBuilder::for($builder)->allowedFilters(
'uuid', 'name', 'external_id'
);
return $this->fractal->collection($servers)
->transformWith($this->getTransformer(ServerTransformer::class))
->toArray();
$servers = $builder->paginate(min($request->query('per_page', 50), 100))->appends($request->query());
return $this->fractal->transformWith($transformer)->collection($servers)->toArray();
}
/**

View File

@ -159,7 +159,7 @@ class FileController extends ClientApiController
{
$this->fileRepository
->setServer($server)
->createDirectory($request->input('name'), $request->input('directory', '/'));
->createDirectory($request->input('name'), $request->input('root', '/'));
return Response::create('', Response::HTTP_NO_CONTENT);
}

View File

@ -0,0 +1,127 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
use Pterodactyl\Models\Server;
use Illuminate\Http\JsonResponse;
use Pterodactyl\Models\Allocation;
use Pterodactyl\Exceptions\DisplayException;
use Pterodactyl\Repositories\Eloquent\ServerRepository;
use Pterodactyl\Repositories\Eloquent\AllocationRepository;
use Pterodactyl\Transformers\Api\Client\AllocationTransformer;
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
use Pterodactyl\Http\Requests\Api\Client\Servers\Network\GetNetworkRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Network\DeleteAllocationRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Network\UpdateAllocationRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Network\SetPrimaryAllocationRequest;
class NetworkAllocationController extends ClientApiController
{
/**
* @var \Pterodactyl\Repositories\Eloquent\AllocationRepository
*/
private $repository;
/**
* @var \Pterodactyl\Repositories\Eloquent\ServerRepository
*/
private $serverRepository;
/**
* NetworkController constructor.
*
* @param \Pterodactyl\Repositories\Eloquent\AllocationRepository $repository
* @param \Pterodactyl\Repositories\Eloquent\ServerRepository $serverRepository
*/
public function __construct(
AllocationRepository $repository,
ServerRepository $serverRepository
) {
parent::__construct();
$this->repository = $repository;
$this->serverRepository = $serverRepository;
}
/**
* Lists all of the allocations available to a server and wether or
* not they are currently assigned as the primary for this server.
*
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Network\GetNetworkRequest $request
* @param \Pterodactyl\Models\Server $server
* @return array
*/
public function index(GetNetworkRequest $request, Server $server): array
{
return $this->fractal->collection($server->allocations)
->transformWith($this->getTransformer(AllocationTransformer::class))
->toArray();
}
/**
* Set the primary allocation for a server.
*
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Network\UpdateAllocationRequest $request
* @param \Pterodactyl\Models\Server $server
* @param \Pterodactyl\Models\Allocation $allocation
* @return array
*
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
*/
public function update(UpdateAllocationRequest $request, Server $server, Allocation $allocation): array
{
$allocation = $this->repository->update($allocation->id, [
'notes' => $request->input('notes'),
]);
return $this->fractal->item($allocation)
->transformWith($this->getTransformer(AllocationTransformer::class))
->toArray();
}
/**
* Set the primary allocation for a server.
*
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Network\SetPrimaryAllocationRequest $request
* @param \Pterodactyl\Models\Server $server
* @param \Pterodactyl\Models\Allocation $allocation
* @return array
*
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
*/
public function setPrimary(SetPrimaryAllocationRequest $request, Server $server, Allocation $allocation): array
{
$this->serverRepository->update($server->id, ['allocation_id' => $allocation->id]);
return $this->fractal->item($allocation)
->transformWith($this->getTransformer(AllocationTransformer::class))
->toArray();
}
/**
* Delete an allocation from a server.
*
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Network\DeleteAllocationRequest $request
* @param \Pterodactyl\Models\Server $server
* @param \Pterodactyl\Models\Allocation $allocation
* @return \Illuminate\Http\JsonResponse
*
* @throws \Pterodactyl\Exceptions\DisplayException
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
*/
public function delete(DeleteAllocationRequest $request, Server $server, Allocation $allocation)
{
if ($allocation->id === $server->allocation_id) {
throw new DisplayException(
'Cannot delete the primary allocation for a server.'
);
}
$this->repository->update($allocation->id, ['server_id' => null, 'notes' => null]);
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
}
}

View File

@ -1,48 +0,0 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
use Pterodactyl\Models\Server;
use Pterodactyl\Repositories\Eloquent\AllocationRepository;
use Pterodactyl\Transformers\Api\Client\AllocationTransformer;
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
use Pterodactyl\Http\Requests\Api\Client\Servers\Network\GetNetworkRequest;
class NetworkController extends ClientApiController
{
/**
* @var \Pterodactyl\Repositories\Eloquent\AllocationRepository
*/
private $repository;
/**
* NetworkController constructor.
*
* @param \Pterodactyl\Repositories\Eloquent\AllocationRepository $repository
*/
public function __construct(AllocationRepository $repository)
{
parent::__construct();
$this->repository = $repository;
}
/**
* Lists all of the allocations available to a server and wether or
* not they are currently assigned as the primary for this server.
*
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Network\GetNetworkRequest $request
* @param \Pterodactyl\Models\Server $server
* @return array
*/
public function index(GetNetworkRequest $request, Server $server): array
{
$allocations = $this->repository->findWhere([
['server_id', '=', $server->id],
]);
return $this->fractal->collection($allocations)
->transformWith($this->getTransformer(AllocationTransformer::class))
->toArray();
}
}

View File

@ -2,8 +2,6 @@
namespace Pterodactyl\Http\Controllers\Base;
use Illuminate\Http\Request;
use Pterodactyl\Models\User;
use Pterodactyl\Http\Controllers\Controller;
use Pterodactyl\Contracts\Repository\ServerRepositoryInterface;
@ -27,15 +25,10 @@ class IndexController extends Controller
/**
* Returns listing of user's servers.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\View\View
*/
public function index(Request $request)
public function index()
{
$servers = $this->repository->setSearchTerm($request->input('query'))->filterUserAccessServers(
$request->user(), User::FILTER_LEVEL_ALL, config('pterodactyl.paginate.frontend.servers')
);
return view('templates/base.core', ['servers' => $servers]);
return view('templates/base.core');
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace Pterodactyl\Http\Middleware\Api\Client\Server;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class AllocationBelongsToServer
{
/**
* Ensure that the allocation found in the URL belongs to the server being queried.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*
* @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException
*/
public function handle(Request $request, Closure $next)
{
/** @var \Pterodactyl\Models\Server $server */
$server = $request->route()->parameter('server');
/** @var \Pterodactyl\Models\Allocation|null $allocation */
$allocation = $request->route()->parameter('allocation');
if ($allocation && $allocation->server_id !== $server->id) {
throw new NotFoundHttpException;
}
return $next($request);
}
}

View File

@ -65,7 +65,7 @@ class AuthenticateServerAccess
}
if ($server->suspended) {
throw new AccessDeniedHttpException('This server is currenty suspended and the functionality requested is unavailable.');
throw new AccessDeniedHttpException('This server is currently suspended and the functionality requested is unavailable.');
}
if (! $server->isInstalled()) {

View File

@ -4,12 +4,12 @@ namespace Pterodactyl\Http\Middleware\Api\Client;
use Closure;
use Pterodactyl\Models\Backup;
use Pterodactyl\Models\Database;
use Illuminate\Container\Container;
use Pterodactyl\Contracts\Extensions\HashidsInterface;
use Pterodactyl\Http\Middleware\Api\ApiSubstituteBindings;
use Pterodactyl\Exceptions\Repository\RecordNotFoundException;
use Pterodactyl\Contracts\Repository\ServerRepositoryInterface;
use Pterodactyl\Contracts\Repository\DatabaseRepositoryInterface;
class SubstituteClientApiBindings extends ApiSubstituteBindings
{
@ -43,17 +43,9 @@ class SubstituteClientApiBindings extends ApiSubstituteBindings
});
$this->router->bind('database', function ($value) use ($request) {
try {
$id = Container::getInstance()->make(HashidsInterface::class)->decodeFirst($value);
$id = Container::getInstance()->make(HashidsInterface::class)->decodeFirst($value);
return Container::getInstance()->make(DatabaseRepositoryInterface::class)->findFirstWhere([
['id', '=', $id],
]);
} catch (RecordNotFoundException $exception) {
$request->attributes->set('is_missing_model', true);
return null;
}
return Database::query()->where('id', $id)->firstOrFail();
});
$this->router->model('backup', Backup::class, function ($value) {

View File

@ -2,6 +2,8 @@
namespace Pterodactyl\Http\Requests\Api\Client;
use Pterodactyl\Models\User;
class GetServersRequest extends ClientApiRequest
{
/**
@ -11,4 +13,28 @@ class GetServersRequest extends ClientApiRequest
{
return true;
}
/**
* Return the filtering method for servers when the client base endpoint is requested.
*
* @return int
*/
public function getFilterLevel(): int
{
switch ($this->input('type')) {
case 'all':
return User::FILTER_LEVEL_ALL;
break;
case 'admin':
return User::FILTER_LEVEL_ADMIN;
break;
case 'owner':
return User::FILTER_LEVEL_OWNER;
break;
case 'subuser-of':
default:
return User::FILTER_LEVEL_SUBUSER;
break;
}
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace Pterodactyl\Http\Requests\Api\Client\Servers\Network;
use Pterodactyl\Models\Permission;
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
class DeleteAllocationRequest extends ClientApiRequest
{
/**
* @return string
*/
public function permission(): string
{
return Permission::ACTION_ALLOCATION_DELETE;
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace Pterodactyl\Http\Requests\Api\Client\Servers\Network;
class SetPrimaryAllocationRequest extends UpdateAllocationRequest
{
/**
* @return array
*/
public function rules(): array
{
return [];
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace Pterodactyl\Http\Requests\Api\Client\Servers\Network;
use Pterodactyl\Models\Allocation;
use Pterodactyl\Models\Permission;
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
class UpdateAllocationRequest extends ClientApiRequest
{
/**
* @return string
*/
public function permission(): string
{
return Permission::ACTION_ALLOCATION_UPDATE;
}
/**
* @return array
*/
public function rules(): array
{
$rules = Allocation::getRules();
return [
'notes' => array_merge($rules['notes'], ['present']),
];
}
}

View File

@ -1,38 +0,0 @@
<?php
namespace Pterodactyl\Http\ViewComposers\Server;
use Illuminate\View\View;
use Illuminate\Http\Request;
class ServerDataComposer
{
/**
* @var \Illuminate\Http\Request
*/
protected $request;
/**
* ServerDataComposer constructor.
*
* @param \Illuminate\Http\Request $request
*/
public function __construct(Request $request)
{
$this->request = $request;
}
/**
* Attach server data to a view automatically.
*
* @param \Illuminate\View\View $view
*/
public function compose(View $view)
{
$server = $this->request->get('server');
$view->with('server', $server);
$view->with('node', object_get($server, 'node'));
$view->with('daemon_token', $this->request->get('server_token'));
}
}

View File

@ -1,51 +0,0 @@
<?php
namespace Pterodactyl\Http\ViewComposers;
use Illuminate\View\View;
use Illuminate\Http\Request;
use Pterodactyl\Models\User;
use Pterodactyl\Contracts\Repository\ServerRepositoryInterface;
class ServerListComposer
{
/**
* @var \Illuminate\Http\Request
*/
private $request;
/**
* @var \Pterodactyl\Contracts\Repository\ServerRepositoryInterface
*/
private $repository;
/**
* ServerListComposer constructor.
*
* @param \Illuminate\Http\Request $request
* @param \Pterodactyl\Contracts\Repository\ServerRepositoryInterface $repository
*/
public function __construct(Request $request, ServerRepositoryInterface $repository)
{
$this->request = $request;
$this->repository = $repository;
}
/**
* Attach a list of servers the user can access to the view.
*
* @param \Illuminate\View\View $view
*/
public function compose(View $view)
{
if (! $this->request->user()) {
return;
}
$servers = $this->repository
->setColumns(['id', 'owner_id', 'uuidShort', 'name', 'description'])
->filterUserAccessServers($this->request->user(), User::FILTER_LEVEL_SUBUSER, false);
$view->with('sidebarServerList', $servers);
}
}

View File

@ -9,6 +9,7 @@ namespace Pterodactyl\Models;
* @property string|null $ip_alias
* @property int $port
* @property int|null $server_id
* @property string|null $notes
* @property \Carbon\Carbon|null $created_at
* @property \Carbon\Carbon|null $updated_at
*
@ -60,6 +61,7 @@ class Allocation extends Model
'port' => 'required|numeric|between:1024,65553',
'ip_alias' => 'nullable|string',
'server_id' => 'nullable|exists:servers,id',
'notes' => 'nullable|string|max:256',
];
/**

View File

@ -44,7 +44,9 @@ class Permission extends Model
const ACTION_BACKUP_DOWNLOAD = 'backup.download';
const ACTION_ALLOCATION_READ = 'allocation.read';
const ACTION_ALLOCIATION_UPDATE = 'allocation.update';
const ACTION_ALLOCATION_CREATE = 'allocation.create';
const ACTION_ALLOCATION_UPDATE = 'allocation.update';
const ACTION_ALLOCATION_DELETE = 'allocation.delete';
const ACTION_FILE_READ = 'file.read';
const ACTION_FILE_CREATE = 'file.create';
@ -157,7 +159,9 @@ class Permission extends Model
'description' => 'Permissions that control a user\'s ability to modify the port allocations for this server.',
'keys' => [
'read' => 'Allows a user to view the allocations assigned to this server.',
'update' => 'Allows a user to modify the allocations assigned to this server.',
'create' => 'Allows a user to assign additional allocations to the server.',
'update' => 'Allows a user to change the primary server allocation and attach notes to each allocation.',
'delete' => 'Allows a user to delete an allocation from the server.',
],
],

View File

@ -17,6 +17,11 @@ class RecoveryToken extends Model
*/
const UPDATED_AT = null;
/**
* @var bool
*/
public $timestamps = true;
/**
* @var bool
*/

View File

@ -7,6 +7,7 @@ use Illuminate\Support\Collection;
use Illuminate\Validation\Rules\In;
use Illuminate\Auth\Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Database\Eloquent\Builder;
use Pterodactyl\Models\Traits\Searchable;
use Illuminate\Auth\Passwords\CanResetPassword;
use Pterodactyl\Traits\Helpers\AvailableLanguages;
@ -260,4 +261,21 @@ class User extends Model implements
{
return $this->hasMany(RecoveryToken::class);
}
/**
* Returns all of the servers that a user can access by way of being the owner of the
* server, or because they are assigned as a subuser for that server.
*
* @return \Illuminate\Database\Eloquent\Builder
*/
public function accessibleServers()
{
return Server::query()
->select('servers.*')
->leftJoin('subusers', 'subusers.server_id', '=', 'servers.id')
->where(function (Builder $builder) {
$builder->where('servers.owner_id', $this->id)->orWhere('subusers.user_id', $this->id);
})
->groupBy('servers.id');
}
}

View File

@ -4,8 +4,6 @@ namespace Pterodactyl\Providers;
use Illuminate\Support\ServiceProvider;
use Pterodactyl\Http\ViewComposers\AssetComposer;
use Pterodactyl\Http\ViewComposers\ServerListComposer;
use Pterodactyl\Http\ViewComposers\Server\ServerDataComposer;
class ViewComposerServiceProvider extends ServiceProvider
{
@ -15,10 +13,5 @@ class ViewComposerServiceProvider extends ServiceProvider
public function boot()
{
$this->app->make('view')->composer('*', AssetComposer::class);
$this->app->make('view')->composer('server.*', ServerDataComposer::class);
// Add data to make the sidebar work when viewing a server.
$this->app->make('view')->composer(['server.*'], ServerListComposer::class);
}
}

View File

@ -5,7 +5,6 @@ namespace Pterodactyl\Repositories\Eloquent;
use Illuminate\Support\Collection;
use Pterodactyl\Models\Allocation;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Pterodactyl\Contracts\Repository\AllocationRepositoryInterface;
class AllocationRepository extends EloquentRepository implements AllocationRepositoryInterface
@ -20,41 +19,6 @@ class AllocationRepository extends EloquentRepository implements AllocationRepos
return Allocation::class;
}
/**
* Set an array of allocation IDs to be assigned to a specific server.
*
* @param int|null $server
* @param array $ids
* @return int
*/
public function assignAllocationsToServer(int $server = null, array $ids): int
{
return $this->getBuilder()->whereIn('id', $ids)->update(['server_id' => $server]);
}
/**
* Return all of the allocations for a specific node.
*
* @param int $node
* @return \Illuminate\Support\Collection
*/
public function getAllocationsForNode(int $node): Collection
{
return $this->getBuilder()->where('node_id', $node)->get($this->getColumns());
}
/**
* Return all of the allocations for a node in a paginated format.
*
* @param int $node
* @param int $perPage
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
*/
public function getPaginatedAllocationsForNode(int $node, int $perPage = 100): LengthAwarePaginator
{
return $this->getBuilder()->where('node_id', $node)->paginate($perPage, $this->getColumns());
}
/**
* Return all of the unique IPs that exist for a given node.
*

View File

@ -2,9 +2,11 @@
namespace Pterodactyl\Repositories\Eloquent;
use Illuminate\Http\Request;
use Webmozart\Assert\Assert;
use Illuminate\Support\Collection;
use Pterodactyl\Repositories\Repository;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Expression;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
@ -15,6 +17,53 @@ use Pterodactyl\Contracts\Repository\Attributes\SearchableInterface;
abstract class EloquentRepository extends Repository implements RepositoryInterface
{
/**
* @var bool
*/
protected $useRequestFilters = false;
/**
* Determines if the repository function should use filters off the request object
* present when returning results. This allows repository methods to be called in API
* context's such that we can pass through ?filter[name]=Dane&sort=desc for example.
*
* @param bool $usingFilters
* @return $this
*/
public function usingRequestFilters($usingFilters = true)
{
$this->useRequestFilters = $usingFilters;
return $this;
}
/**
* Returns the request instance.
*
* @return \Illuminate\Http\Request
*/
protected function request()
{
return $this->app->make(Request::class);
}
/**
* Paginate the response data based on the page para.
*
* @param \Illuminate\Database\Eloquent\Builder $instance
* @param int $default
*
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
*/
protected function paginate(Builder $instance, int $default = 50)
{
if (! $this->useRequestFilters) {
return $instance->paginate($default);
}
return $instance->paginate($this->request()->query('per_page', $default));
}
/**
* Return an instance of the eloquent model bound to this
* repository instance.
@ -236,6 +285,7 @@ abstract class EloquentRepository extends Repository implements RepositoryInterf
* Return all records associated with the given model.
*
* @return \Illuminate\Support\Collection
* @deprecated Just use the model
*/
public function all(): Collection
{
@ -313,6 +363,7 @@ abstract class EloquentRepository extends Repository implements RepositoryInterf
* Get the amount of entries in the database.
*
* @return int
* @deprecated just use the count method off a model
*/
public function count(): int
{

View File

@ -2,7 +2,6 @@
namespace Pterodactyl\Repositories\Eloquent;
use Pterodactyl\Models\User;
use Pterodactyl\Models\Server;
use Illuminate\Support\Collection;
use Illuminate\Database\Eloquent\Builder;
@ -226,43 +225,6 @@ class ServerRepository extends EloquentRepository implements ServerRepositoryInt
];
}
/**
* Return a paginated list of servers that a user can access at a given level.
*
* @param \Pterodactyl\Models\User $user
* @param int $level
* @param bool|int $paginate
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator|\Illuminate\Database\Eloquent\Collection
*/
public function filterUserAccessServers(User $user, int $level, $paginate = 25)
{
$instance = $this->getBuilder()->select($this->getColumns())->with(['user', 'node', 'allocation']);
// If access level is set to owner, only display servers
// that the user owns.
if ($level === User::FILTER_LEVEL_OWNER) {
$instance->where('owner_id', $user->id);
}
// If set to all, display all servers they can access, including
// those they access as an admin. If set to subuser, only return
// the servers they can access because they are owner, or marked
// as a subuser of the server.
elseif (($level === User::FILTER_LEVEL_ALL && ! $user->root_admin) || $level === User::FILTER_LEVEL_SUBUSER) {
$instance->whereIn('id', $this->getUserAccessServers($user->id));
}
// If set to admin, only display the servers a user can access
// as an administrator (leaves out owned and subuser of).
elseif ($level === User::FILTER_LEVEL_ADMIN && $user->root_admin) {
$instance->whereNotIn('id', $this->getUserAccessServers($user->id));
}
$instance->search($this->getSearchTerm());
return $paginate ? $instance->paginate($paginate) : $instance->get();
}
/**
* Return a server by UUID.
*
@ -339,20 +301,6 @@ class ServerRepository extends EloquentRepository implements ServerRepositoryInt
return ! $this->getBuilder()->where('uuid', '=', $uuid)->orWhere('uuidShort', '=', $short)->exists();
}
/**
* Return an array of server IDs that a given user can access based
* on owner and subuser permissions.
*
* @param int $user
* @return int[]
*/
private function getUserAccessServers(int $user): array
{
return $this->getBuilder()->select('id')->where('owner_id', $user)->union(
$this->app->make(SubuserRepository::class)->getBuilder()->select('server_id')->where('user_id', $user)
)->pluck('id')->all();
}
/**
* Get the amount of servers that are suspended.
*

View File

@ -270,7 +270,9 @@ class ServerCreationService
$records = array_merge($records, $data['allocation_additional']);
}
$this->allocationRepository->assignAllocationsToServer($server->id, $records);
$this->allocationRepository->updateWhereIn('id', $records, [
'server_id' => $server->id,
]);
}
/**

View File

@ -39,6 +39,7 @@ class AllocationTransformer extends BaseTransformer
'ip' => $allocation->ip,
'alias' => $allocation->ip_alias,
'port' => $allocation->port,
'notes' => $allocation->notes,
'assigned' => ! is_null($allocation->server_id),
];
}

View File

@ -24,13 +24,13 @@ class AllocationTransformer extends BaseClientTransformer
*/
public function transform(Allocation $model)
{
$model->loadMissing('server');
return [
'id' => $model->id,
'ip' => $model->ip,
'alias' => $model->ip_alias,
'ip_alias' => $model->ip_alias,
'port' => $model->port,
'default' => $model->getRelation('server')->allocation_id === $model->id,
'notes' => $model->notes,
'is_default' => $model->server->allocation_id === $model->id,
];
}
}

View File

@ -5,9 +5,15 @@ namespace Pterodactyl\Transformers\Api\Client;
use Pterodactyl\Models\Egg;
use Pterodactyl\Models\Server;
use Pterodactyl\Models\Subuser;
use Pterodactyl\Models\Allocation;
class ServerTransformer extends BaseClientTransformer
{
/**
* @var string[]
*/
protected $defaultIncludes = ['allocations'];
/**
* @var array
*/
@ -41,10 +47,6 @@ class ServerTransformer extends BaseClientTransformer
'port' => $server->node->daemonSFTP,
],
'description' => $server->description,
'allocation' => [
'ip' => $server->allocation->alias,
'port' => $server->allocation->port,
],
'limits' => [
'memory' => $server->memory,
'swap' => $server->swap,
@ -62,6 +64,22 @@ class ServerTransformer extends BaseClientTransformer
];
}
/**
* Returns the allocations associated with this server.
*
* @param \Pterodactyl\Models\Server $server
* @return \League\Fractal\Resource\Collection
* @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException
*/
public function includeAllocations(Server $server)
{
return $this->collection(
$server->allocations,
$this->makeTransformer(AllocationTransformer::class),
Allocation::RESOURCE_NAME
);
}
/**
* Returns the egg associated with this server.
*

23
babel.config.js Normal file
View File

@ -0,0 +1,23 @@
module.exports = {
presets: [
'@babel/typescript',
['@babel/env', {
modules: false,
useBuiltIns: 'entry',
corejs: 3,
}],
'@babel/react',
],
plugins: [
'babel-plugin-macros',
'styled-components',
'react-hot-loader/babel',
'@babel/transform-runtime',
'@babel/transform-react-jsx',
'@babel/proposal-class-properties',
'@babel/proposal-object-rest-spread',
'@babel/proposal-optional-chaining',
'@babel/proposal-nullish-coalescing-operator',
'@babel/syntax-dynamic-import',
],
};

View File

@ -37,6 +37,7 @@
"psy/psysh": "^0.10.4",
"s1lentium/iptools": "^1.1",
"spatie/laravel-fractal": "^5.7",
"spatie/laravel-query-builder": "^2.8",
"staudenmeir/belongs-to-through": "^2.10",
"symfony/yaml": "^4.4",
"webmozart/assert": "^1.9"

66
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "155b8e930e604c0476fa975b1084ca3f",
"content-hash": "d05ab995e4aff4b847ff2a027924065c",
"packages": [
{
"name": "appstract/laravel-blade-directives",
@ -3361,6 +3361,70 @@
],
"time": "2020-03-02T18:40:49+00:00"
},
{
"name": "spatie/laravel-query-builder",
"version": "2.8.2",
"source": {
"type": "git",
"url": "https://github.com/spatie/laravel-query-builder.git",
"reference": "2737b2298e8bfeb632a80013646943307bf31775"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/laravel-query-builder/zipball/2737b2298e8bfeb632a80013646943307bf31775",
"reference": "2737b2298e8bfeb632a80013646943307bf31775",
"shasum": ""
},
"require": {
"illuminate/database": "~5.6.34|~5.7.0|~5.8.0|^6.0|^7.0",
"illuminate/http": "~5.6.34|~5.7.0|~5.8.0|^6.0|^7.0",
"illuminate/support": "~5.6.34|~5.7.0|~5.8.0|^6.0|^7.0",
"php": "^7.1"
},
"require-dev": {
"ext-json": "*",
"orchestra/testbench": "~3.6.0|~3.7.0|~3.8.0|^4.0|^5.0",
"phpunit/phpunit": "^7.0|^8.0|^9.0"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Spatie\\QueryBuilder\\QueryBuilderServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Spatie\\QueryBuilder\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Alex Vanderbist",
"email": "alex@spatie.be",
"homepage": "https://spatie.be",
"role": "Developer"
}
],
"description": "Easily build Eloquent queries from API requests",
"homepage": "https://github.com/spatie/laravel-query-builder",
"keywords": [
"laravel-query-builder",
"spatie"
],
"funding": [
{
"url": "https://spatie.be/open-source/support-us",
"type": "custom"
}
],
"time": "2020-05-25T09:36:37+00:00"
},
{
"name": "staudenmeir/belongs-to-through",
"version": "v2.10",

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddNotesColumnForAllocations extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('allocations', function (Blueprint $table) {
$table->string('notes')->nullable()->after('server_id');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('allocations', function (Blueprint $table) {
$table->dropColumn('notes');
});
}
}

View File

@ -1,17 +1,18 @@
{
"name": "pterodactyl-panel",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^1.2.19",
"@fortawesome/fontawesome-svg-core": "1.2.19",
"@fortawesome/free-solid-svg-icons": "^5.9.0",
"@fortawesome/react-fontawesome": "^0.1.4",
"@fortawesome/react-fontawesome": "0.1.4",
"@types/react-google-recaptcha": "^1.1.1",
"axios": "^0.19.0",
"axios": "^0.19.2",
"ayu-ace": "^2.0.4",
"brace": "^0.11.1",
"chart.js": "^2.8.0",
"classnames": "^2.2.6",
"date-fns": "^1.29.0",
"easy-peasy": "^3.2.3",
"date-fns": "^2.14.0",
"debounce": "^1.2.0",
"deepmerge": "^4.2.2",
"easy-peasy": "^3.3.1",
"events": "^3.0.0",
"formik": "^2.1.4",
"i18next": "^19.0.0",
@ -19,26 +20,26 @@
"i18next-localstorage-backend": "^3.0.0",
"i18next-xhr-backend": "^3.2.2",
"jquery": "^3.3.1",
"lodash-es": "^4.17.15",
"path": "^0.12.7",
"query-string": "^6.7.0",
"react": "^16.12.0",
"react": "^16.13.1",
"react-dom": "npm:@hot-loader/react-dom",
"react-fast-compare": "^3.2.0",
"react-google-recaptcha": "^2.0.1",
"react-hot-loader": "^4.12.18",
"react-hot-loader": "^4.12.21",
"react-i18next": "^11.2.1",
"react-redux": "^7.1.0",
"react-router-dom": "^5.1.2",
"react-transition-group": "^4.3.0",
"react-transition-group": "^4.4.1",
"sockette": "^2.0.6",
"styled-components": "^4.4.1",
"styled-components": "^5.1.1",
"styled-components-breakpoint": "^3.0.0-preview.20",
"use-react-router": "^1.0.7",
"swr": "^0.2.3",
"uuid": "^3.3.2",
"xterm": "^3.14.4",
"xterm-addon-attach": "^0.1.0",
"xterm-addon-fit": "^0.1.0",
"yup": "^0.27.0"
"yup": "^0.29.1"
},
"devDependencies": {
"@babel/core": "^7.7.5",
@ -47,75 +48,65 @@
"@babel/plugin-proposal-object-rest-spread": "^7.7.4",
"@babel/plugin-proposal-optional-chaining": "^7.8.3",
"@babel/plugin-syntax-dynamic-import": "^7.7.4",
"@babel/plugin-transform-react-jsx": "^7.10.4",
"@babel/plugin-transform-runtime": "^7.7.5",
"@babel/preset-env": "^7.7.5",
"@babel/preset-react": "^7.7.4",
"@babel/preset-typescript": "^7.7.4",
"@babel/runtime": "^7.7.5",
"@types/chart.js": "^2.8.5",
"@types/classnames": "^2.2.8",
"@types/debounce": "^1.2.0",
"@types/events": "^3.0.0",
"@types/feather-icons": "^4.7.0",
"@types/lodash": "^4.14.119",
"@types/lodash-es": "^4.17.3",
"@types/node": "^12.6.9",
"@types/query-string": "^6.3.0",
"@types/react": "^16.9.15",
"@types/react-dom": "^16.9.4",
"@types/react": "^16.9.41",
"@types/react-dom": "^16.9.8",
"@types/react-redux": "^7.1.1",
"@types/react-router": "^5.1.3",
"@types/react-router-dom": "^5.1.3",
"@types/react-transition-group": "^2.9.2",
"@types/styled-components": "^4.4.0",
"@types/react-transition-group": "^4.4.0",
"@types/styled-components": "^5.1.0",
"@types/uuid": "^3.4.5",
"@types/webpack-env": "^1.13.6",
"@types/yup": "^0.26.17",
"@typescript-eslint/eslint-plugin": "^2.19.0",
"@typescript-eslint/parser": "^2.19.0",
"@types/webpack-env": "^1.15.2",
"@types/yup": "^0.29.3",
"@typescript-eslint/eslint-plugin": "^3.5.0",
"@typescript-eslint/parser": "^3.5.0",
"babel-loader": "^8.0.6",
"babel-plugin-styled-components": "^1.10.6",
"babel-plugin-tailwind-components": "^0.5.10",
"babel-plugin-styled-components": "^1.10.7",
"cross-env": "^7.0.2",
"css-loader": "^3.2.1",
"cssnano": "^4.1.10",
"eslint": "^5.16.0",
"eslint-config-standard": "^12.0.0",
"eslint-plugin-import": "^2.17.3",
"eslint": "^7.4.0",
"eslint-config-standard": "^14.1.1",
"eslint-plugin-import": "^2.22.0",
"eslint-plugin-node": "^9.1.0",
"eslint-plugin-promise": "^4.1.1",
"eslint-plugin-react-hooks": "^2.1.2",
"eslint-plugin-standard": "^4.0.0",
"fork-ts-checker-webpack-plugin": "^1.5.0",
"glob-all": "^3.1.0",
"html-webpack-plugin": "^3.2.0",
"mini-css-extract-plugin": "^0.8.0",
"postcss": "^7.0.24",
"postcss-import": "^12.0.1",
"postcss-loader": "^3.0.0",
"postcss-preset-env": "^6.7.0",
"precss": "^4.0.0",
"purgecss-webpack-plugin": "^1.6.0",
"eslint-plugin-promise": "^4.2.1",
"eslint-plugin-react": "^7.20.3",
"eslint-plugin-react-hooks": "^4.0.5",
"eslint-plugin-standard": "^4.0.1",
"fork-ts-checker-webpack-plugin": "^5.0.6",
"redux-devtools-extension": "^2.13.8",
"resolve-url-loader": "^3.0.0",
"source-map-loader": "^0.2.4",
"style-loader": "^0.23.1",
"tailwindcss": "^0.7.4",
"terser-webpack-plugin": "^1.3.0",
"ts-loader": "^6.2.1",
"typescript": "^3.7.5",
"webpack": "^4.41.2",
"source-map-loader": "^1.0.1",
"style-loader": "^1.2.1",
"svg-url-loader": "^6.0.0",
"tailwindcss": "^1.4.6",
"terser-webpack-plugin": "^3.0.6",
"twin.macro": "^1.4.1",
"typescript": "^3.9.6",
"typescript-plugin-tw-template": "^2.0.1",
"webpack": "^4.43.0",
"webpack-assets-manifest": "^3.1.1",
"webpack-cli": "^3.3.10",
"webpack-dev-server": "^3.9.0",
"webpack-manifest-plugin": "^2.0.3",
"webpack-bundle-analyzer": "^3.8.0",
"webpack-cli": "^3.3.12",
"webpack-dev-server": "^3.11.0",
"yarn-deduplicate": "^1.1.1"
},
"scripts": {
"clean": "rm -rf public/assets/*.{js,css,map}",
"clean": "cd public/assets && find . \\( -name \"*.js\" -o -name \"*.map\" \\) -type f -delete",
"lint": "eslint ./resources/scripts/**/*.{ts,tsx} --ext .ts,.tsx",
"watch": "cross-env NODE_ENV=development ./node_modules/.bin/webpack --watch --progress",
"build": "cross-env NODE_ENV=development ./node_modules/.bin/webpack --progress",
"build:production": "yarn run clean && cross-env NODE_ENV=production ./node_modules/.bin/webpack --mode production",
"serve": "yarn run clean && cross-env PUBLIC_PATH=https://pterodactyl.test:8080 NODE_ENV=development webpack-dev-server --host 0.0.0.0 --hot --https --key /etc/ssl/private/pterodactyl.test-key.pem --cert /etc/ssl/private/pterodactyl.test.pem"
"serve": "yarn run clean && cross-env PUBLIC_PATH=https://pterodactyl.test:8080 NODE_ENV=development TSC_WATCHFILE=UseFsEventsWithFallbackDynamicPolling webpack-dev-server --host 0.0.0.0 --hot --https --key /etc/ssl/private/pterodactyl.test-key.pem --cert /etc/ssl/private/pterodactyl.test.pem"
},
"browserslist": [
"> 0.5%",

View File

@ -1,38 +1,76 @@
parser: "@typescript-eslint/parser"
parserOptions:
ecmaVersion: 6
ecmaFeatures:
jsx: true
project: "./tsconfig.json"
tsconfigRootDir: "./"
settings:
react:
pragma: "React"
version: "detect"
linkComponents:
- name: Link
linkAttribute: to
- name: NavLink
linkAttribute: to
env:
browser: true
es6: true
plugins:
- "@typescript-eslint"
- "react"
- "react-hooks"
- "@typescript-eslint"
extends:
- "standard"
- "plugin:react/recommended"
- "plugin:@typescript-eslint/recommended"
globals:
tw: "readonly"
rules:
indent:
- error
- 4
- SwitchCase: 1
semi:
- error
- always
comma-dangle:
- error
- always-multiline
array-bracket-spacing:
- warn
- always
"react-hooks/rules-of-hooks":
- error
"react-hooks/exhaustive-deps": 0
"@typescript-eslint/explicit-function-return-type": 0
"@typescript-eslint/explicit-member-accessibility": 0
"@typescript-eslint/ban-ts-ignore": 0
"@typescript-eslint/no-unused-vars": 0
"@typescript-eslint/no-explicit-any": 0
"@typescript-eslint/no-non-null-assertion": 0
"@typescript-eslint/ban-ts-comment": 0
# This would be nice to have, but don't want to deal with the warning spam at the moment.
"@typescript-eslint/explicit-module-boundary-types": 0
no-restricted-imports:
- error
- paths:
- name: styled-components
message: Please import from styled-components/macro.
patterns:
- "!styled-components/macro"
# Not sure, this rule just doesn't work right and is protected by our use of Typescript anyways
# so I'm just not going to worry about it.
"react/prop-types": 0
"react/display-name": 0
"react/jsx-indent-props":
- warn
- 4
"react/jsx-boolean-value":
- warn
- never
"react/jsx-closing-bracket-location":
- 1
- "line-aligned"
"react/jsx-closing-tag-location": 1
overrides:
- files:
- "**/*.tsx"

View File

@ -1,21 +1,30 @@
import React from 'react';
import { Route } from 'react-router';
import { CSSTransition, TransitionGroup } from 'react-transition-group';
import { SwitchTransition } from 'react-transition-group';
import Fade from '@/components/elements/Fade';
import styled from 'styled-components/macro';
import tw from 'twin.macro';
type Props = Readonly<{
children: React.ReactNode;
}>;
const StyledSwitchTransition = styled(SwitchTransition)`
${tw`relative`};
& section {
${tw`absolute w-full top-0 left-0`};
}
`;
export default ({ children }: Props) => (
const TransitionRouter: React.FC = ({ children }) => (
<Route
render={({ location }) => (
<TransitionGroup className={'route-transition-group'}>
<CSSTransition key={location.key} timeout={250} in={true} appear={true} classNames={'fade'}>
<StyledSwitchTransition>
<Fade timeout={150} key={location.key} in appear unmountOnExit>
<section>
{children}
</section>
</CSSTransition>
</TransitionGroup>
</Fade>
</StyledSwitchTransition>
)}
/>
);
export default TransitionRouter;

View File

@ -3,13 +3,13 @@ import { ApiKey, rawDataToApiKey } from '@/api/account/getApiKeys';
export default (description: string, allowedIps: string): Promise<ApiKey & { secretToken: string }> => {
return new Promise((resolve, reject) => {
http.post(`/api/client/account/api-keys`, {
http.post('/api/client/account/api-keys', {
description,
// eslint-disable-next-line @typescript-eslint/camelcase
allowed_ips: allowedIps.length > 0 ? allowedIps.split('\n') : [],
})
.then(({ data }) => resolve({
...rawDataToApiKey(data.attributes),
// eslint-disable-next-line camelcase
secretToken: data.meta?.secret_token ?? '',
}))
.catch(reject);

View File

@ -9,10 +9,8 @@ interface Data {
export default ({ current, password, confirmPassword }: Data): Promise<void> => {
return new Promise((resolve, reject) => {
http.put('/api/client/account/password', {
// eslint-disable-next-line @typescript-eslint/camelcase
current_password: current,
password: password,
// eslint-disable-next-line @typescript-eslint/camelcase
password_confirmation: confirmPassword,
})
.then(() => resolve())

View File

@ -4,11 +4,9 @@ import { LoginResponse } from '@/api/auth/login';
export default (token: string, code: string, recoveryToken?: string): Promise<LoginResponse> => {
return new Promise((resolve, reject) => {
http.post('/auth/login/checkpoint', {
/* eslint-disable @typescript-eslint/camelcase */
confirmation_token: token,
authentication_code: code,
recovery_token: (recoveryToken && recoveryToken.length > 0) ? recoveryToken : undefined,
/* eslint-enable @typescript-eslint/camelcase */
})
.then(response => resolve({
complete: response.data.data.complete,

View File

@ -17,7 +17,6 @@ export default (email: string, data: Data): Promise<PasswordResetResponse> => {
email,
token: data.token,
password: data.password,
// eslint-disable-next-line @typescript-eslint/camelcase
password_confirmation: data.passwordConfirmation,
})
.then(response => resolve({

View File

@ -3,16 +3,15 @@ import http, { getPaginationSet, PaginatedResult } from '@/api/http';
export default (query?: string, includeAdmin?: boolean): Promise<PaginatedResult<Server>> => {
return new Promise((resolve, reject) => {
http.get(`/api/client`, {
http.get('/api/client', {
params: {
include: [ 'allocation' ],
// eslint-disable-next-line @typescript-eslint/camelcase
filter: includeAdmin ? 'all' : undefined,
query,
type: includeAdmin ? 'all' : undefined,
'filter[name]': query,
},
})
.then(({ data }) => resolve({
items: (data.data || []).map((datum: any) => rawDataToServerObject(datum.attributes)),
items: (data.data || []).map((datum: any) => rawDataToServerObject(datum)),
pagination: getPaginationSet(data.meta.pagination),
}))
.catch(reject);

View File

@ -3,7 +3,7 @@ import http from '@/api/http';
export default (): Promise<PanelPermissions> => {
return new Promise((resolve, reject) => {
http.get(`/api/client/permissions`)
http.get('/api/client/permissions')
.then(({ data }) => resolve(data.attributes.permissions))
.catch(reject);
});

View File

@ -5,7 +5,7 @@ const http: AxiosInstance = axios.create({
timeout: 20000,
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json',
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-Token': (window as any).X_CSRF_TOKEN as string || '',
},
@ -75,12 +75,15 @@ export interface FractalResponseData {
object: string;
attributes: {
[k: string]: any;
relationships?: {
[k: string]: FractalResponseData;
};
relationships?: Record<string, FractalResponseData | FractalResponseList>;
};
}
export interface FractalResponseList {
object: 'list';
data: FractalResponseData[];
}
export interface PaginatedResult<T> {
items: T[];
pagination: PaginationDataSet;

View File

@ -14,23 +14,21 @@ export interface FileObject {
modifiedAt: Date;
}
export default (uuid: string, directory?: string): Promise<FileObject[]> => {
return new Promise((resolve, reject) => {
http.get(`/api/client/servers/${uuid}/files/list`, {
params: { directory },
})
.then(response => resolve((response.data.data || []).map((item: any): FileObject => ({
uuid: v4(),
name: item.attributes.name,
mode: item.attributes.mode,
size: Number(item.attributes.size),
isFile: item.attributes.is_file,
isSymlink: item.attributes.is_symlink,
isEditable: item.attributes.is_editable,
mimetype: item.attributes.mimetype,
createdAt: new Date(item.attributes.created_at),
modifiedAt: new Date(item.attributes.modified_at),
}))))
.catch(reject);
export default async (uuid: string, directory?: string): Promise<FileObject[]> => {
const { data } = await http.get(`/api/client/servers/${uuid}/files/list`, {
params: { directory },
});
return (data.data || []).map((item: any): FileObject => ({
uuid: v4(),
name: item.attributes.name,
mode: item.attributes.mode,
size: Number(item.attributes.size),
isFile: item.attributes.is_file,
isSymlink: item.attributes.is_symlink,
isEditable: item.attributes.is_editable,
mimetype: item.attributes.mimetype,
createdAt: new Date(item.attributes.created_at),
modifiedAt: new Date(item.attributes.modified_at),
}));
};

View File

@ -8,9 +8,7 @@ interface Data {
export default (uuid: string, { renameFrom, renameTo }: Data): Promise<void> => {
return new Promise((resolve, reject) => {
http.put(`/api/client/servers/${uuid}/files/rename`, {
// eslint-disable-next-line @typescript-eslint/camelcase
rename_from: renameFrom,
// eslint-disable-next-line @typescript-eslint/camelcase
rename_to: renameTo,
})
.then(() => resolve())

View File

@ -1,10 +1,13 @@
import http from '@/api/http';
import http, { FractalResponseData, FractalResponseList } from '@/api/http';
import { rawDataToServerAllocation } from '@/api/transformers';
export interface Allocation {
id: number;
ip: string;
alias: string | null;
port: number;
default: boolean;
notes: string | null;
isDefault: boolean;
}
export interface Server {
@ -35,7 +38,7 @@ export interface Server {
isInstalling: boolean;
}
export const rawDataToServerObject = (data: any): Server => ({
export const rawDataToServerObject = ({ attributes: data }: FractalResponseData): Server => ({
id: data.identifier,
uuid: data.uuid,
name: data.name,
@ -45,24 +48,20 @@ export const rawDataToServerObject = (data: any): Server => ({
port: data.sftp_details.port,
},
description: data.description ? ((data.description.length > 0) ? data.description : null) : null,
allocations: [ {
ip: data.allocation.ip,
alias: null,
port: data.allocation.port,
default: true,
} ],
limits: { ...data.limits },
featureLimits: { ...data.feature_limits },
isSuspended: data.is_suspended,
isInstalling: data.is_installing,
allocations: ((data.relationships?.allocations as FractalResponseList | undefined)?.data || []).map(rawDataToServerAllocation),
});
export default (uuid: string): Promise<[ Server, string[] ]> => {
return new Promise((resolve, reject) => {
http.get(`/api/client/servers/${uuid}`)
.then(({ data }) => resolve([
rawDataToServerObject(data.attributes),
data.meta?.is_server_owner ? ['*'] : (data.meta?.user_permissions || []),
rawDataToServerObject(data),
// eslint-disable-next-line camelcase
data.meta?.is_server_owner ? [ '*' ] : (data.meta?.user_permissions || []),
]))
.catch(reject);
});

View File

@ -18,7 +18,7 @@ export const rawDataToServerDatabase = (data: any): ServerDatabase => ({
password: data.relationships && data.relationships.password ? data.relationships.password.attributes.password : undefined,
});
export default (uuid: string, includePassword: boolean = true): Promise<ServerDatabase[]> => {
export default (uuid: string, includePassword = true): Promise<ServerDatabase[]> => {
return new Promise((resolve, reject) => {
http.get(`/api/client/servers/${uuid}/databases`, {
params: includePassword ? { include: 'password' } : undefined,

View File

@ -0,0 +1,4 @@
import { Allocation } from '@/api/server/getServer';
import http from '@/api/http';
export default async (uuid: string, id: number): Promise<Allocation> => await http.delete(`/api/client/servers/${uuid}/network/allocations/${id}`);

View File

@ -0,0 +1,9 @@
import http from '@/api/http';
import { rawDataToServerAllocation } from '@/api/transformers';
import { Allocation } from '@/api/server/getServer';
export default async (uuid: string): Promise<Allocation[]> => {
const { data } = await http.get(`/api/client/servers/${uuid}/network/allocations`);
return (data.data || []).map(rawDataToServerAllocation);
};

View File

@ -0,0 +1,9 @@
import { Allocation } from '@/api/server/getServer';
import http from '@/api/http';
import { rawDataToServerAllocation } from '@/api/transformers';
export default async (uuid: string, id: number): Promise<Allocation> => {
const { data } = await http.post(`/api/client/servers/${uuid}/network/allocations/${id}/primary`);
return rawDataToServerAllocation(data);
};

View File

@ -0,0 +1,9 @@
import { Allocation } from '@/api/server/getServer';
import http from '@/api/http';
import { rawDataToServerAllocation } from '@/api/transformers';
export default async (uuid: string, id: number, notes: string | null): Promise<Allocation> => {
const { data } = await http.post(`/api/client/servers/${uuid}/network/allocations/${id}`, { notes });
return rawDataToServerAllocation(data);
};

View File

@ -6,4 +6,4 @@ export default (uuid: string): Promise<void> => {
.then(() => resolve())
.catch(reject);
});
}
};

View File

@ -11,7 +11,6 @@ export default (uuid: string, schedule: number, task: number | undefined, { time
return new Promise((resolve, reject) => {
http.post(`/api/client/servers/${uuid}/schedules/${schedule}/tasks${task ? `/${task}` : ''}`, {
...data,
// eslint-disable-next-line @typescript-eslint/camelcase
time_offset: timeOffset,
})
.then(({ data }) => resolve(rawDataToServerTask(data.attributes)))

View File

@ -5,5 +5,5 @@ export default (uuid: string, scheduleId: number, taskId: number): Promise<void>
http.delete(`/api/client/servers/${uuid}/schedules/${scheduleId}/tasks/${taskId}`)
.then(() => resolve())
.catch(reject);
})
});
};

View File

@ -5,7 +5,7 @@ export default (uuid: string, schedule: number): Promise<Schedule> => {
return new Promise((resolve, reject) => {
http.get(`/api/client/servers/${uuid}/schedules/${schedule}`, {
params: {
include: ['tasks'],
include: [ 'tasks' ],
},
})
.then(({ data }) => resolve(rawDataToServerSchedule(data.attributes)))

View File

@ -64,7 +64,7 @@ export default (uuid: string): Promise<Schedule[]> => {
return new Promise((resolve, reject) => {
http.get(`/api/client/servers/${uuid}/schedules`, {
params: {
include: ['tasks'],
include: [ 'tasks' ],
},
})
.then(({ data }) => resolve((data.data || []).map((row: any) => rawDataToServerSchedule(row.attributes))))

View File

@ -15,4 +15,4 @@ export default (uuid: string, params: Params, subuser?: Subuser): Promise<Subuse
.then(data => resolve(rawDataToServerSubuser(data.data)))
.catch(reject);
});
}
};

View File

@ -0,0 +1,11 @@
import { Allocation } from '@/api/server/getServer';
import { FractalResponseData } from '@/api/http';
export const rawDataToServerAllocation = (data: FractalResponseData): Allocation => ({
id: data.attributes.id,
ip: data.attributes.ip,
alias: data.attributes.ip_alias,
port: data.attributes.port,
notes: data.attributes.notes,
isDefault: data.attributes.is_default,
});

View File

@ -0,0 +1,35 @@
import tw from 'twin.macro';
import { createGlobalStyle } from 'styled-components/macro';
export default createGlobalStyle`
body {
${tw`font-sans bg-neutral-800 text-neutral-200`};
letter-spacing: 0.015em;
}
h1, h2, h3, h4, h5, h6 {
${tw`font-medium tracking-normal font-header`};
}
p {
${tw`text-neutral-200 leading-snug font-sans`};
}
form {
${tw`m-0`};
}
textarea, select, input, button, button:focus, button:focus-visible {
${tw`outline-none`};
}
input[type=number]::-webkit-outer-spin-button,
input[type=number]::-webkit-inner-spin-button {
-webkit-appearance: none !important;
margin: 0;
}
input[type=number] {
-moz-appearance: textfield !important;
}
`;

View File

@ -8,9 +8,10 @@ import ServerRouter from '@/routers/ServerRouter';
import AuthenticationRouter from '@/routers/AuthenticationRouter';
import { Provider } from 'react-redux';
import { SiteSettings } from '@/state/settings';
import { DefaultTheme, ThemeProvider } from 'styled-components';
import ProgressBar from '@/components/elements/ProgressBar';
import NotFound from '@/components/screens/NotFound';
import tw from 'twin.macro';
import GlobalStylesheet from '@/assets/css/GlobalStylesheet';
interface ExtendedWindow extends Window {
SiteConfiguration?: SiteSettings;
@ -18,24 +19,16 @@ interface ExtendedWindow extends Window {
uuid: string;
username: string;
email: string;
/* eslint-disable camelcase */
root_admin: boolean;
use_totp: boolean;
language: string;
updated_at: string;
created_at: string;
/* eslint-enable camelcase */
};
}
const theme: DefaultTheme = {
breakpoints: {
xs: 0,
sm: 576,
md: 768,
lg: 992,
xl: 1200,
},
};
const App = () => {
const { PterodactylUser, SiteConfiguration } = (window as ExtendedWindow);
if (PterodactylUser && !store.getState().user.data) {
@ -56,11 +49,12 @@ const App = () => {
}
return (
<ThemeProvider theme={theme}>
<>
<GlobalStylesheet/>
<StoreProvider store={store}>
<Provider store={store}>
<ProgressBar/>
<div className={'mx-auto w-auto'}>
<div css={tw`mx-auto w-auto`}>
<BrowserRouter basename={'/'} key={'root-router'}>
<Switch>
<Route path="/server/:id" component={ServerRouter}/>
@ -72,7 +66,7 @@ const App = () => {
</div>
</Provider>
</StoreProvider>
</ThemeProvider>
</>
);
};

View File

@ -1,38 +1,35 @@
import React from 'react';
import MessageBox from '@/components/MessageBox';
import { State, useStoreState } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import { useStoreState } from 'easy-peasy';
import tw from 'twin.macro';
type Props = Readonly<{
byKey?: string;
spacerClass?: string;
className?: string;
}>;
export default ({ className, spacerClass, byKey }: Props) => {
const flashes = useStoreState((state: State<ApplicationStore>) => state.flashes.items);
let filtered = flashes;
if (byKey) {
filtered = flashes.filter(flash => flash.key === byKey);
}
if (filtered.length === 0) {
return null;
}
const FlashMessageRender = ({ byKey, className }: Props) => {
const flashes = useStoreState(state => state.flashes.items.filter(
flash => byKey ? flash.key === byKey : true,
));
return (
<div className={className}>
{
filtered.map((flash, index) => (
<React.Fragment key={flash.id || flash.type + flash.message}>
{index > 0 && <div className={spacerClass || 'mt-2'}></div>}
<MessageBox type={flash.type} title={flash.title}>
{flash.message}
</MessageBox>
</React.Fragment>
))
}
</div>
flashes.length ?
<div className={className}>
{
flashes.map((flash, index) => (
<React.Fragment key={flash.id || flash.type + flash.message}>
{index > 0 && <div css={tw`mt-2`}></div>}
<MessageBox type={flash.type} title={flash.title}>
{flash.message}
</MessageBox>
</React.Fragment>
))
}
</div>
:
null
);
};
export default FlashMessageRender;

View File

@ -1,4 +1,6 @@
import * as React from 'react';
import tw, { TwStyle } from 'twin.macro';
import styled from 'styled-components/macro';
export type FlashMessageType = 'success' | 'info' | 'warning' | 'error';
@ -8,11 +10,60 @@ interface Props {
type?: FlashMessageType;
}
export default ({ title, children, type }: Props) => (
<div className={`lg:inline-flex alert ${type}`} role={'alert'}>
{title && <span className={'title'}>{title}</span>}
<span className={'message'}>
const styling = (type?: FlashMessageType): TwStyle | string => {
switch (type) {
case 'error':
return tw`bg-red-600 border-red-800`;
case 'info':
return tw`bg-primary-600 border-primary-800`;
case 'success':
return tw`bg-green-600 border-green-800`;
case 'warning':
return tw`bg-yellow-600 border-yellow-800`;
default:
return '';
}
};
const getBackground = (type?: FlashMessageType): TwStyle | string => {
switch (type) {
case 'error':
return tw`bg-red-500`;
case 'info':
return tw`bg-primary-500`;
case 'success':
return tw`bg-green-500`;
case 'warning':
return tw`bg-yellow-500`;
default:
return '';
}
};
const Container = styled.div<{ $type?: FlashMessageType }>`
${tw`p-2 border items-center leading-normal rounded flex w-full text-sm text-white`};
${props => styling(props.$type)};
`;
Container.displayName = 'MessageBox.Container';
const MessageBox = ({ title, children, type }: Props) => (
<Container css={tw`lg:inline-flex`} $type={type} role={'alert'}>
{title &&
<span
className={'title'}
css={[
tw`flex rounded-full uppercase px-2 py-1 text-xs font-bold mr-3 leading-none`,
getBackground(type),
]}
>
{title}
</span>
}
<span css={tw`mr-2 text-left flex-auto`}>
{children}
</span>
</div>
</Container>
);
MessageBox.displayName = 'MessageBox';
export default MessageBox;

View File

@ -1,51 +1,77 @@
import * as React from 'react';
import { Link, NavLink } from 'react-router-dom';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faLayerGroup } from '@fortawesome/free-solid-svg-icons/faLayerGroup';
import { faUserCircle } from '@fortawesome/free-solid-svg-icons/faUserCircle';
import { faSignOutAlt } from '@fortawesome/free-solid-svg-icons/faSignOutAlt';
import { faSwatchbook } from '@fortawesome/free-solid-svg-icons/faSwatchbook';
import { faCogs } from '@fortawesome/free-solid-svg-icons/faCogs';
import { faCogs, faLayerGroup, faSignOutAlt, faUserCircle } from '@fortawesome/free-solid-svg-icons';
import { useStoreState } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import { faSearch } from '@fortawesome/free-solid-svg-icons/faSearch';
import SearchContainer from '@/components/dashboard/search/SearchContainer';
import tw from 'twin.macro';
import styled from 'styled-components/macro';
// @ts-ignore
import * as config from '@/../../tailwind.config.js';
const Navigation = styled.div`
${tw`w-full bg-neutral-900 shadow-md`};
& > div {
${tw`mx-auto w-full flex items-center`};
}
& #logo {
${tw`flex-1`};
& > a {
${tw`text-2xl font-header px-4 no-underline text-neutral-200 hover:text-neutral-100 transition-colors duration-150`};
}
}
`;
const RightNavigation = styled.div`
${tw`flex h-full items-center justify-center`};
& > a, & > .navigation-link {
${tw`flex items-center h-full no-underline text-neutral-300 px-6 cursor-pointer transition-all duration-150`};
&:active, &:hover {
${tw`text-neutral-100 bg-black`};
}
&:active, &:hover, &.active {
box-shadow: inset 0 -2px ${config.theme.colors.cyan['700']};
}
}
`;
export default () => {
const user = useStoreState((state: ApplicationStore) => state.user.data!);
const name = useStoreState((state: ApplicationStore) => state.settings.data!.name);
return (
<div id={'navigation'}>
<div className={'mx-auto w-full flex items-center'} style={{ maxWidth: '1200px', height: '3.5rem' }}>
<Navigation>
<div css={tw`mx-auto w-full flex items-center`} style={{ maxWidth: '1200px', height: '3.5rem' }}>
<div id={'logo'}>
<Link to={'/'}>
{name}
</Link>
</div>
<div className={'right-navigation'}>
<RightNavigation>
<SearchContainer/>
<NavLink to={'/'} exact={true}>
<NavLink to={'/'} exact>
<FontAwesomeIcon icon={faLayerGroup}/>
</NavLink>
<NavLink to={'/account'}>
<FontAwesomeIcon icon={faUserCircle}/>
</NavLink>
{user.rootAdmin &&
<a href={'/admin'} target={'_blank'}>
<a href={'/admin'} target={'_blank'} rel={'noreferrer'}>
<FontAwesomeIcon icon={faCogs}/>
</a>
}
{process.env.NODE_ENV !== 'production' &&
<NavLink to={'/design'}>
<FontAwesomeIcon icon={faSwatchbook}/>
</NavLink>
}
<a href={'/auth/logout'}>
<FontAwesomeIcon icon={faSignOutAlt}/>
</a>
</div>
</RightNavigation>
</div>
</div>
</Navigation>
);
};

View File

@ -1,13 +0,0 @@
import * as React from 'react';
import MessageBox from '@/components/MessageBox';
export default ({ message }: { message: string | undefined | null }) => (
!message ?
null
:
<div className={'mb-4'}>
<MessageBox type={'error'} title={'Error'}>
{message}
</MessageBox>
</div>
);

View File

@ -1,13 +0,0 @@
import * as React from 'react';
import { NavLink } from 'react-router-dom';
export default class ServerOverviewContainer extends React.PureComponent {
render () {
return (
<div className={'mt-10'}>
<NavLink className={'text-neutral-100 text-sm block mb-2 no-underline hover:underline'} to={'/account'}>Account</NavLink>
<NavLink className={'text-neutral-100 text-sm block mb-2 no-underline hover:underline'} to={'/account/design'}>Design</NavLink>
</div>
);
}
}

View File

@ -8,6 +8,8 @@ import { ApplicationStore } from '@/state';
import Field from '@/components/elements/Field';
import { Formik, FormikHelpers } from 'formik';
import { object, string } from 'yup';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
interface Values {
email: string;
@ -43,33 +45,30 @@ export default () => {
{({ isSubmitting }) => (
<LoginFormContainer
title={'Request Password Reset'}
className={'w-full flex'}
css={tw`w-full flex`}
>
<Field
light={true}
light
label={'Email'}
description={'Enter your account email address to receive instructions on resetting your password.'}
name={'email'}
type={'email'}
/>
<div className={'mt-6'}>
<button
<div css={tw`mt-6`}>
<Button
type={'submit'}
className={'btn btn-primary btn-jumbo flex justify-center'}
size={'xlarge'}
disabled={isSubmitting}
isLoading={isSubmitting}
>
{isSubmitting ?
<div className={'spinner-circle spinner-sm spinner-white'}></div>
:
'Send Email'
}
</button>
Send Email
</Button>
</div>
<div className={'mt-6 text-center'}>
<div css={tw`mt-6 text-center`}>
<Link
type={'button'}
to={'/auth/login'}
className={'text-xs text-neutral-500 tracking-wide uppercase no-underline hover:text-neutral-700'}
css={tw`text-xs text-neutral-500 tracking-wide uppercase no-underline hover:text-neutral-700`}
>
Return to Login
</Link>

View File

@ -5,19 +5,19 @@ import { httpErrorToHuman } from '@/api/http';
import LoginFormContainer from '@/components/auth/LoginFormContainer';
import { ActionCreator } from 'easy-peasy';
import { StaticContext } from 'react-router';
import Spinner from '@/components/elements/Spinner';
import { useFormikContext, withFormik } from 'formik';
import { object, string } from 'yup';
import useFlash from '@/plugins/useFlash';
import { FlashStore } from '@/state/flashes';
import Field from '@/components/elements/Field';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
interface Values {
code: string;
recoveryCode: '',
}
type OwnProps = RouteComponentProps<{}, StaticContext, { token?: string }>
type OwnProps = RouteComponentProps<Record<string, string | undefined>, StaticContext, { token?: string }>
type Props = OwnProps & {
addError: ActionCreator<FlashStore['addError']['payload']>;
@ -29,13 +29,10 @@ const LoginCheckpointContainer = () => {
const [ isMissingDevice, setIsMissingDevice ] = useState(false);
return (
<LoginFormContainer
title={'Device Checkpoint'}
className={'w-full flex'}
>
<div className={'mt-6'}>
<LoginFormContainer title={'Device Checkpoint'} css={tw`w-full flex`}>
<div css={tw`mt-6`}>
<Field
light={true}
light
name={isMissingDevice ? 'recoveryCode' : 'code'}
title={isMissingDevice ? 'Recovery Code' : 'Authentication Code'}
description={
@ -44,38 +41,35 @@ const LoginCheckpointContainer = () => {
: 'Enter the two-factor token generated by your device.'
}
type={isMissingDevice ? 'text' : 'number'}
autoFocus={true}
autoFocus
/>
</div>
<div className={'mt-6'}>
<button
<div css={tw`mt-6`}>
<Button
size={'xlarge'}
type={'submit'}
className={'btn btn-primary btn-jumbo'}
disabled={isSubmitting}
isLoading={isSubmitting}
>
{isSubmitting ?
<Spinner size={'tiny'} className={'mx-auto'}/>
:
'Continue'
}
</button>
Continue
</Button>
</div>
<div className={'mt-6 text-center'}>
<div css={tw`mt-6 text-center`}>
<span
onClick={() => {
setFieldValue('code', '');
setFieldValue('recoveryCode', '');
setIsMissingDevice(s => !s);
}}
className={'cursor-pointer text-xs text-neutral-500 tracking-wide uppercase no-underline hover:text-neutral-700'}
css={tw`cursor-pointer text-xs text-neutral-500 tracking-wide uppercase no-underline hover:text-neutral-700`}
>
{!isMissingDevice ? 'I\'ve Lost My Device' : 'I Have My Device'}
</span>
</div>
<div className={'mt-6 text-center'}>
<div css={tw`mt-6 text-center`}>
<Link
to={'/auth/login'}
className={'text-xs text-neutral-500 tracking-wide uppercase no-underline hover:text-neutral-700'}
css={tw`text-xs text-neutral-500 tracking-wide uppercase no-underline hover:text-neutral-700`}
>
Return to Login
</Link>

View File

@ -10,7 +10,8 @@ import Field from '@/components/elements/Field';
import { httpErrorToHuman } from '@/api/http';
import { FlashMessage } from '@/state/flashes';
import ReCAPTCHA from 'react-google-recaptcha';
import Spinner from '@/components/elements/Spinner';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
type OwnProps = RouteComponentProps & {
clearFlashes: ActionCreator<void>;
@ -34,38 +35,27 @@ const LoginContainer = ({ isSubmitting, setFieldValue, values, submitForm, handl
return (
<React.Fragment>
{ref.current && ref.current.render()}
<LoginFormContainer
title={'Login to Continue'}
className={'w-full flex'}
onSubmit={submit}
>
<label htmlFor={'username'}>Username or Email</label>
<LoginFormContainer title={'Login to Continue'} css={tw`w-full flex`} onSubmit={submit}>
<Field
type={'text'}
label={'Username or Email'}
id={'username'}
name={'username'}
className={'input'}
light
/>
<div className={'mt-6'}>
<label htmlFor={'password'}>Password</label>
<div css={tw`mt-6`}>
<Field
type={'password'}
label={'Password'}
id={'password'}
name={'password'}
className={'input'}
light
/>
</div>
<div className={'mt-6'}>
<button
type={'submit'}
className={'btn btn-primary btn-jumbo'}
>
{isSubmitting ?
<Spinner size={'tiny'} className={'mx-auto'}/>
:
'Login'
}
</button>
<div css={tw`mt-6`}>
<Button type={'submit'} size={'xlarge'} isLoading={isSubmitting}>
Login
</Button>
</div>
{recaptchaEnabled &&
<ReCAPTCHA
@ -80,10 +70,10 @@ const LoginContainer = ({ isSubmitting, setFieldValue, values, submitForm, handl
onExpired={() => setFieldValue('recaptchaData', null)}
/>
}
<div className={'mt-6 text-center'}>
<div css={tw`mt-6 text-center`}>
<Link
to={'/auth/password'}
className={'text-xs text-neutral-500 tracking-wide no-underline uppercase hover:text-neutral-600'}
css={tw`text-xs text-neutral-500 tracking-wide no-underline uppercase hover:text-neutral-600`}
>
Forgot password?
</Link>
@ -96,7 +86,7 @@ const LoginContainer = ({ isSubmitting, setFieldValue, values, submitForm, handl
const EnhancedForm = withFormik<OwnProps, LoginData>({
displayName: 'LoginContainerForm',
mapPropsToValues: (props) => ({
mapPropsToValues: () => ({
username: '',
password: '',
recaptchaData: null,

View File

@ -1,8 +1,9 @@
import React, { forwardRef } from 'react';
import { Form } from 'formik';
import styled from 'styled-components';
import { breakpoint } from 'styled-components-breakpoint';
import styled from 'styled-components/macro';
import { breakpoint } from '@/theme';
import FlashMessageRender from '@/components/FlashMessageRender';
import tw from 'twin.macro';
type Props = React.DetailedHTMLProps<React.FormHTMLAttributes<HTMLFormElement>, HTMLFormElement> & {
title?: string;
@ -29,27 +30,29 @@ const Container = styled.div`
export default forwardRef<HTMLFormElement, Props>(({ title, ...props }, ref) => (
<Container>
{title && <h2 className={'text-center text-neutral-100 font-medium py-4'}>
{title &&
<h2 css={tw`text-3xl text-center text-neutral-100 font-medium py-4`}>
{title}
</h2>}
<FlashMessageRender className={'mb-2 px-1'}/>
</h2>
}
<FlashMessageRender css={tw`mb-2 px-1`}/>
<Form {...props} ref={ref}>
<div className={'md:flex w-full bg-white shadow-lg rounded-lg p-6 md:pl-0 mx-1'}>
<div className={'flex-none select-none mb-6 md:mb-0 self-center'}>
<img src={'/assets/svgs/pterodactyl.svg'} className={'block w-48 md:w-64 mx-auto'}/>
<div css={tw`md:flex w-full bg-white shadow-lg rounded-lg p-6 md:pl-0 mx-1`}>
<div css={tw`flex-none select-none mb-6 md:mb-0 self-center`}>
<img src={'/assets/svgs/pterodactyl.svg'} css={tw`block w-48 md:w-64 mx-auto`}/>
</div>
<div className={'flex-1'}>
<div css={tw`flex-1`}>
{props.children}
</div>
</div>
</Form>
<p className={'text-center text-neutral-500 text-xs mt-4'}>
<p css={tw`text-center text-neutral-500 text-xs mt-4`}>
&copy; 2015 - 2020&nbsp;
<a
rel={'noopener nofollow'}
rel={'noopener nofollow noreferrer'}
href={'https://pterodactyl.io'}
target={'_blank'}
className={'no-underline text-neutral-500 hover:text-neutral-300'}
css={tw`no-underline text-neutral-500 hover:text-neutral-300`}
>
Pterodactyl Software
</a>

View File

@ -7,19 +7,19 @@ import { httpErrorToHuman } from '@/api/http';
import LoginFormContainer from '@/components/auth/LoginFormContainer';
import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import Spinner from '@/components/elements/Spinner';
import { Formik, FormikHelpers } from 'formik';
import { object, ref, string } from 'yup';
import Field from '@/components/elements/Field';
type Props = Readonly<RouteComponentProps<{ token: string }> & {}>;
import Input from '@/components/elements/Input';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
interface Values {
password: string;
passwordConfirmation: string;
}
export default ({ match, history, location }: Props) => {
export default ({ match, location }: RouteComponentProps<{ token: string }>) => {
const [ email, setEmail ] = useState('');
const { clearFlashes, addFlash } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
@ -56,52 +56,50 @@ export default ({ match, history, location }: Props) => {
.min(8, 'Your new password should be at least 8 characters in length.'),
passwordConfirmation: string()
.required('Your new password does not match.')
.oneOf([ref('password'), null], 'Your new password does not match.'),
// @ts-ignore
.oneOf([ ref('password'), null ], 'Your new password does not match.'),
})}
>
{({ isSubmitting }) => (
<LoginFormContainer
title={'Reset Password'}
className={'w-full flex'}
css={tw`w-full flex`}
>
<div>
<label>Email</label>
<input className={'input'} value={email} disabled={true}/>
<Input value={email} isLight disabled/>
</div>
<div className={'mt-6'}>
<div css={tw`mt-6`}>
<Field
light={true}
light
label={'New Password'}
name={'password'}
type={'password'}
description={'Passwords must be at least 8 characters in length.'}
/>
</div>
<div className={'mt-6'}>
<div css={tw`mt-6`}>
<Field
light={true}
light
label={'Confirm New Password'}
name={'passwordConfirmation'}
type={'password'}
/>
</div>
<div className={'mt-6'}>
<button
<div css={tw`mt-6`}>
<Button
size={'xlarge'}
type={'submit'}
className={'btn btn-primary btn-jumbo'}
disabled={isSubmitting}
isLoading={isSubmitting}
>
{isSubmitting ?
<Spinner size={'tiny'} className={'mx-auto'}/>
:
'Reset Password'
}
</button>
Reset Password
</Button>
</div>
<div className={'mt-6 text-center'}>
<div css={tw`mt-6 text-center`}>
<Link
to={'/auth/login'}
className={'text-xs text-neutral-500 tracking-wide no-underline uppercase hover:text-neutral-600'}
css={tw`text-xs text-neutral-500 tracking-wide no-underline uppercase hover:text-neutral-600`}
>
Return to Login
</Link>

View File

@ -4,16 +4,17 @@ import CreateApiKeyForm from '@/components/dashboard/forms/CreateApiKeyForm';
import getApiKeys, { ApiKey } from '@/api/account/getApiKeys';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faKey } from '@fortawesome/free-solid-svg-icons/faKey';
import { faTrashAlt } from '@fortawesome/free-solid-svg-icons/faTrashAlt';
import { faKey, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
import ConfirmationModal from '@/components/elements/ConfirmationModal';
import deleteApiKey from '@/api/account/deleteApiKey';
import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import FlashMessageRender from '@/components/FlashMessageRender';
import { httpErrorToHuman } from '@/api/http';
import format from 'date-fns/format';
import { format } from 'date-fns';
import PageContentBlock from '@/components/elements/PageContentBlock';
import tw from 'twin.macro';
import GreyRowBox from '@/components/elements/GreyRowBox';
export default () => {
const [ deleteIdentifier, setDeleteIdentifier ] = useState('');
@ -48,18 +49,18 @@ export default () => {
return (
<PageContentBlock>
<FlashMessageRender byKey={'account'} className={'mb-4'}/>
<div className={'flex'}>
<ContentBox title={'Create API Key'} className={'flex-1'}>
<FlashMessageRender byKey={'account'} css={tw`mb-4`}/>
<div css={tw`flex`}>
<ContentBox title={'Create API Key'} css={tw`flex-1`}>
<CreateApiKeyForm onKeyCreated={key => setKeys(s => ([ ...s!, key ]))}/>
</ContentBox>
<ContentBox title={'API Keys'} className={'ml-10 flex-1'}>
<ContentBox title={'API Keys'} css={tw`ml-10 flex-1`}>
<SpinnerOverlay visible={loading}/>
{deleteIdentifier &&
<ConfirmationModal
visible
title={'Confirm key deletion'}
buttonText={'Yes, delete key'}
visible={true}
onConfirmed={() => {
doDeletion(deleteIdentifier);
setDeleteIdentifier('');
@ -72,38 +73,38 @@ export default () => {
}
{
keys.length === 0 ?
<p className={'text-center text-sm'}>
<p css={tw`text-center text-sm`}>
{loading ? 'Loading...' : 'No API keys exist for this account.'}
</p>
:
keys.map(key => (
<div
keys.map((key, index) => (
<GreyRowBox
key={key.identifier}
className={'grey-row-box bg-neutral-600 mb-2 flex items-center'}
css={[ tw`bg-neutral-600 flex items-center`, index > 0 && tw`mt-2` ]}
>
<FontAwesomeIcon icon={faKey} className={'text-neutral-300'}/>
<div className={'ml-4 flex-1'}>
<p className={'text-sm'}>{key.description}</p>
<p className={'text-2xs text-neutral-300 uppercase'}>
Last
used: {key.lastUsedAt ? format(key.lastUsedAt, 'MMM Do, YYYY HH:mm') : 'Never'}
<FontAwesomeIcon icon={faKey} css={tw`text-neutral-300`}/>
<div css={tw`ml-4 flex-1`}>
<p css={tw`text-sm`}>{key.description}</p>
<p css={tw`text-2xs text-neutral-300 uppercase`}>
Last used:&nbsp;
{key.lastUsedAt ? format(key.lastUsedAt, 'MMM do, yyyy HH:mm') : 'Never'}
</p>
</div>
<p className={'text-sm ml-4'}>
<code className={'font-mono py-1 px-2 bg-neutral-900 rounded'}>
<p css={tw`text-sm ml-4`}>
<code css={tw`font-mono py-1 px-2 bg-neutral-900 rounded`}>
{key.identifier}
</code>
</p>
<button
className={'ml-4 p-2 text-sm'}
css={tw`ml-4 p-2 text-sm`}
onClick={() => setDeleteIdentifier(key.identifier)}
>
<FontAwesomeIcon
icon={faTrashAlt}
className={'text-neutral-400 hover:text-red-400 transition-colors duration-150'}
css={tw`text-neutral-400 hover:text-red-400 transition-colors duration-150`}
/>
</button>
</div>
</GreyRowBox>
))
}
</ContentBox>

View File

@ -3,9 +3,10 @@ import ContentBox from '@/components/elements/ContentBox';
import UpdatePasswordForm from '@/components/dashboard/forms/UpdatePasswordForm';
import UpdateEmailAddressForm from '@/components/dashboard/forms/UpdateEmailAddressForm';
import ConfigureTwoFactorForm from '@/components/dashboard/forms/ConfigureTwoFactorForm';
import styled from 'styled-components';
import { breakpoint } from 'styled-components-breakpoint';
import PageContentBlock from '@/components/elements/PageContentBlock';
import tw from 'twin.macro';
import { breakpoint } from '@/theme';
import styled from 'styled-components/macro';
const Container = styled.div`
${tw`flex flex-wrap my-10`};
@ -31,13 +32,13 @@ export default () => {
<UpdatePasswordForm/>
</ContentBox>
<ContentBox
className={'mt-8 md:mt-0 md:ml-8'}
css={tw`mt-8 md:mt-0 md:ml-8`}
title={'Update Email Address'}
showFlashes={'account:email'}
>
<UpdateEmailAddressForm/>
</ContentBox>
<ContentBox className={'xl:ml-8 mt-8 xl:mt-0'} title={'Configure Two Factor'}>
<ContentBox css={tw`xl:ml-8 mt-8 xl:mt-0`} title={'Configure Two Factor'}>
<ConfigureTwoFactorForm/>
</ContentBox>
</Container>

View File

@ -10,6 +10,7 @@ import FlashMessageRender from '@/components/FlashMessageRender';
import { useStoreState } from 'easy-peasy';
import { usePersistedState } from '@/plugins/usePersistedState';
import Switch from '@/components/elements/Switch';
import tw from 'twin.macro';
export default () => {
const { addError, clearFlashes } = useFlash();
@ -37,10 +38,10 @@ export default () => {
return (
<PageContentBlock>
<FlashMessageRender className={'mb-4'}/>
<FlashMessageRender css={tw`mb-4`}/>
{rootAdmin &&
<div className={'mb-2 flex justify-end items-center'}>
<p className={'uppercase text-xs text-neutral-400 mr-2'}>
<div css={tw`mb-2 flex justify-end items-center`}>
<p css={tw`uppercase text-xs text-neutral-400 mr-2`}>
{showAdmin ? 'Showing all servers' : 'Showing your servers'}
</p>
<Switch
@ -51,14 +52,16 @@ export default () => {
</div>
}
{loading ?
<Spinner centered={true} size={'large'}/>
<Spinner centered size={'large'}/>
:
servers.length > 0 ?
servers.map(server => (
<ServerRow key={server.uuid} server={server} className={'mt-2'}/>
servers.map((server, index) => (
<div key={server.uuid} css={index > 0 ? tw`mt-2` : undefined}>
<ServerRow server={server}/>
</div>
))
:
<p className={'text-center text-sm text-neutral-400'}>
<p css={tw`text-center text-sm text-neutral-400`}>
There are no servers associated with your account.
</p>
}

View File

@ -1,82 +0,0 @@
import * as React from 'react';
import { Link } from 'react-router-dom';
import ContentBox from '@/components/elements/ContentBox';
export default class DesignElementsContainer extends React.PureComponent {
render () {
return (
<React.Fragment>
<div className={'my-10'}>
<div className={'flex'}>
<ContentBox
className={'flex-1 mr-4'}
title={'A Special Announcement'}
borderColor={'border-primary-400'}
>
<p className={'text-neutral-200 text-sm'}>
Your demands have been received: Dark Mode will be default in Pterodactyl 0.8!
</p>
<p><Link to={'/'}>Back</Link></p>
</ContentBox>
<div className={'ml-4 flex-1'}>
<h2 className={'text-neutral-300 mb-2 px-4'}>Form Elements</h2>
<div className={'bg-neutral-700 p-4 rounded shadow-lg border-t-4 border-primary-400'}>
<label className={'uppercase text-neutral-200'}>Email</label>
<input type={'text'} className={'input-dark'}/>
<p className={'input-help'}>
This is some descriptive helper text to explain how things work.
</p>
<div className={'mt-6'}/>
<label className={'uppercase text-neutral-200'}>Username</label>
<input type={'text'} className={'input-dark error'}/>
<p className={'input-help'}>
This field has an error.
</p>
<div className={'mt-6'}/>
<label className={'uppercase text-neutral-200'}>Disabled Field</label>
<input type={'text'} className={'input-dark'} disabled={true}/>
<div className={'mt-6'}/>
<label className={'uppercase text-neutral-200'}>Select</label>
<select className={'input-dark'}>
<option>Option 1</option>
<option>Option 2</option>
<option>Option 3</option>
</select>
<div className={'mt-6'}/>
<label className={'uppercase text-neutral-200'}>Textarea</label>
<textarea className={'input-dark h-32'}></textarea>
<div className={'mt-6'}/>
<button className={'btn btn-primary btn-sm'}>
Blue
</button>
<button className={'btn btn-grey btn-sm ml-2'}>
Grey
</button>
<button className={'btn btn-green btn-sm ml-2'}>
Green
</button>
<button className={'btn btn-red btn-sm ml-2'}>
Red
</button>
<div className={'mt-6'}/>
<button className={'btn btn-secondary btn-sm'}>
Secondary
</button>
<button className={'btn btn-secondary btn-red btn-sm ml-2'}>
Secondary Danger
</button>
<div className={'mt-6'}/>
<button className={'btn btn-primary btn-lg'}>
Large
</button>
<button className={'btn btn-primary btn-xs ml-2'}>
Tiny
</button>
</div>
</div>
</div>
</div>
</React.Fragment>
);
}
}

View File

@ -1,16 +1,13 @@
import React, { useEffect, useRef, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faServer } from '@fortawesome/free-solid-svg-icons/faServer';
import { faEthernet } from '@fortawesome/free-solid-svg-icons/faEthernet';
import { faMicrochip } from '@fortawesome/free-solid-svg-icons/faMicrochip';
import { faMemory } from '@fortawesome/free-solid-svg-icons/faMemory';
import { faHdd } from '@fortawesome/free-solid-svg-icons/faHdd';
import { faServer, faEthernet, faMicrochip, faMemory, faHdd } from '@fortawesome/free-solid-svg-icons';
import { Link } from 'react-router-dom';
import { Server } from '@/api/server/getServer';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import getServerResourceUsage, { ServerStats } from '@/api/server/getServerResourceUsage';
import { bytesToHuman } from '@/helpers';
import classNames from 'classnames';
import tw from 'twin.macro';
import GreyRowBox from '@/components/elements/GreyRowBox';
// Determines if the current value is in an alarm threshold so we can show it in red rather
// than the more faded default style.
@ -20,7 +17,7 @@ const isAlarmState = (current: number, limit: number): boolean => {
return current / limitInBytes >= 0.90;
};
export default ({ server, className }: { server: Server; className: string | undefined }) => {
export default ({ server }: { server: Server }) => {
const interval = useRef<number>(null);
const [ stats, setStats ] = useState<ServerStats | null>(null);
const [ statsError, setStatsError ] = useState(false);
@ -52,108 +49,111 @@ export default ({ server, className }: { server: Server; className: string | und
alarms.memory = isAlarmState(stats.memoryUsageInBytes, server.limits.memory);
alarms.disk = server.limits.disk === 0 ? false : isAlarmState(stats.diskUsageInBytes, server.limits.disk);
}
const disklimit = server.limits.disk != 0 ? bytesToHuman(server.limits.disk * 1000 * 1000) : "Unlimited";
const memorylimit = server.limits.memory != 0 ? bytesToHuman(server.limits.memory * 1000 * 1000) : "Unlimited";
const disklimit = server.limits.disk !== 0 ? bytesToHuman(server.limits.disk * 1000 * 1000) : 'Unlimited';
const memorylimit = server.limits.memory !== 0 ? bytesToHuman(server.limits.memory * 1000 * 1000) : 'Unlimited';
return (
<Link to={`/server/${server.id}`} className={`grey-row-box cursor-pointer ${className}`}>
<GreyRowBox as={Link} to={`/server/${server.id}`}>
<div className={'icon'}>
<FontAwesomeIcon icon={faServer}/>
</div>
<div className={'flex-1 ml-4'}>
<p className={'text-lg'}>{server.name}</p>
<div css={tw`flex-1 ml-4`}>
<p css={tw`text-lg`}>{server.name}</p>
</div>
<div className={'w-1/4 overflow-hidden'}>
<div className={'flex ml-4'}>
<FontAwesomeIcon icon={faEthernet} className={'text-neutral-500'}/>
<p className={'text-sm text-neutral-400 ml-2'}>
<div css={tw`w-1/4 overflow-hidden`}>
<div css={tw`flex ml-4`}>
<FontAwesomeIcon icon={faEthernet} css={tw`text-neutral-500`}/>
<p css={tw`text-sm text-neutral-400 ml-2`}>
{
server.allocations.filter(alloc => alloc.default).map(allocation => (
server.allocations.filter(alloc => alloc.isDefault).map(allocation => (
<span key={allocation.ip + allocation.port.toString()}>{allocation.alias || allocation.ip}:{allocation.port}</span>
))
}
</p>
</div>
</div>
<div className={'w-1/3 flex items-baseline relative'}>
<div css={tw`w-1/3 flex items-baseline relative`}>
{!stats ?
!statsError ?
<SpinnerOverlay size={'tiny'} visible={true} backgroundOpacity={0.25}/>
<SpinnerOverlay size={'small'} visible backgroundOpacity={0.25}/>
:
server.isInstalling ?
<div className={'flex-1 text-center'}>
<span className={'bg-neutral-500 rounded px-2 py-1 text-neutral-100 text-xs'}>
<div css={tw`flex-1 text-center`}>
<span css={tw`bg-neutral-500 rounded px-2 py-1 text-neutral-100 text-xs`}>
Installing
</span>
</div>
:
<div className={'flex-1 text-center'}>
<span className={'bg-red-500 rounded px-2 py-1 text-red-100 text-xs'}>
<div css={tw`flex-1 text-center`}>
<span css={tw`bg-red-500 rounded px-2 py-1 text-red-100 text-xs`}>
{server.isSuspended ? 'Suspended' : 'Connection Error'}
</span>
</div>
:
<React.Fragment>
<div className={'flex-1 flex ml-4 justify-center'}>
<div css={tw`flex-1 flex ml-4 justify-center`}>
<FontAwesomeIcon
icon={faMicrochip}
className={classNames({
'text-neutral-500': !alarms.cpu,
'text-red-400': alarms.cpu,
})}
css={[
!alarms.cpu && tw`text-neutral-500`,
alarms.cpu && tw`text-red-400`,
]}
/>
<p
className={classNames('text-sm ml-2', {
'text-neutral-400': !alarms.cpu,
'text-white': alarms.cpu,
})}
css={[
tw`text-sm ml-2`,
!alarms.cpu && tw`text-neutral-400`,
alarms.cpu && tw`text-white`,
]}
>
{stats.cpuUsagePercent} %
</p>
</div>
<div className={'flex-1 ml-4'}>
<div className={'flex justify-center'}>
<div css={tw`flex-1 ml-4`}>
<div css={tw`flex justify-center`}>
<FontAwesomeIcon
icon={faMemory}
className={classNames({
'text-neutral-500': !alarms.memory,
'text-red-400': alarms.memory,
})}
css={[
!alarms.memory && tw`text-neutral-500`,
alarms.memory && tw`text-red-400`,
]}
/>
<p
className={classNames('text-sm ml-2', {
'text-neutral-400': !alarms.memory,
'text-white': alarms.memory,
})}
css={[
tw`text-sm ml-2`,
!alarms.memory && tw`text-neutral-400`,
alarms.memory && tw`text-white`,
]}
>
{bytesToHuman(stats.memoryUsageInBytes)}
</p>
</div>
<p className={'text-xs text-neutral-600 text-center mt-1'}>of {memorylimit}</p>
<p css={tw`text-xs text-neutral-600 text-center mt-1`}>of {memorylimit}</p>
</div>
<div className={'flex-1 ml-4'}>
<div className={'flex justify-center'}>
<div css={tw`flex-1 ml-4`}>
<div css={tw`flex justify-center`}>
<FontAwesomeIcon
icon={faHdd}
className={classNames({
'text-neutral-500': !alarms.disk,
'text-red-400': alarms.disk,
})}
css={[
!alarms.disk && tw`text-neutral-500`,
alarms.disk && tw`text-red-400`,
]}
/>
<p
className={classNames('text-sm ml-2', {
'text-neutral-400': !alarms.disk,
'text-white': alarms.disk,
})}
css={[
tw`text-sm ml-2`,
!alarms.disk && tw`text-neutral-400`,
alarms.disk && tw`text-white`,
]}
>
{bytesToHuman(stats.diskUsageInBytes)}
</p>
</div>
<p className={'text-xs text-neutral-600 text-center mt-1'}>of {disklimit}</p>
<p css={tw`text-xs text-neutral-600 text-center mt-1`}>of {disklimit}</p>
</div>
</React.Fragment>
}
</div>
</Link>
</GreyRowBox>
);
};

View File

@ -3,6 +3,8 @@ import { useStoreState } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import SetupTwoFactorModal from '@/components/dashboard/forms/SetupTwoFactorModal';
import DisableTwoFactorModal from '@/components/dashboard/forms/DisableTwoFactorModal';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
export default () => {
const user = useStoreState((state: ApplicationStore) => state.user.data!);
@ -12,43 +14,45 @@ export default () => {
<div>
{visible &&
<DisableTwoFactorModal
appear={true}
appear
visible={visible}
onDismissed={() => setVisible(false)}
/>
}
<p className={'text-sm'}>
<p css={tw`text-sm`}>
Two-factor authentication is currently enabled on your account.
</p>
<div className={'mt-6'}>
<button
<div css={tw`mt-6`}>
<Button
color={'red'}
isSecondary
onClick={() => setVisible(true)}
className={'btn btn-red btn-secondary btn-sm'}
>
Disable
</button>
</Button>
</div>
</div>
:
<div>
{visible &&
<SetupTwoFactorModal
appear={true}
appear
visible={visible}
onDismissed={() => setVisible(false)}
/>
}
<p className={'text-sm'}>
<p css={tw`text-sm`}>
You do not currently have two-factor authentication enabled on your account. Click
the button below to begin configuring it.
</p>
<div className={'mt-6'}>
<button
<div css={tw`mt-6`}>
<Button
color={'green'}
isSecondary
onClick={() => setVisible(true)}
className={'btn btn-green btn-secondary btn-sm'}
>
Begin Setup
</button>
</Button>
</div>
</div>
;

View File

@ -9,6 +9,9 @@ import { ApplicationStore } from '@/state';
import { httpErrorToHuman } from '@/api/http';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import { ApiKey } from '@/api/account/getApiKeys';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import Input, { Textarea } from '@/components/elements/Input';
interface Values {
description: string;
@ -44,22 +47,21 @@ export default ({ onKeyCreated }: { onKeyCreated: (key: ApiKey) => void }) => {
closeOnEscape={false}
closeOnBackground={false}
>
<h3 className={'mb-6'}>Your API Key</h3>
<p className={'text-sm mb-6'}>
<h3 css={tw`mb-6`}>Your API Key</h3>
<p css={tw`text-sm mb-6`}>
The API key you have requested is shown below. Please store this in a safe location, it will not be
shown again.
</p>
<pre className={'text-sm bg-neutral-900 rounded py-2 px-4 font-mono'}>
<code className={'font-mono'}>{apiKey}</code>
<pre css={tw`text-sm bg-neutral-900 rounded py-2 px-4 font-mono`}>
<code css={tw`font-mono`}>{apiKey}</code>
</pre>
<div className={'flex justify-end mt-6'}>
<button
<div css={tw`flex justify-end mt-6`}>
<Button
type={'button'}
className={'btn btn-secondary btn-sm'}
onClick={() => setApiKey('')}
>
Close
</button>
</Button>
</div>
</Modal>
<Formik
@ -80,25 +82,19 @@ export default ({ onKeyCreated }: { onKeyCreated: (key: ApiKey) => void }) => {
label={'Description'}
name={'description'}
description={'A description of this API key.'}
className={'mb-6'}
css={tw`mb-6`}
>
<Field name={'description'} className={'input-dark'}/>
<Field name={'description'} as={Input}/>
</FormikFieldWrapper>
<FormikFieldWrapper
label={'Allowed IPs'}
name={'allowedIps'}
description={'Leave blank to allow any IP address to use this API key, otherwise provide each IP address on a new line.'}
>
<Field
as={'textarea'}
name={'allowedIps'}
className={'input-dark h-32'}
/>
<Field as={Textarea} name={'allowedIps'} css={tw`h-32`}/>
</FormikFieldWrapper>
<div className={'flex justify-end mt-6'}>
<button className={'btn btn-primary btn-sm'}>
Create
</button>
<div css={tw`flex justify-end mt-6`}>
<Button>Create</Button>
</div>
</Form>
)}

View File

@ -8,6 +8,8 @@ import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import disableAccountTwoFactor from '@/api/account/disableAccountTwoFactor';
import { httpErrorToHuman } from '@/api/http';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
interface Values {
password: string;
@ -45,19 +47,19 @@ export default ({ ...props }: RequiredModalProps) => {
{({ isSubmitting, isValid }) => (
<Modal {...props} dismissable={!isSubmitting} showSpinnerOverlay={isSubmitting}>
<Form className={'mb-0'}>
<FlashMessageRender className={'mb-6'} byKey={'account:two-factor'}/>
<FlashMessageRender css={tw`mb-6`} byKey={'account:two-factor'}/>
<Field
id={'password'}
name={'password'}
type={'password'}
label={'Current Password'}
description={'In order to disable two-factor authentication you will need to provide your account password.'}
autoFocus={true}
autoFocus
/>
<div className={'mt-6 text-right'}>
<button className={'btn btn-red btn-sm'} disabled={!isValid}>
<div css={tw`mt-6 text-right`}>
<Button disabled={!isValid}>
Disable Two-Factor
</button>
</Button>
</div>
</Form>
</Modal>

View File

@ -9,6 +9,8 @@ import { ApplicationStore } from '@/state';
import { httpErrorToHuman } from '@/api/http';
import FlashMessageRender from '@/components/FlashMessageRender';
import Field from '@/components/elements/Field';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
interface Values {
code: string;
@ -64,7 +66,7 @@ export default ({ onDismissed, ...props }: RequiredModalProps) => {
.matches(/^(\d){6}$/, 'Authenticator code must be 6 digits.'),
})}
>
{({ isSubmitting, isValid }) => (
{({ isSubmitting }) => (
<Modal
{...props}
onDismissed={dismiss}
@ -75,47 +77,47 @@ export default ({ onDismissed, ...props }: RequiredModalProps) => {
>
{recoveryTokens.length > 0 ?
<>
<h2 className={'mb-4'}>Two-factor authentication enabled</h2>
<p className={'text-neutral-300'}>
<h2 css={tw`text-2xl mb-4`}>Two-factor authentication enabled</h2>
<p css={tw`text-neutral-300`}>
Two-factor authentication has been enabled on your account. Should you loose access to
this device you'll need to use on of the codes displayed below in order to access your
this device you&apos;ll need to use on of the codes displayed below in order to access your
account.
</p>
<p className={'text-neutral-300 mt-4'}>
<p css={tw`text-neutral-300 mt-4`}>
<strong>These codes will not be displayed again.</strong> Please take note of them now
by storing them in a secure repository such as a password manager.
</p>
<pre className={'mt-4 rounded font-mono bg-neutral-900 p-4'}>
{recoveryTokens.map(token => <code key={token} className={'block mb-1'}>{token}</code>)}
<pre css={tw`text-sm mt-4 rounded font-mono bg-neutral-900 p-4`}>
{recoveryTokens.map(token => <code key={token} css={tw`block mb-1`}>{token}</code>)}
</pre>
<div className={'text-right'}>
<button className={'mt-6 btn btn-lg btn-primary'} onClick={dismiss}>
<div css={tw`text-right`}>
<Button css={tw`mt-6`} size={'large'} onClick={dismiss}>
Close
</button>
</Button>
</div>
</>
:
<Form className={'mb-0'}>
<FlashMessageRender className={'mb-6'} byKey={'account:two-factor'}/>
<div className={'flex flex-wrap'}>
<div className={'w-full md:flex-1'}>
<div className={'w-32 h-32 md:w-64 md:h-64 bg-neutral-600 p-2 rounded mx-auto'}>
<Form css={tw`mb-0`}>
<FlashMessageRender css={tw`mb-6`} byKey={'account:two-factor'}/>
<div css={tw`flex flex-wrap`}>
<div css={tw`w-full md:flex-1`}>
<div css={tw`w-32 h-32 md:w-64 md:h-64 bg-neutral-600 p-2 rounded mx-auto`}>
{!token || !token.length ?
<img
src={'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='}
className={'w-64 h-64 rounded'}
css={tw`w-64 h-64 rounded`}
/>
:
<img
src={`https://api.qrserver.com/v1/create-qr-code/?size=500x500&data=${token}`}
onLoad={() => setLoading(false)}
className={'w-full h-full shadow-none rounded-0'}
css={tw`w-full h-full shadow-none rounded-none`}
/>
}
</div>
</div>
<div className={'w-full mt-6 md:mt-0 md:flex-1 md:flex md:flex-col'}>
<div className={'flex-1'}>
<div css={tw`w-full mt-6 md:mt-0 md:flex-1 md:flex md:flex-col`}>
<div css={tw`flex-1`}>
<Field
id={'code'}
name={'code'}
@ -125,10 +127,10 @@ export default ({ onDismissed, ...props }: RequiredModalProps) => {
autoFocus={!loading}
/>
</div>
<div className={'mt-6 md:mt-0 text-right'}>
<button className={'btn btn-primary btn-sm'} disabled={!isValid}>
<div css={tw`mt-6 md:mt-0 text-right`}>
<Button>
Setup
</button>
</Button>
</div>
</div>
</div>

View File

@ -6,6 +6,8 @@ import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import Field from '@/components/elements/Field';
import { httpErrorToHuman } from '@/api/http';
import { ApplicationStore } from '@/state';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
interface Values {
email: string;
@ -54,14 +56,14 @@ export default () => {
({ isSubmitting, isValid }) => (
<React.Fragment>
<SpinnerOverlay size={'large'} visible={isSubmitting}/>
<Form className={'m-0'}>
<Form css={tw`m-0`}>
<Field
id={'current_email'}
type={'email'}
name={'email'}
label={'Email'}
/>
<div className={'mt-6'}>
<div css={tw`mt-6`}>
<Field
id={'confirm_password'}
type={'password'}
@ -69,10 +71,10 @@ export default () => {
label={'Confirm Password'}
/>
</div>
<div className={'mt-6'}>
<button className={'btn btn-sm btn-primary'} disabled={isSubmitting || !isValid}>
<div css={tw`mt-6`}>
<Button size={'small'} disabled={isSubmitting || !isValid}>
Update Email
</button>
</Button>
</div>
</Form>
</React.Fragment>

View File

@ -7,6 +7,8 @@ import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import updateAccountPassword from '@/api/account/updateAccountPassword';
import { httpErrorToHuman } from '@/api/http';
import { ApplicationStore } from '@/state';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
interface Values {
current: string;
@ -30,7 +32,7 @@ export default () => {
return null;
}
const submit = (values: Values, { resetForm, setSubmitting }: FormikHelpers<Values>) => {
const submit = (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
clearFlashes('account:password');
updateAccountPassword({ ...values })
.then(() => {
@ -57,14 +59,14 @@ export default () => {
({ isSubmitting, isValid }) => (
<React.Fragment>
<SpinnerOverlay size={'large'} visible={isSubmitting}/>
<Form className={'m-0'}>
<Form css={tw`m-0`}>
<Field
id={'current_password'}
type={'password'}
name={'current'}
label={'Current Password'}
/>
<div className={'mt-6'}>
<div css={tw`mt-6`}>
<Field
id={'new_password'}
type={'password'}
@ -73,7 +75,7 @@ export default () => {
description={'Your new password should be at least 8 characters in length and unique to this website.'}
/>
</div>
<div className={'mt-6'}>
<div css={tw`mt-6`}>
<Field
id={'confirm_password'}
type={'password'}
@ -81,10 +83,10 @@ export default () => {
label={'Confirm New Password'}
/>
</div>
<div className={'mt-6'}>
<button className={'btn btn-primary btn-sm'} disabled={isSubmitting || !isValid}>
<div css={tw`mt-6`}>
<Button size={'small'} disabled={isSubmitting || !isValid}>
Update Password
</button>
</Button>
</div>
</Form>
</React.Fragment>

View File

@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSearch } from '@fortawesome/free-solid-svg-icons/faSearch';
import { faSearch } from '@fortawesome/free-solid-svg-icons';
import useEventListener from '@/plugins/useEventListener';
import SearchModal from '@/components/dashboard/search/SearchModal';
@ -19,7 +19,7 @@ export default () => {
<>
{visible &&
<SearchModal
appear={true}
appear
visible={visible}
onDismissed={() => setVisible(false)}
/>

View File

@ -3,7 +3,7 @@ import Modal, { RequiredModalProps } from '@/components/elements/Modal';
import { Field, Form, Formik, FormikHelpers, useFormikContext } from 'formik';
import { Actions, useStoreActions, useStoreState } from 'easy-peasy';
import { object, string } from 'yup';
import { debounce } from 'lodash-es';
import debounce from 'debounce';
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
import InputSpinner from '@/components/elements/InputSpinner';
import getServers from '@/api/getServers';
@ -11,7 +11,9 @@ import { Server } from '@/api/server/getServer';
import { ApplicationStore } from '@/state';
import { httpErrorToHuman } from '@/api/http';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import styled from 'styled-components/macro';
import tw from 'twin.macro';
import Input from '@/components/elements/Input';
type Props = RequiredModalProps;
@ -20,8 +22,7 @@ interface Values {
}
const ServerResult = styled(Link)`
${tw`flex items-center bg-neutral-900 p-4 rounded border-l-4 border-neutral-900 no-underline`};
transition: all 250ms linear;
${tw`flex items-center bg-neutral-900 p-4 rounded border-l-4 border-neutral-900 no-underline transition-all duration-150`};
&:hover {
${tw`shadow border-cyan-500`};
@ -55,6 +56,7 @@ export default ({ ...props }: Props) => {
setLoading(true);
setSubmitting(false);
clearFlashes('search');
getServers(term)
.then(servers => setServers(servers.items.filter((_, index) => index < 5)))
.catch(error => {
@ -93,16 +95,12 @@ export default ({ ...props }: Props) => {
>
<SearchWatcher/>
<InputSpinner visible={loading}>
<Field
innerRef={ref}
name={'term'}
className={'input-dark'}
/>
<Field as={Input} innerRef={ref} name={'term'}/>
</InputSpinner>
</FormikFieldWrapper>
</Form>
{servers.length > 0 &&
<div className={'mt-6'}>
<div css={tw`mt-6`}>
{
servers.map(server => (
<ServerResult
@ -111,17 +109,17 @@ export default ({ ...props }: Props) => {
onClick={() => props.onDismissed()}
>
<div>
<p className={'text-sm'}>{server.name}</p>
<p className={'mt-1 text-xs text-neutral-400'}>
<p css={tw`text-sm`}>{server.name}</p>
<p css={tw`mt-1 text-xs text-neutral-400`}>
{
server.allocations.filter(alloc => alloc.default).map(allocation => (
server.allocations.filter(alloc => alloc.isDefault).map(allocation => (
<span key={allocation.ip + allocation.port.toString()}>{allocation.alias || allocation.ip}:{allocation.port}</span>
))
}
</p>
</div>
<div className={'flex-1 text-right'}>
<span className={'text-xs py-1 px-2 bg-cyan-800 text-cyan-100 rounded'}>
<div css={tw`flex-1 text-right`}>
<span css={tw`text-xs py-1 px-2 bg-cyan-800 text-cyan-100 rounded`}>
{server.node}
</span>
</div>

View File

@ -1,9 +1,10 @@
import React, { useCallback, useEffect, useState, lazy } from 'react';
import useRouter from 'use-react-router';
import { ServerContext } from '@/state/server';
import React, { useCallback, useEffect, useState } from 'react';
import ace, { Editor } from 'brace';
import getFileContents from '@/api/server/files/getFileContents';
import styled from 'styled-components';
import styled from 'styled-components/macro';
import tw from 'twin.macro';
import Select from '@/components/elements/Select';
// @ts-ignore
import modes from '@/modes';
// @ts-ignore
require('brace/ext/modelist');
@ -11,7 +12,7 @@ require('ayu-ace/mirage');
const EditorContainer = styled.div`
min-height: 16rem;
height: calc(100vh - 16rem);
height: calc(100vh - 20rem);
${tw`relative`};
#editor {
@ -19,35 +20,6 @@ const EditorContainer = styled.div`
}
`;
const modes: { [k: string]: string } = {
// eslint-disable-next-line @typescript-eslint/camelcase
assembly_x86: 'Assembly (x86)',
// eslint-disable-next-line @typescript-eslint/camelcase
c_cpp: 'C++',
coffee: 'Coffeescript',
css: 'CSS',
dockerfile: 'Dockerfile',
golang: 'Go',
html: 'HTML',
ini: 'Ini',
java: 'Java',
javascript: 'Javascript',
json: 'JSON',
kotlin: 'Kotlin',
lua: 'Luascript',
perl: 'Perl',
php: 'PHP',
properties: 'Properties',
python: 'Python',
ruby: 'Ruby',
// eslint-disable-next-line @typescript-eslint/camelcase
plain_text: 'Plaintext',
toml: 'TOML',
typescript: 'Typescript',
xml: 'XML',
yaml: 'YAML',
};
Object.keys(modes).forEach(mode => require(`brace/mode/${mode}`));
export interface Props {
@ -70,7 +42,7 @@ export default ({ style, initialContent, initialModePath, fetchContent, onConten
useEffect(() => {
editor && editor.session.setMode(mode);
}, [editor, mode]);
}, [ editor, mode ]);
useEffect(() => {
editor && editor.session.setValue(initialContent || '');
@ -113,19 +85,18 @@ export default ({ style, initialContent, initialModePath, fetchContent, onConten
return (
<EditorContainer style={style}>
<div id={'editor'} ref={ref}/>
<div className={'absolute pin-r pin-b z-50'}>
<div className={'m-3 rounded bg-neutral-900 border border-black'}>
<select
className={'input-dark'}
<div css={tw`absolute right-0 bottom-0 z-50`}>
<div css={tw`m-3 rounded bg-neutral-900 border border-black`}>
<Select
value={mode.split('/').pop()}
onChange={e => setMode(`ace/mode/${e.currentTarget.value}`)}
>
{
Object.keys(modes).map(key => (
<option key={key} value={key}>{modes[key]}</option>
<option key={key} value={key}>{(modes as { [k: string]: string })[key]}</option>
))
}
</select>
</Select>
</div>
</div>
</EditorContainer>

View File

@ -1,20 +1,99 @@
import React from 'react';
import classNames from 'classnames';
import styled, { css } from 'styled-components/macro';
import tw from 'twin.macro';
import Spinner from '@/components/elements/Spinner';
type Props = { isLoading?: boolean } & React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>;
interface Props {
isLoading?: boolean;
size?: 'xsmall' | 'small' | 'large' | 'xlarge';
color?: 'green' | 'red' | 'primary' | 'grey';
isSecondary?: boolean;
}
export default ({ isLoading, children, className, ...props }: Props) => (
<button
{...props}
className={classNames('btn btn-sm relative', className)}
>
const ButtonStyle = styled.button<Omit<Props, 'isLoading'>>`
${tw`relative inline-block rounded p-2 uppercase tracking-wide text-sm transition-all duration-150 border`};
${props => ((!props.isSecondary && !props.color) || props.color === 'primary') && css<Props>`
${props => !props.isSecondary && tw`bg-primary-500 border-primary-600 border text-primary-50`};
&:hover:not(:disabled) {
${tw`bg-primary-600 border-primary-700`};
}
`};
${props => props.color === 'grey' && css`
${tw`border-neutral-600 bg-neutral-500 text-neutral-50`};
&:hover:not(:disabled) {
${tw`bg-neutral-600 border-neutral-700`};
}
`};
${props => props.color === 'green' && css<Props>`
${tw`border-green-600 bg-green-500 text-green-50`};
&:hover:not(:disabled) {
${tw`bg-green-600 border-green-700`};
}
${props => props.isSecondary && css`
&:active:not(:disabled) {
${tw`bg-green-600 border-green-700`};
}
`};
`};
${props => props.color === 'red' && css<Props>`
${tw`border-red-600 bg-red-500 text-red-50`};
&:hover:not(:disabled) {
${tw`bg-red-600 border-red-700`};
}
${props => props.isSecondary && css`
&:active:not(:disabled) {
${tw`bg-red-600 border-red-700`};
}
`};
`};
${props => props.size === 'xsmall' && tw`p-2 text-xs`};
${props => (!props.size || props.size === 'small') && tw`p-3`};
${props => props.size === 'large' && tw`p-4 text-sm`};
${props => props.size === 'xlarge' && tw`p-4 w-full`};
${props => props.isSecondary && css<Props>`
${tw`border-neutral-600 bg-transparent text-neutral-200`};
&:hover:not(:disabled) {
${tw`border-neutral-500 text-neutral-100`};
${props => props.color === 'red' && tw`bg-red-500 border-red-600 text-red-50`};
${props => props.color === 'primary' && tw`bg-primary-500 border-primary-600 text-primary-50`};
${props => props.color === 'green' && tw`bg-green-500 border-green-600 text-green-50`};
}
`};
&:disabled { opacity: 0.55; cursor: default }
`;
type ComponentProps = Omit<JSX.IntrinsicElements['button'], 'ref' | keyof Props> & Props;
const Button: React.FC<ComponentProps> = ({ children, isLoading, ...props }) => (
<ButtonStyle {...props}>
{isLoading &&
<div className={'w-full flex absolute justify-center'} style={{ marginLeft: '-0.75rem' }}>
<div className={'spinner-circle spinner-white spinner-sm'}/>
<div css={tw`flex absolute justify-center items-center w-full h-full left-0 top-0`}>
<Spinner size={'small'}/>
</div>
}
<span className={isLoading ? 'text-transparent' : undefined}>
<span css={isLoading ? tw`text-transparent` : undefined}>
{children}
</span>
</button>
</ButtonStyle>
);
type LinkProps = Omit<JSX.IntrinsicElements['a'], 'ref' | keyof Props> & Props;
const LinkButton: React.FC<LinkProps> = props => <ButtonStyle as={'a'} {...props}/>;
export { LinkButton, ButtonStyle };
export default Button;

View File

@ -1,14 +1,15 @@
import React from 'react';
import { Field, FieldProps } from 'formik';
import Input from '@/components/elements/Input';
interface Props {
name: string;
value: string;
}
type OmitFields = 'name' | 'value' | 'type' | 'checked' | 'onChange';
type OmitFields = 'ref' | 'name' | 'value' | 'type' | 'checked' | 'onClick' | 'onChange';
type InputProps = Omit<React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>, OmitFields>;
type InputProps = Omit<JSX.IntrinsicElements['input'], OmitFields>;
const Checkbox = ({ name, value, ...props }: Props & InputProps) => (
<Field name={name}>
@ -20,7 +21,7 @@ const Checkbox = ({ name, value, ...props }: Props & InputProps) => (
}
return (
<input
<Input
{...field}
{...props}
type={'checkbox'}

View File

@ -1,5 +1,7 @@
import React from 'react';
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
type Props = {
title: string;
@ -16,15 +18,15 @@ const ConfirmationModal = ({ title, appear, children, visible, buttonText, onCon
showSpinnerOverlay={showSpinnerOverlay}
onDismissed={() => onDismissed()}
>
<h3 className={'mb-6'}>{title}</h3>
<p className={'text-sm'}>{children}</p>
<div className={'flex items-center justify-end mt-8'}>
<button className={'btn btn-secondary btn-sm'} onClick={() => onDismissed()}>
<h2 css={tw`text-2xl mb-6`}>{title}</h2>
<p css={tw`text-sm`}>{children}</p>
<div css={tw`flex items-center justify-end mt-8`}>
<Button isSecondary onClick={() => onDismissed()}>
Cancel
</button>
<button className={'btn btn-red btn-sm ml-4'} onClick={() => onConfirmed()}>
</Button>
<Button color={'red'} css={tw`ml-4`} onClick={() => onConfirmed()}>
{buttonText}
</button>
</Button>
</div>
</Modal>
);

View File

@ -1,7 +1,7 @@
import * as React from 'react';
import classNames from 'classnames';
import React from 'react';
import FlashMessageRender from '@/components/FlashMessageRender';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import tw from 'twin.macro';
type Props = Readonly<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
title?: string;
@ -12,16 +12,19 @@ type Props = Readonly<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElemen
const ContentBox = ({ title, borderColor, showFlashes, showLoadingOverlay, children, ...props }: Props) => (
<div {...props}>
{title && <h2 className={'text-neutral-300 mb-4 px-4'}>{title}</h2>}
{title && <h2 css={tw`text-neutral-300 mb-4 px-4 text-2xl`}>{title}</h2>}
{showFlashes &&
<FlashMessageRender
byKey={typeof showFlashes === 'string' ? showFlashes : undefined}
className={'mb-4'}
css={tw`mb-4`}
/>
}
<div className={classNames('bg-neutral-700 p-4 rounded shadow-lg relative', borderColor, {
'border-t-4': !!borderColor,
})}>
<div
css={[
tw`bg-neutral-700 p-4 rounded shadow-lg relative`,
!!borderColor && tw`border-t-4`,
]}
>
<SpinnerOverlay visible={showLoadingOverlay || false}/>
{children}
</div>

View File

@ -1,5 +1,6 @@
import styled from 'styled-components';
import { breakpoint } from 'styled-components-breakpoint';
import styled from 'styled-components/macro';
import { breakpoint } from '@/theme';
import tw from 'twin.macro';
const ContentContainer = styled.div`
max-width: 1200px;
@ -9,5 +10,6 @@ const ContentContainer = styled.div`
${tw`mx-auto`};
`};
`;
ContentContainer.displayName = 'ContentContainer';
export default ContentContainer;

View File

@ -1,6 +1,7 @@
import React, { useEffect, useRef, useState } from 'react';
import { CSSTransition } from 'react-transition-group';
import styled from 'styled-components';
import React, { createRef } from 'react';
import styled from 'styled-components/macro';
import tw from 'twin.macro';
import Fade from '@/components/elements/Fade';
interface Props {
children: React.ReactNode;
@ -12,76 +13,95 @@ export const DropdownButtonRow = styled.button<{ danger?: boolean }>`
transition: 150ms all ease;
&:hover {
${props => props.danger
? tw`text-red-700 bg-red-100`
: tw`text-neutral-700 bg-neutral-100`
};
${props => props.danger ? tw`text-red-700 bg-red-100` : tw`text-neutral-700 bg-neutral-100`};
}
`;
const DropdownMenu = ({ renderToggle, children }: Props) => {
const menu = useRef<HTMLDivElement>(null);
const [ posX, setPosX ] = useState(0);
const [ visible, setVisible ] = useState(false);
interface State {
posX: number;
visible: boolean;
}
const onClickHandler = (e: React.MouseEvent<any, MouseEvent>) => {
class DropdownMenu extends React.PureComponent<Props, State> {
menu = createRef<HTMLDivElement>();
state: State = {
posX: 0,
visible: false,
};
componentWillUnmount () {
this.removeListeners();
}
componentDidUpdate (prevProps: Readonly<Props>, prevState: Readonly<State>) {
const menu = this.menu.current;
if (this.state.visible && !prevState.visible && menu) {
document.addEventListener('click', this.windowListener);
document.addEventListener('contextmenu', this.contextMenuListener);
menu.setAttribute(
'style', `left: ${Math.round(this.state.posX - menu.clientWidth)}px`,
);
}
if (!this.state.visible && prevState.visible) {
this.removeListeners();
}
}
removeListeners = () => {
document.removeEventListener('click', this.windowListener);
document.removeEventListener('contextmenu', this.contextMenuListener);
};
onClickHandler = (e: React.MouseEvent<any, MouseEvent>) => {
e.preventDefault();
!visible && setPosX(e.clientX);
setVisible(s => !s);
this.triggerMenu(e.clientX);
};
const windowListener = (e: MouseEvent) => {
if (e.button === 2 || !visible || !menu.current) {
contextMenuListener = () => this.setState({ visible: false });
windowListener = (e: MouseEvent) => {
const menu = this.menu.current;
if (e.button === 2 || !this.state.visible || !menu) {
return;
}
if (e.target === menu.current || menu.current.contains(e.target as Node)) {
if (e.target === menu || menu.contains(e.target as Node)) {
return;
}
if (e.target !== menu.current && !menu.current.contains(e.target as Node)) {
setVisible(false);
if (e.target !== menu && !menu.contains(e.target as Node)) {
this.setState({ visible: false });
}
};
useEffect(() => {
if (!visible || !menu.current) {
return;
}
triggerMenu = (posX: number) => this.setState(s => ({
posX: !s.visible ? posX : s.posX,
visible: !s.visible,
}));
document.addEventListener('click', windowListener);
menu.current.setAttribute(
'style', `left: ${Math.round(posX - menu.current.clientWidth)}px`,
render () {
return (
<div>
{this.props.renderToggle(this.onClickHandler)}
<Fade timeout={150} in={this.state.visible} unmountOnExit>
<div
ref={this.menu}
onClick={e => {
e.stopPropagation();
this.setState({ visible: false });
}}
css={tw`absolute bg-white p-2 rounded border border-neutral-700 shadow-lg text-neutral-500 min-w-48`}
>
{this.props.children}
</div>
</Fade>
</div>
);
return () => {
document.removeEventListener('click', windowListener);
}
}, [ visible ]);
return (
<div>
{renderToggle(onClickHandler)}
<CSSTransition
timeout={250}
in={visible}
unmountOnExit={true}
classNames={'fade'}
>
<div
ref={menu}
onClick={e => {
e.stopPropagation();
setVisible(false);
}}
className={'absolute bg-white p-2 rounded border border-neutral-700 shadow-lg text-neutral-500 min-w-48'}
>
{children}
</div>
</CSSTransition>
</div>
);
};
}
}
export default DropdownMenu;

View File

@ -0,0 +1,43 @@
import React from 'react';
import tw from 'twin.macro';
import styled from 'styled-components/macro';
import CSSTransition, { CSSTransitionProps } from 'react-transition-group/CSSTransition';
interface Props extends Omit<CSSTransitionProps, 'timeout' | 'classNames'> {
timeout: number;
}
const Container = styled.div<{ timeout: number }>`
.fade-enter, .fade-exit {
will-change: opacity;
}
.fade-enter {
${tw`opacity-0`};
&.fade-enter-active {
${tw`opacity-100 transition-opacity ease-in`};
transition-duration: ${props => props.timeout}ms;
}
}
.fade-exit {
${tw`opacity-100`};
&.fade-exit-active {
${tw`opacity-0 transition-opacity ease-in`};
transition-duration: ${props => props.timeout}ms;
}
}
`;
const Fade: React.FC<Props> = ({ timeout, children, ...props }) => (
<Container timeout={timeout}>
<CSSTransition timeout={timeout} classNames={'fade'} {...props}>
{children}
</CSSTransition>
</Container>
);
Fade.displayName = 'Fade';
export default Fade;

View File

@ -1,6 +1,7 @@
import React from 'react';
import React, { forwardRef } from 'react';
import { Field as FormikField, FieldProps } from 'formik';
import classNames from 'classnames';
import Input from '@/components/elements/Input';
import Label from '@/components/elements/Label';
interface OwnProps {
name: string;
@ -12,21 +13,20 @@ interface OwnProps {
type Props = OwnProps & Omit<React.InputHTMLAttributes<HTMLInputElement>, 'name'>;
const Field = ({ id, name, light = false, label, description, validate, className, ...props }: Props) => (
<FormikField name={name} validate={validate}>
const Field = forwardRef<HTMLInputElement, Props>(({ id, name, light = false, label, description, validate, ...props }, ref) => (
<FormikField innerRef={ref} name={name} validate={validate}>
{
({ field, form: { errors, touched } }: FieldProps) => (
<React.Fragment>
<>
{label &&
<label htmlFor={id} className={light ? undefined : 'input-dark-label'}>{label}</label>
<Label htmlFor={id} isLight={light}>{label}</Label>
}
<input
<Input
id={id}
{...field}
{...props}
className={classNames((className || (light ? 'input' : 'input-dark')), {
error: touched[field.name] && errors[field.name],
})}
isLight={light}
hasError={!!(touched[field.name] && errors[field.name])}
/>
{touched[field.name] && errors[field.name] ?
<p className={'input-help error'}>
@ -35,10 +35,11 @@ const Field = ({ id, name, light = false, label, description, validate, classNam
:
description ? <p className={'input-help'}>{description}</p> : null
}
</React.Fragment>
</>
)
}
</FormikField>
);
));
Field.displayName = 'Field';
export default Field;

View File

@ -1,7 +1,7 @@
import React from 'react';
import { Field, FieldProps } from 'formik';
import classNames from 'classnames';
import InputError from '@/components/elements/InputError';
import Label from '@/components/elements/Label';
interface Props {
id?: string;
@ -17,11 +17,11 @@ const FormikFieldWrapper = ({ id, name, label, className, description, validate,
<Field name={name} validate={validate}>
{
({ field, form: { errors, touched } }: FieldProps) => (
<div className={classNames(className, { 'has-error': touched[field.name] && errors[field.name] })}>
{label && <label htmlFor={id} className={'input-dark-label'}>{label}</label>}
<div className={`${className} ${(touched[field.name] && errors[field.name]) ? 'has-error' : undefined}`}>
{label && <Label htmlFor={id}>{label}</Label>}
{children}
<InputError errors={errors} touched={touched} name={field.name}>
{description ? <p className={'input-help'}>{description}</p> : null}
{description || null}
</InputError>
</div>
)

View File

@ -0,0 +1,12 @@
import styled from 'styled-components/macro';
import tw from 'twin.macro';
export default styled.div<{ $hoverable?: boolean }>`
${tw`flex rounded no-underline text-neutral-200 items-center bg-neutral-700 p-4 border border-transparent transition-colors duration-150`};
${props => props.$hoverable !== false && tw`hover:border-neutral-500`};
& > div.icon {
${tw`rounded-full bg-neutral-500 p-3`};
}
`;

View File

@ -0,0 +1,81 @@
import styled, { css } from 'styled-components/macro';
import tw from 'twin.macro';
export interface Props {
isLight?: boolean;
hasError?: boolean;
}
const light = css<Props>`
${tw`bg-white border-neutral-200 text-neutral-800`};
&:focus { ${tw`border-primary-400`} }
&:disabled {
${tw`bg-neutral-100 border-neutral-200`};
}
`;
const checkboxStyle = css<Props>`
${tw`cursor-pointer appearance-none inline-block align-middle select-none flex-shrink-0 w-4 h-4 text-primary-400 border border-neutral-300 rounded-sm`};
color-adjust: exact;
background-origin: border-box;
transition: all 75ms linear, box-shadow 25ms linear;
&:checked {
${tw`border-transparent bg-no-repeat bg-center`};
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M5.707 7.293a1 1 0 0 0-1.414 1.414l2 2a1 1 0 0 0 1.414 0l4-4a1 1 0 0 0-1.414-1.414L7 8.586 5.707 7.293z'/%3e%3c/svg%3e");
background-color: currentColor;
background-size: 100% 100%;
}
&:focus {
${tw`outline-none border-primary-300`};
box-shadow: 0 0 0 1px rgba(9, 103, 210, 0.25);
}
`;
const inputStyle = css<Props>`
// Reset to normal styling.
resize: none;
${tw`appearance-none outline-none w-full min-w-0`};
${tw`p-3 border rounded text-sm transition-all duration-150`};
${tw`bg-neutral-600 border-neutral-500 hover:border-neutral-400 text-neutral-200 shadow-none`};
& + .input-help {
${tw`mt-1 text-xs`};
${props => props.hasError ? tw`text-red-400` : tw`text-neutral-400`};
}
&:required, &:invalid {
${tw`shadow-none`};
}
&:not(:disabled):not(:read-only):focus {
${tw`shadow-md border-primary-400`};
}
&:disabled {
${tw`opacity-75`};
}
${props => props.isLight && light};
${props => props.hasError && tw`text-red-600 border-red-500 hover:border-red-600`};
`;
const Input = styled.input<Props>`
&:not([type="checkbox"]):not([type="radio"]) {
${inputStyle};
}
&[type="checkbox"], &[type="radio"] {
${checkboxStyle};
&[type="radio"] {
${tw`rounded-full`};
}
}
`;
const Textarea = styled.textarea<Props>`${inputStyle}`;
export { Textarea };
export default Input;

View File

@ -1,17 +1,18 @@
import React from 'react';
import capitalize from 'lodash-es/capitalize';
import { FormikErrors, FormikTouched } from 'formik';
import tw from 'twin.macro';
import { capitalize } from '@/helpers';
interface Props {
errors: FormikErrors<any>;
touched: FormikTouched<any>;
name: string;
children?: React.ReactNode;
children?: string | number | null | undefined;
}
const InputError = ({ errors, touched, name, children }: Props) => (
touched[name] && errors[name] ?
<p className={'input-help error'}>
<p css={tw`text-xs text-red-400 pt-2`}>
{typeof errors[name] === 'string' ?
capitalize(errors[name] as string)
:
@ -19,9 +20,9 @@ const InputError = ({ errors, touched, name, children }: Props) => (
}
</p>
:
<React.Fragment>
{children}
</React.Fragment>
<>
{children ? <p css={tw`text-xs text-neutral-400 pt-2`}>{children}</p> : null}
</>
);
export default InputError;

Some files were not shown because too many files have changed in this diff Show More