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 name: tests
on: on:
push: push:
branch-ignore:
- 'master'
- 'release/**'
pull_request: pull_request:
jobs: jobs:
integration_tests: integration_tests:

View file

@ -3,36 +3,9 @@
namespace Pterodactyl\Contracts\Repository; namespace Pterodactyl\Contracts\Repository;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
interface AllocationRepositoryInterface extends RepositoryInterface 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. * Return all of the unique IPs that exist for a given node.
* *

View file

@ -2,7 +2,6 @@
namespace Pterodactyl\Contracts\Repository; namespace Pterodactyl\Contracts\Repository;
use Pterodactyl\Models\User;
use Pterodactyl\Models\Server; use Pterodactyl\Models\Server;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
@ -107,16 +106,6 @@ interface ServerRepositoryInterface extends RepositoryInterface, SearchableInter
*/ */
public function getDaemonServiceData(Server $server, bool $refresh = false): array; 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. * Return a server by UUID.
* *

View file

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

View file

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

View file

@ -159,7 +159,7 @@ class FileController extends ClientApiController
{ {
$this->fileRepository $this->fileRepository
->setServer($server) ->setServer($server)
->createDirectory($request->input('name'), $request->input('directory', '/')); ->createDirectory($request->input('name'), $request->input('root', '/'));
return Response::create('', Response::HTTP_NO_CONTENT); 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; namespace Pterodactyl\Http\Controllers\Base;
use Illuminate\Http\Request;
use Pterodactyl\Models\User;
use Pterodactyl\Http\Controllers\Controller; use Pterodactyl\Http\Controllers\Controller;
use Pterodactyl\Contracts\Repository\ServerRepositoryInterface; use Pterodactyl\Contracts\Repository\ServerRepositoryInterface;
@ -27,15 +25,10 @@ class IndexController extends Controller
/** /**
* Returns listing of user's servers. * Returns listing of user's servers.
* *
* @param \Illuminate\Http\Request $request
* @return \Illuminate\View\View * @return \Illuminate\View\View
*/ */
public function index(Request $request) public function index()
{ {
$servers = $this->repository->setSearchTerm($request->input('query'))->filterUserAccessServers( return view('templates/base.core');
$request->user(), User::FILTER_LEVEL_ALL, config('pterodactyl.paginate.frontend.servers')
);
return view('templates/base.core', ['servers' => $servers]);
} }
} }

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) { 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()) { if (! $server->isInstalled()) {

View file

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

View file

@ -2,6 +2,8 @@
namespace Pterodactyl\Http\Requests\Api\Client; namespace Pterodactyl\Http\Requests\Api\Client;
use Pterodactyl\Models\User;
class GetServersRequest extends ClientApiRequest class GetServersRequest extends ClientApiRequest
{ {
/** /**
@ -11,4 +13,28 @@ class GetServersRequest extends ClientApiRequest
{ {
return true; 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 string|null $ip_alias
* @property int $port * @property int $port
* @property int|null $server_id * @property int|null $server_id
* @property string|null $notes
* @property \Carbon\Carbon|null $created_at * @property \Carbon\Carbon|null $created_at
* @property \Carbon\Carbon|null $updated_at * @property \Carbon\Carbon|null $updated_at
* *
@ -60,6 +61,7 @@ class Allocation extends Model
'port' => 'required|numeric|between:1024,65553', 'port' => 'required|numeric|between:1024,65553',
'ip_alias' => 'nullable|string', 'ip_alias' => 'nullable|string',
'server_id' => 'nullable|exists:servers,id', '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_BACKUP_DOWNLOAD = 'backup.download';
const ACTION_ALLOCATION_READ = 'allocation.read'; 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_READ = 'file.read';
const ACTION_FILE_CREATE = 'file.create'; 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.', 'description' => 'Permissions that control a user\'s ability to modify the port allocations for this server.',
'keys' => [ 'keys' => [
'read' => 'Allows a user to view the allocations assigned to this server.', '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; const UPDATED_AT = null;
/**
* @var bool
*/
public $timestamps = true;
/** /**
* @var bool * @var bool
*/ */

View file

@ -7,6 +7,7 @@ use Illuminate\Support\Collection;
use Illuminate\Validation\Rules\In; use Illuminate\Validation\Rules\In;
use Illuminate\Auth\Authenticatable; use Illuminate\Auth\Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Illuminate\Database\Eloquent\Builder;
use Pterodactyl\Models\Traits\Searchable; use Pterodactyl\Models\Traits\Searchable;
use Illuminate\Auth\Passwords\CanResetPassword; use Illuminate\Auth\Passwords\CanResetPassword;
use Pterodactyl\Traits\Helpers\AvailableLanguages; use Pterodactyl\Traits\Helpers\AvailableLanguages;
@ -260,4 +261,21 @@ class User extends Model implements
{ {
return $this->hasMany(RecoveryToken::class); 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 Illuminate\Support\ServiceProvider;
use Pterodactyl\Http\ViewComposers\AssetComposer; use Pterodactyl\Http\ViewComposers\AssetComposer;
use Pterodactyl\Http\ViewComposers\ServerListComposer;
use Pterodactyl\Http\ViewComposers\Server\ServerDataComposer;
class ViewComposerServiceProvider extends ServiceProvider class ViewComposerServiceProvider extends ServiceProvider
{ {
@ -15,10 +13,5 @@ class ViewComposerServiceProvider extends ServiceProvider
public function boot() public function boot()
{ {
$this->app->make('view')->composer('*', AssetComposer::class); $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 Illuminate\Support\Collection;
use Pterodactyl\Models\Allocation; use Pterodactyl\Models\Allocation;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Pterodactyl\Contracts\Repository\AllocationRepositoryInterface; use Pterodactyl\Contracts\Repository\AllocationRepositoryInterface;
class AllocationRepository extends EloquentRepository implements AllocationRepositoryInterface class AllocationRepository extends EloquentRepository implements AllocationRepositoryInterface
@ -20,41 +19,6 @@ class AllocationRepository extends EloquentRepository implements AllocationRepos
return Allocation::class; 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. * Return all of the unique IPs that exist for a given node.
* *

View file

@ -2,9 +2,11 @@
namespace Pterodactyl\Repositories\Eloquent; namespace Pterodactyl\Repositories\Eloquent;
use Illuminate\Http\Request;
use Webmozart\Assert\Assert; use Webmozart\Assert\Assert;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Pterodactyl\Repositories\Repository; use Pterodactyl\Repositories\Repository;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Expression; use Illuminate\Database\Query\Expression;
use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
@ -15,6 +17,53 @@ use Pterodactyl\Contracts\Repository\Attributes\SearchableInterface;
abstract class EloquentRepository extends Repository implements RepositoryInterface 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 * Return an instance of the eloquent model bound to this
* repository instance. * repository instance.
@ -236,6 +285,7 @@ abstract class EloquentRepository extends Repository implements RepositoryInterf
* Return all records associated with the given model. * Return all records associated with the given model.
* *
* @return \Illuminate\Support\Collection * @return \Illuminate\Support\Collection
* @deprecated Just use the model
*/ */
public function all(): Collection public function all(): Collection
{ {
@ -313,6 +363,7 @@ abstract class EloquentRepository extends Repository implements RepositoryInterf
* Get the amount of entries in the database. * Get the amount of entries in the database.
* *
* @return int * @return int
* @deprecated just use the count method off a model
*/ */
public function count(): int public function count(): int
{ {

View file

@ -2,7 +2,6 @@
namespace Pterodactyl\Repositories\Eloquent; namespace Pterodactyl\Repositories\Eloquent;
use Pterodactyl\Models\User;
use Pterodactyl\Models\Server; use Pterodactyl\Models\Server;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Database\Eloquent\Builder; 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. * 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 ! $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. * Get the amount of servers that are suspended.
* *

View file

@ -270,7 +270,9 @@ class ServerCreationService
$records = array_merge($records, $data['allocation_additional']); $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, 'ip' => $allocation->ip,
'alias' => $allocation->ip_alias, 'alias' => $allocation->ip_alias,
'port' => $allocation->port, 'port' => $allocation->port,
'notes' => $allocation->notes,
'assigned' => ! is_null($allocation->server_id), 'assigned' => ! is_null($allocation->server_id),
]; ];
} }

View file

@ -24,13 +24,13 @@ class AllocationTransformer extends BaseClientTransformer
*/ */
public function transform(Allocation $model) public function transform(Allocation $model)
{ {
$model->loadMissing('server');
return [ return [
'id' => $model->id,
'ip' => $model->ip, 'ip' => $model->ip,
'alias' => $model->ip_alias, 'ip_alias' => $model->ip_alias,
'port' => $model->port, '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\Egg;
use Pterodactyl\Models\Server; use Pterodactyl\Models\Server;
use Pterodactyl\Models\Subuser; use Pterodactyl\Models\Subuser;
use Pterodactyl\Models\Allocation;
class ServerTransformer extends BaseClientTransformer class ServerTransformer extends BaseClientTransformer
{ {
/**
* @var string[]
*/
protected $defaultIncludes = ['allocations'];
/** /**
* @var array * @var array
*/ */
@ -41,10 +47,6 @@ class ServerTransformer extends BaseClientTransformer
'port' => $server->node->daemonSFTP, 'port' => $server->node->daemonSFTP,
], ],
'description' => $server->description, 'description' => $server->description,
'allocation' => [
'ip' => $server->allocation->alias,
'port' => $server->allocation->port,
],
'limits' => [ 'limits' => [
'memory' => $server->memory, 'memory' => $server->memory,
'swap' => $server->swap, '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. * 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", "psy/psysh": "^0.10.4",
"s1lentium/iptools": "^1.1", "s1lentium/iptools": "^1.1",
"spatie/laravel-fractal": "^5.7", "spatie/laravel-fractal": "^5.7",
"spatie/laravel-query-builder": "^2.8",
"staudenmeir/belongs-to-through": "^2.10", "staudenmeir/belongs-to-through": "^2.10",
"symfony/yaml": "^4.4", "symfony/yaml": "^4.4",
"webmozart/assert": "^1.9" "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", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "155b8e930e604c0476fa975b1084ca3f", "content-hash": "d05ab995e4aff4b847ff2a027924065c",
"packages": [ "packages": [
{ {
"name": "appstract/laravel-blade-directives", "name": "appstract/laravel-blade-directives",
@ -3361,6 +3361,70 @@
], ],
"time": "2020-03-02T18:40:49+00:00" "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", "name": "staudenmeir/belongs-to-through",
"version": "v2.10", "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", "name": "pterodactyl-panel",
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "^1.2.19", "@fortawesome/fontawesome-svg-core": "1.2.19",
"@fortawesome/free-solid-svg-icons": "^5.9.0", "@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", "@types/react-google-recaptcha": "^1.1.1",
"axios": "^0.19.0", "axios": "^0.19.2",
"ayu-ace": "^2.0.4", "ayu-ace": "^2.0.4",
"brace": "^0.11.1", "brace": "^0.11.1",
"chart.js": "^2.8.0", "chart.js": "^2.8.0",
"classnames": "^2.2.6", "date-fns": "^2.14.0",
"date-fns": "^1.29.0", "debounce": "^1.2.0",
"easy-peasy": "^3.2.3", "deepmerge": "^4.2.2",
"easy-peasy": "^3.3.1",
"events": "^3.0.0", "events": "^3.0.0",
"formik": "^2.1.4", "formik": "^2.1.4",
"i18next": "^19.0.0", "i18next": "^19.0.0",
@ -19,26 +20,26 @@
"i18next-localstorage-backend": "^3.0.0", "i18next-localstorage-backend": "^3.0.0",
"i18next-xhr-backend": "^3.2.2", "i18next-xhr-backend": "^3.2.2",
"jquery": "^3.3.1", "jquery": "^3.3.1",
"lodash-es": "^4.17.15",
"path": "^0.12.7", "path": "^0.12.7",
"query-string": "^6.7.0", "query-string": "^6.7.0",
"react": "^16.12.0", "react": "^16.13.1",
"react-dom": "npm:@hot-loader/react-dom", "react-dom": "npm:@hot-loader/react-dom",
"react-fast-compare": "^3.2.0",
"react-google-recaptcha": "^2.0.1", "react-google-recaptcha": "^2.0.1",
"react-hot-loader": "^4.12.18", "react-hot-loader": "^4.12.21",
"react-i18next": "^11.2.1", "react-i18next": "^11.2.1",
"react-redux": "^7.1.0", "react-redux": "^7.1.0",
"react-router-dom": "^5.1.2", "react-router-dom": "^5.1.2",
"react-transition-group": "^4.3.0", "react-transition-group": "^4.4.1",
"sockette": "^2.0.6", "sockette": "^2.0.6",
"styled-components": "^4.4.1", "styled-components": "^5.1.1",
"styled-components-breakpoint": "^3.0.0-preview.20", "styled-components-breakpoint": "^3.0.0-preview.20",
"use-react-router": "^1.0.7", "swr": "^0.2.3",
"uuid": "^3.3.2", "uuid": "^3.3.2",
"xterm": "^3.14.4", "xterm": "^3.14.4",
"xterm-addon-attach": "^0.1.0", "xterm-addon-attach": "^0.1.0",
"xterm-addon-fit": "^0.1.0", "xterm-addon-fit": "^0.1.0",
"yup": "^0.27.0" "yup": "^0.29.1"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.7.5", "@babel/core": "^7.7.5",
@ -47,75 +48,65 @@
"@babel/plugin-proposal-object-rest-spread": "^7.7.4", "@babel/plugin-proposal-object-rest-spread": "^7.7.4",
"@babel/plugin-proposal-optional-chaining": "^7.8.3", "@babel/plugin-proposal-optional-chaining": "^7.8.3",
"@babel/plugin-syntax-dynamic-import": "^7.7.4", "@babel/plugin-syntax-dynamic-import": "^7.7.4",
"@babel/plugin-transform-react-jsx": "^7.10.4",
"@babel/plugin-transform-runtime": "^7.7.5", "@babel/plugin-transform-runtime": "^7.7.5",
"@babel/preset-env": "^7.7.5", "@babel/preset-env": "^7.7.5",
"@babel/preset-react": "^7.7.4", "@babel/preset-react": "^7.7.4",
"@babel/preset-typescript": "^7.7.4", "@babel/preset-typescript": "^7.7.4",
"@babel/runtime": "^7.7.5", "@babel/runtime": "^7.7.5",
"@types/chart.js": "^2.8.5", "@types/chart.js": "^2.8.5",
"@types/classnames": "^2.2.8", "@types/debounce": "^1.2.0",
"@types/events": "^3.0.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/node": "^12.6.9",
"@types/query-string": "^6.3.0", "@types/query-string": "^6.3.0",
"@types/react": "^16.9.15", "@types/react": "^16.9.41",
"@types/react-dom": "^16.9.4", "@types/react-dom": "^16.9.8",
"@types/react-redux": "^7.1.1", "@types/react-redux": "^7.1.1",
"@types/react-router": "^5.1.3", "@types/react-router": "^5.1.3",
"@types/react-router-dom": "^5.1.3", "@types/react-router-dom": "^5.1.3",
"@types/react-transition-group": "^2.9.2", "@types/react-transition-group": "^4.4.0",
"@types/styled-components": "^4.4.0", "@types/styled-components": "^5.1.0",
"@types/uuid": "^3.4.5", "@types/uuid": "^3.4.5",
"@types/webpack-env": "^1.13.6", "@types/webpack-env": "^1.15.2",
"@types/yup": "^0.26.17", "@types/yup": "^0.29.3",
"@typescript-eslint/eslint-plugin": "^2.19.0", "@typescript-eslint/eslint-plugin": "^3.5.0",
"@typescript-eslint/parser": "^2.19.0", "@typescript-eslint/parser": "^3.5.0",
"babel-loader": "^8.0.6", "babel-loader": "^8.0.6",
"babel-plugin-styled-components": "^1.10.6", "babel-plugin-styled-components": "^1.10.7",
"babel-plugin-tailwind-components": "^0.5.10",
"cross-env": "^7.0.2", "cross-env": "^7.0.2",
"css-loader": "^3.2.1", "css-loader": "^3.2.1",
"cssnano": "^4.1.10", "eslint": "^7.4.0",
"eslint": "^5.16.0", "eslint-config-standard": "^14.1.1",
"eslint-config-standard": "^12.0.0", "eslint-plugin-import": "^2.22.0",
"eslint-plugin-import": "^2.17.3",
"eslint-plugin-node": "^9.1.0", "eslint-plugin-node": "^9.1.0",
"eslint-plugin-promise": "^4.1.1", "eslint-plugin-promise": "^4.2.1",
"eslint-plugin-react-hooks": "^2.1.2", "eslint-plugin-react": "^7.20.3",
"eslint-plugin-standard": "^4.0.0", "eslint-plugin-react-hooks": "^4.0.5",
"fork-ts-checker-webpack-plugin": "^1.5.0", "eslint-plugin-standard": "^4.0.1",
"glob-all": "^3.1.0", "fork-ts-checker-webpack-plugin": "^5.0.6",
"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",
"redux-devtools-extension": "^2.13.8", "redux-devtools-extension": "^2.13.8",
"resolve-url-loader": "^3.0.0", "source-map-loader": "^1.0.1",
"source-map-loader": "^0.2.4", "style-loader": "^1.2.1",
"style-loader": "^0.23.1", "svg-url-loader": "^6.0.0",
"tailwindcss": "^0.7.4", "tailwindcss": "^1.4.6",
"terser-webpack-plugin": "^1.3.0", "terser-webpack-plugin": "^3.0.6",
"ts-loader": "^6.2.1", "twin.macro": "^1.4.1",
"typescript": "^3.7.5", "typescript": "^3.9.6",
"webpack": "^4.41.2", "typescript-plugin-tw-template": "^2.0.1",
"webpack": "^4.43.0",
"webpack-assets-manifest": "^3.1.1", "webpack-assets-manifest": "^3.1.1",
"webpack-cli": "^3.3.10", "webpack-bundle-analyzer": "^3.8.0",
"webpack-dev-server": "^3.9.0", "webpack-cli": "^3.3.12",
"webpack-manifest-plugin": "^2.0.3", "webpack-dev-server": "^3.11.0",
"yarn-deduplicate": "^1.1.1" "yarn-deduplicate": "^1.1.1"
}, },
"scripts": { "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", "watch": "cross-env NODE_ENV=development ./node_modules/.bin/webpack --watch --progress",
"build": "cross-env NODE_ENV=development ./node_modules/.bin/webpack --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", "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": [ "browserslist": [
"> 0.5%", "> 0.5%",

View file

@ -1,38 +1,76 @@
parser: "@typescript-eslint/parser" parser: "@typescript-eslint/parser"
parserOptions: parserOptions:
ecmaVersion: 6 ecmaVersion: 6
ecmaFeatures:
jsx: true
project: "./tsconfig.json" project: "./tsconfig.json"
tsconfigRootDir: "./" tsconfigRootDir: "./"
settings:
react:
pragma: "React"
version: "detect"
linkComponents:
- name: Link
linkAttribute: to
- name: NavLink
linkAttribute: to
env: env:
browser: true browser: true
es6: true es6: true
plugins: plugins:
- "@typescript-eslint" - "react"
- "react-hooks" - "react-hooks"
- "@typescript-eslint"
extends: extends:
- "standard" - "standard"
- "plugin:react/recommended"
- "plugin:@typescript-eslint/recommended" - "plugin:@typescript-eslint/recommended"
globals:
tw: "readonly"
rules: rules:
indent: indent:
- error - error
- 4 - 4
- SwitchCase: 1
semi: semi:
- error - error
- always - always
comma-dangle: comma-dangle:
- error - error
- always-multiline - always-multiline
array-bracket-spacing:
- warn
- always
"react-hooks/rules-of-hooks": "react-hooks/rules-of-hooks":
- error - error
"react-hooks/exhaustive-deps": 0 "react-hooks/exhaustive-deps": 0
"@typescript-eslint/explicit-function-return-type": 0 "@typescript-eslint/explicit-function-return-type": 0
"@typescript-eslint/explicit-member-accessibility": 0 "@typescript-eslint/explicit-member-accessibility": 0
"@typescript-eslint/ban-ts-ignore": 0 "@typescript-eslint/ban-ts-ignore": 0
"@typescript-eslint/no-unused-vars": 0
"@typescript-eslint/no-explicit-any": 0 "@typescript-eslint/no-explicit-any": 0
"@typescript-eslint/no-non-null-assertion": 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: overrides:
- files: - files:
- "**/*.tsx" - "**/*.tsx"

View file

@ -1,21 +1,30 @@
import React from 'react'; import React from 'react';
import { Route } from 'react-router'; 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<{ const StyledSwitchTransition = styled(SwitchTransition)`
children: React.ReactNode; ${tw`relative`};
}>;
export default ({ children }: Props) => ( & section {
${tw`absolute w-full top-0 left-0`};
}
`;
const TransitionRouter: React.FC = ({ children }) => (
<Route <Route
render={({ location }) => ( render={({ location }) => (
<TransitionGroup className={'route-transition-group'}> <StyledSwitchTransition>
<CSSTransition key={location.key} timeout={250} in={true} appear={true} classNames={'fade'}> <Fade timeout={150} key={location.key} in appear unmountOnExit>
<section> <section>
{children} {children}
</section> </section>
</CSSTransition> </Fade>
</TransitionGroup> </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 }> => { export default (description: string, allowedIps: string): Promise<ApiKey & { secretToken: string }> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
http.post(`/api/client/account/api-keys`, { http.post('/api/client/account/api-keys', {
description, description,
// eslint-disable-next-line @typescript-eslint/camelcase
allowed_ips: allowedIps.length > 0 ? allowedIps.split('\n') : [], allowed_ips: allowedIps.length > 0 ? allowedIps.split('\n') : [],
}) })
.then(({ data }) => resolve({ .then(({ data }) => resolve({
...rawDataToApiKey(data.attributes), ...rawDataToApiKey(data.attributes),
// eslint-disable-next-line camelcase
secretToken: data.meta?.secret_token ?? '', secretToken: data.meta?.secret_token ?? '',
})) }))
.catch(reject); .catch(reject);

View file

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

View file

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

View file

@ -17,7 +17,6 @@ export default (email: string, data: Data): Promise<PasswordResetResponse> => {
email, email,
token: data.token, token: data.token,
password: data.password, password: data.password,
// eslint-disable-next-line @typescript-eslint/camelcase
password_confirmation: data.passwordConfirmation, password_confirmation: data.passwordConfirmation,
}) })
.then(response => resolve({ .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>> => { export default (query?: string, includeAdmin?: boolean): Promise<PaginatedResult<Server>> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
http.get(`/api/client`, { http.get('/api/client', {
params: { params: {
include: [ 'allocation' ], include: [ 'allocation' ],
// eslint-disable-next-line @typescript-eslint/camelcase type: includeAdmin ? 'all' : undefined,
filter: includeAdmin ? 'all' : undefined, 'filter[name]': query,
query,
}, },
}) })
.then(({ data }) => resolve({ .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), pagination: getPaginationSet(data.meta.pagination),
})) }))
.catch(reject); .catch(reject);

View file

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

View file

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

View file

@ -14,23 +14,21 @@ export interface FileObject {
modifiedAt: Date; modifiedAt: Date;
} }
export default (uuid: string, directory?: string): Promise<FileObject[]> => { export default async (uuid: string, directory?: string): Promise<FileObject[]> => {
return new Promise((resolve, reject) => { const { data } = await http.get(`/api/client/servers/${uuid}/files/list`, {
http.get(`/api/client/servers/${uuid}/files/list`, { params: { directory },
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);
}); });
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> => { export default (uuid: string, { renameFrom, renameTo }: Data): Promise<void> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
http.put(`/api/client/servers/${uuid}/files/rename`, { http.put(`/api/client/servers/${uuid}/files/rename`, {
// eslint-disable-next-line @typescript-eslint/camelcase
rename_from: renameFrom, rename_from: renameFrom,
// eslint-disable-next-line @typescript-eslint/camelcase
rename_to: renameTo, rename_to: renameTo,
}) })
.then(() => resolve()) .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 { export interface Allocation {
id: number;
ip: string; ip: string;
alias: string | null; alias: string | null;
port: number; port: number;
default: boolean; notes: string | null;
isDefault: boolean;
} }
export interface Server { export interface Server {
@ -35,7 +38,7 @@ export interface Server {
isInstalling: boolean; isInstalling: boolean;
} }
export const rawDataToServerObject = (data: any): Server => ({ export const rawDataToServerObject = ({ attributes: data }: FractalResponseData): Server => ({
id: data.identifier, id: data.identifier,
uuid: data.uuid, uuid: data.uuid,
name: data.name, name: data.name,
@ -45,24 +48,20 @@ export const rawDataToServerObject = (data: any): Server => ({
port: data.sftp_details.port, port: data.sftp_details.port,
}, },
description: data.description ? ((data.description.length > 0) ? data.description : null) : null, 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 }, limits: { ...data.limits },
featureLimits: { ...data.feature_limits }, featureLimits: { ...data.feature_limits },
isSuspended: data.is_suspended, isSuspended: data.is_suspended,
isInstalling: data.is_installing, isInstalling: data.is_installing,
allocations: ((data.relationships?.allocations as FractalResponseList | undefined)?.data || []).map(rawDataToServerAllocation),
}); });
export default (uuid: string): Promise<[ Server, string[] ]> => { export default (uuid: string): Promise<[ Server, string[] ]> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
http.get(`/api/client/servers/${uuid}`) http.get(`/api/client/servers/${uuid}`)
.then(({ data }) => resolve([ .then(({ data }) => resolve([
rawDataToServerObject(data.attributes), rawDataToServerObject(data),
data.meta?.is_server_owner ? ['*'] : (data.meta?.user_permissions || []), // eslint-disable-next-line camelcase
data.meta?.is_server_owner ? [ '*' ] : (data.meta?.user_permissions || []),
])) ]))
.catch(reject); .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, 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) => { return new Promise((resolve, reject) => {
http.get(`/api/client/servers/${uuid}/databases`, { http.get(`/api/client/servers/${uuid}/databases`, {
params: includePassword ? { include: 'password' } : undefined, 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()) .then(() => resolve())
.catch(reject); .catch(reject);
}); });
} };

View file

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

View file

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

View file

@ -64,7 +64,7 @@ export default (uuid: string): Promise<Schedule[]> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
http.get(`/api/client/servers/${uuid}/schedules`, { http.get(`/api/client/servers/${uuid}/schedules`, {
params: { params: {
include: ['tasks'], include: [ 'tasks' ],
}, },
}) })
.then(({ data }) => resolve((data.data || []).map((row: any) => rawDataToServerSchedule(row.attributes)))) .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))) .then(data => resolve(rawDataToServerSubuser(data.data)))
.catch(reject); .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 AuthenticationRouter from '@/routers/AuthenticationRouter';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { SiteSettings } from '@/state/settings'; import { SiteSettings } from '@/state/settings';
import { DefaultTheme, ThemeProvider } from 'styled-components';
import ProgressBar from '@/components/elements/ProgressBar'; import ProgressBar from '@/components/elements/ProgressBar';
import NotFound from '@/components/screens/NotFound'; import NotFound from '@/components/screens/NotFound';
import tw from 'twin.macro';
import GlobalStylesheet from '@/assets/css/GlobalStylesheet';
interface ExtendedWindow extends Window { interface ExtendedWindow extends Window {
SiteConfiguration?: SiteSettings; SiteConfiguration?: SiteSettings;
@ -18,24 +19,16 @@ interface ExtendedWindow extends Window {
uuid: string; uuid: string;
username: string; username: string;
email: string; email: string;
/* eslint-disable camelcase */
root_admin: boolean; root_admin: boolean;
use_totp: boolean; use_totp: boolean;
language: string; language: string;
updated_at: string; updated_at: string;
created_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 App = () => {
const { PterodactylUser, SiteConfiguration } = (window as ExtendedWindow); const { PterodactylUser, SiteConfiguration } = (window as ExtendedWindow);
if (PterodactylUser && !store.getState().user.data) { if (PterodactylUser && !store.getState().user.data) {
@ -56,11 +49,12 @@ const App = () => {
} }
return ( return (
<ThemeProvider theme={theme}> <>
<GlobalStylesheet/>
<StoreProvider store={store}> <StoreProvider store={store}>
<Provider store={store}> <Provider store={store}>
<ProgressBar/> <ProgressBar/>
<div className={'mx-auto w-auto'}> <div css={tw`mx-auto w-auto`}>
<BrowserRouter basename={'/'} key={'root-router'}> <BrowserRouter basename={'/'} key={'root-router'}>
<Switch> <Switch>
<Route path="/server/:id" component={ServerRouter}/> <Route path="/server/:id" component={ServerRouter}/>
@ -72,7 +66,7 @@ const App = () => {
</div> </div>
</Provider> </Provider>
</StoreProvider> </StoreProvider>
</ThemeProvider> </>
); );
}; };

View file

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

View file

@ -1,4 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import tw, { TwStyle } from 'twin.macro';
import styled from 'styled-components/macro';
export type FlashMessageType = 'success' | 'info' | 'warning' | 'error'; export type FlashMessageType = 'success' | 'info' | 'warning' | 'error';
@ -8,11 +10,60 @@ interface Props {
type?: FlashMessageType; type?: FlashMessageType;
} }
export default ({ title, children, type }: Props) => ( const styling = (type?: FlashMessageType): TwStyle | string => {
<div className={`lg:inline-flex alert ${type}`} role={'alert'}> switch (type) {
{title && <span className={'title'}>{title}</span>} case 'error':
<span className={'message'}> 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} {children}
</span> </span>
</div> </Container>
); );
MessageBox.displayName = 'MessageBox';
export default MessageBox;

View file

@ -1,51 +1,77 @@
import * as React from 'react'; import * as React from 'react';
import { Link, NavLink } from 'react-router-dom'; import { Link, NavLink } from 'react-router-dom';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faLayerGroup } from '@fortawesome/free-solid-svg-icons/faLayerGroup'; import { faCogs, faLayerGroup, faSignOutAlt, faUserCircle } from '@fortawesome/free-solid-svg-icons';
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 { useStoreState } from 'easy-peasy'; import { useStoreState } from 'easy-peasy';
import { ApplicationStore } from '@/state'; import { ApplicationStore } from '@/state';
import { faSearch } from '@fortawesome/free-solid-svg-icons/faSearch';
import SearchContainer from '@/components/dashboard/search/SearchContainer'; 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 () => { export default () => {
const user = useStoreState((state: ApplicationStore) => state.user.data!); const user = useStoreState((state: ApplicationStore) => state.user.data!);
const name = useStoreState((state: ApplicationStore) => state.settings.data!.name); const name = useStoreState((state: ApplicationStore) => state.settings.data!.name);
return ( return (
<div id={'navigation'}> <Navigation>
<div className={'mx-auto w-full flex items-center'} style={{ maxWidth: '1200px', height: '3.5rem' }}> <div css={tw`mx-auto w-full flex items-center`} style={{ maxWidth: '1200px', height: '3.5rem' }}>
<div id={'logo'}> <div id={'logo'}>
<Link to={'/'}> <Link to={'/'}>
{name} {name}
</Link> </Link>
</div> </div>
<div className={'right-navigation'}> <RightNavigation>
<SearchContainer/> <SearchContainer/>
<NavLink to={'/'} exact={true}> <NavLink to={'/'} exact>
<FontAwesomeIcon icon={faLayerGroup}/> <FontAwesomeIcon icon={faLayerGroup}/>
</NavLink> </NavLink>
<NavLink to={'/account'}> <NavLink to={'/account'}>
<FontAwesomeIcon icon={faUserCircle}/> <FontAwesomeIcon icon={faUserCircle}/>
</NavLink> </NavLink>
{user.rootAdmin && {user.rootAdmin &&
<a href={'/admin'} target={'_blank'}> <a href={'/admin'} target={'_blank'} rel={'noreferrer'}>
<FontAwesomeIcon icon={faCogs}/> <FontAwesomeIcon icon={faCogs}/>
</a> </a>
} }
{process.env.NODE_ENV !== 'production' &&
<NavLink to={'/design'}>
<FontAwesomeIcon icon={faSwatchbook}/>
</NavLink>
}
<a href={'/auth/logout'}> <a href={'/auth/logout'}>
<FontAwesomeIcon icon={faSignOutAlt}/> <FontAwesomeIcon icon={faSignOutAlt}/>
</a> </a>
</div> </RightNavigation>
</div> </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 Field from '@/components/elements/Field';
import { Formik, FormikHelpers } from 'formik'; import { Formik, FormikHelpers } from 'formik';
import { object, string } from 'yup'; import { object, string } from 'yup';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
interface Values { interface Values {
email: string; email: string;
@ -43,33 +45,30 @@ export default () => {
{({ isSubmitting }) => ( {({ isSubmitting }) => (
<LoginFormContainer <LoginFormContainer
title={'Request Password Reset'} title={'Request Password Reset'}
className={'w-full flex'} css={tw`w-full flex`}
> >
<Field <Field
light={true} light
label={'Email'} label={'Email'}
description={'Enter your account email address to receive instructions on resetting your password.'} description={'Enter your account email address to receive instructions on resetting your password.'}
name={'email'} name={'email'}
type={'email'} type={'email'}
/> />
<div className={'mt-6'}> <div css={tw`mt-6`}>
<button <Button
type={'submit'} type={'submit'}
className={'btn btn-primary btn-jumbo flex justify-center'} size={'xlarge'}
disabled={isSubmitting} disabled={isSubmitting}
isLoading={isSubmitting}
> >
{isSubmitting ? Send Email
<div className={'spinner-circle spinner-sm spinner-white'}></div> </Button>
:
'Send Email'
}
</button>
</div> </div>
<div className={'mt-6 text-center'}> <div css={tw`mt-6 text-center`}>
<Link <Link
type={'button'} type={'button'}
to={'/auth/login'} 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 Return to Login
</Link> </Link>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,9 +3,10 @@ import ContentBox from '@/components/elements/ContentBox';
import UpdatePasswordForm from '@/components/dashboard/forms/UpdatePasswordForm'; import UpdatePasswordForm from '@/components/dashboard/forms/UpdatePasswordForm';
import UpdateEmailAddressForm from '@/components/dashboard/forms/UpdateEmailAddressForm'; import UpdateEmailAddressForm from '@/components/dashboard/forms/UpdateEmailAddressForm';
import ConfigureTwoFactorForm from '@/components/dashboard/forms/ConfigureTwoFactorForm'; 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 PageContentBlock from '@/components/elements/PageContentBlock';
import tw from 'twin.macro';
import { breakpoint } from '@/theme';
import styled from 'styled-components/macro';
const Container = styled.div` const Container = styled.div`
${tw`flex flex-wrap my-10`}; ${tw`flex flex-wrap my-10`};
@ -31,13 +32,13 @@ export default () => {
<UpdatePasswordForm/> <UpdatePasswordForm/>
</ContentBox> </ContentBox>
<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'} title={'Update Email Address'}
showFlashes={'account:email'} showFlashes={'account:email'}
> >
<UpdateEmailAddressForm/> <UpdateEmailAddressForm/>
</ContentBox> </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/> <ConfigureTwoFactorForm/>
</ContentBox> </ContentBox>
</Container> </Container>

View file

@ -10,6 +10,7 @@ import FlashMessageRender from '@/components/FlashMessageRender';
import { useStoreState } from 'easy-peasy'; import { useStoreState } from 'easy-peasy';
import { usePersistedState } from '@/plugins/usePersistedState'; import { usePersistedState } from '@/plugins/usePersistedState';
import Switch from '@/components/elements/Switch'; import Switch from '@/components/elements/Switch';
import tw from 'twin.macro';
export default () => { export default () => {
const { addError, clearFlashes } = useFlash(); const { addError, clearFlashes } = useFlash();
@ -37,10 +38,10 @@ export default () => {
return ( return (
<PageContentBlock> <PageContentBlock>
<FlashMessageRender className={'mb-4'}/> <FlashMessageRender css={tw`mb-4`}/>
{rootAdmin && {rootAdmin &&
<div className={'mb-2 flex justify-end items-center'}> <div css={tw`mb-2 flex justify-end items-center`}>
<p className={'uppercase text-xs text-neutral-400 mr-2'}> <p css={tw`uppercase text-xs text-neutral-400 mr-2`}>
{showAdmin ? 'Showing all servers' : 'Showing your servers'} {showAdmin ? 'Showing all servers' : 'Showing your servers'}
</p> </p>
<Switch <Switch
@ -51,14 +52,16 @@ export default () => {
</div> </div>
} }
{loading ? {loading ?
<Spinner centered={true} size={'large'}/> <Spinner centered size={'large'}/>
: :
servers.length > 0 ? servers.length > 0 ?
servers.map(server => ( servers.map((server, index) => (
<ServerRow key={server.uuid} server={server} className={'mt-2'}/> <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. There are no servers associated with your account.
</p> </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 React, { useEffect, useRef, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faServer } from '@fortawesome/free-solid-svg-icons/faServer'; import { faServer, faEthernet, faMicrochip, faMemory, faHdd } from '@fortawesome/free-solid-svg-icons';
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 { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Server } from '@/api/server/getServer'; import { Server } from '@/api/server/getServer';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import getServerResourceUsage, { ServerStats } from '@/api/server/getServerResourceUsage'; import getServerResourceUsage, { ServerStats } from '@/api/server/getServerResourceUsage';
import { bytesToHuman } from '@/helpers'; 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 // Determines if the current value is in an alarm threshold so we can show it in red rather
// than the more faded default style. // than the more faded default style.
@ -20,7 +17,7 @@ const isAlarmState = (current: number, limit: number): boolean => {
return current / limitInBytes >= 0.90; 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 interval = useRef<number>(null);
const [ stats, setStats ] = useState<ServerStats | null>(null); const [ stats, setStats ] = useState<ServerStats | null>(null);
const [ statsError, setStatsError ] = useState(false); 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.memory = isAlarmState(stats.memoryUsageInBytes, server.limits.memory);
alarms.disk = server.limits.disk === 0 ? false : isAlarmState(stats.diskUsageInBytes, server.limits.disk); 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 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 memorylimit = server.limits.memory !== 0 ? bytesToHuman(server.limits.memory * 1000 * 1000) : 'Unlimited';
return ( return (
<Link to={`/server/${server.id}`} className={`grey-row-box cursor-pointer ${className}`}> <GreyRowBox as={Link} to={`/server/${server.id}`}>
<div className={'icon'}> <div className={'icon'}>
<FontAwesomeIcon icon={faServer}/> <FontAwesomeIcon icon={faServer}/>
</div> </div>
<div className={'flex-1 ml-4'}> <div css={tw`flex-1 ml-4`}>
<p className={'text-lg'}>{server.name}</p> <p css={tw`text-lg`}>{server.name}</p>
</div> </div>
<div className={'w-1/4 overflow-hidden'}> <div css={tw`w-1/4 overflow-hidden`}>
<div className={'flex ml-4'}> <div css={tw`flex ml-4`}>
<FontAwesomeIcon icon={faEthernet} className={'text-neutral-500'}/> <FontAwesomeIcon icon={faEthernet} css={tw`text-neutral-500`}/>
<p className={'text-sm text-neutral-400 ml-2'}> <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> <span key={allocation.ip + allocation.port.toString()}>{allocation.alias || allocation.ip}:{allocation.port}</span>
)) ))
} }
</p> </p>
</div> </div>
</div> </div>
<div className={'w-1/3 flex items-baseline relative'}> <div css={tw`w-1/3 flex items-baseline relative`}>
{!stats ? {!stats ?
!statsError ? !statsError ?
<SpinnerOverlay size={'tiny'} visible={true} backgroundOpacity={0.25}/> <SpinnerOverlay size={'small'} visible backgroundOpacity={0.25}/>
: :
server.isInstalling ? server.isInstalling ?
<div className={'flex-1 text-center'}> <div css={tw`flex-1 text-center`}>
<span className={'bg-neutral-500 rounded px-2 py-1 text-neutral-100 text-xs'}> <span css={tw`bg-neutral-500 rounded px-2 py-1 text-neutral-100 text-xs`}>
Installing Installing
</span> </span>
</div> </div>
: :
<div className={'flex-1 text-center'}> <div css={tw`flex-1 text-center`}>
<span className={'bg-red-500 rounded px-2 py-1 text-red-100 text-xs'}> <span css={tw`bg-red-500 rounded px-2 py-1 text-red-100 text-xs`}>
{server.isSuspended ? 'Suspended' : 'Connection Error'} {server.isSuspended ? 'Suspended' : 'Connection Error'}
</span> </span>
</div> </div>
: :
<React.Fragment> <React.Fragment>
<div className={'flex-1 flex ml-4 justify-center'}> <div css={tw`flex-1 flex ml-4 justify-center`}>
<FontAwesomeIcon <FontAwesomeIcon
icon={faMicrochip} icon={faMicrochip}
className={classNames({ css={[
'text-neutral-500': !alarms.cpu, !alarms.cpu && tw`text-neutral-500`,
'text-red-400': alarms.cpu, alarms.cpu && tw`text-red-400`,
})} ]}
/> />
<p <p
className={classNames('text-sm ml-2', { css={[
'text-neutral-400': !alarms.cpu, tw`text-sm ml-2`,
'text-white': alarms.cpu, !alarms.cpu && tw`text-neutral-400`,
})} alarms.cpu && tw`text-white`,
]}
> >
{stats.cpuUsagePercent} % {stats.cpuUsagePercent} %
</p> </p>
</div> </div>
<div className={'flex-1 ml-4'}> <div css={tw`flex-1 ml-4`}>
<div className={'flex justify-center'}> <div css={tw`flex justify-center`}>
<FontAwesomeIcon <FontAwesomeIcon
icon={faMemory} icon={faMemory}
className={classNames({ css={[
'text-neutral-500': !alarms.memory, !alarms.memory && tw`text-neutral-500`,
'text-red-400': alarms.memory, alarms.memory && tw`text-red-400`,
})} ]}
/> />
<p <p
className={classNames('text-sm ml-2', { css={[
'text-neutral-400': !alarms.memory, tw`text-sm ml-2`,
'text-white': alarms.memory, !alarms.memory && tw`text-neutral-400`,
})} alarms.memory && tw`text-white`,
]}
> >
{bytesToHuman(stats.memoryUsageInBytes)} {bytesToHuman(stats.memoryUsageInBytes)}
</p> </p>
</div> </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>
<div className={'flex-1 ml-4'}> <div css={tw`flex-1 ml-4`}>
<div className={'flex justify-center'}> <div css={tw`flex justify-center`}>
<FontAwesomeIcon <FontAwesomeIcon
icon={faHdd} icon={faHdd}
className={classNames({ css={[
'text-neutral-500': !alarms.disk, !alarms.disk && tw`text-neutral-500`,
'text-red-400': alarms.disk, alarms.disk && tw`text-red-400`,
})} ]}
/> />
<p <p
className={classNames('text-sm ml-2', { css={[
'text-neutral-400': !alarms.disk, tw`text-sm ml-2`,
'text-white': alarms.disk, !alarms.disk && tw`text-neutral-400`,
})} alarms.disk && tw`text-white`,
]}
> >
{bytesToHuman(stats.diskUsageInBytes)} {bytesToHuman(stats.diskUsageInBytes)}
</p> </p>
</div> </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> </div>
</React.Fragment> </React.Fragment>
} }
</div> </div>
</Link> </GreyRowBox>
); );
}; };

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,9 +1,10 @@
import React, { useCallback, useEffect, useState, lazy } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import useRouter from 'use-react-router';
import { ServerContext } from '@/state/server';
import ace, { Editor } from 'brace'; import ace, { Editor } from 'brace';
import getFileContents from '@/api/server/files/getFileContents'; import styled from 'styled-components/macro';
import styled from 'styled-components'; import tw from 'twin.macro';
import Select from '@/components/elements/Select';
// @ts-ignore
import modes from '@/modes';
// @ts-ignore // @ts-ignore
require('brace/ext/modelist'); require('brace/ext/modelist');
@ -11,7 +12,7 @@ require('ayu-ace/mirage');
const EditorContainer = styled.div` const EditorContainer = styled.div`
min-height: 16rem; min-height: 16rem;
height: calc(100vh - 16rem); height: calc(100vh - 20rem);
${tw`relative`}; ${tw`relative`};
#editor { #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}`)); Object.keys(modes).forEach(mode => require(`brace/mode/${mode}`));
export interface Props { export interface Props {
@ -70,7 +42,7 @@ export default ({ style, initialContent, initialModePath, fetchContent, onConten
useEffect(() => { useEffect(() => {
editor && editor.session.setMode(mode); editor && editor.session.setMode(mode);
}, [editor, mode]); }, [ editor, mode ]);
useEffect(() => { useEffect(() => {
editor && editor.session.setValue(initialContent || ''); editor && editor.session.setValue(initialContent || '');
@ -113,19 +85,18 @@ export default ({ style, initialContent, initialModePath, fetchContent, onConten
return ( return (
<EditorContainer style={style}> <EditorContainer style={style}>
<div id={'editor'} ref={ref}/> <div id={'editor'} ref={ref}/>
<div className={'absolute pin-r pin-b z-50'}> <div css={tw`absolute right-0 bottom-0 z-50`}>
<div className={'m-3 rounded bg-neutral-900 border border-black'}> <div css={tw`m-3 rounded bg-neutral-900 border border-black`}>
<select <Select
className={'input-dark'}
value={mode.split('/').pop()} value={mode.split('/').pop()}
onChange={e => setMode(`ace/mode/${e.currentTarget.value}`)} onChange={e => setMode(`ace/mode/${e.currentTarget.value}`)}
> >
{ {
Object.keys(modes).map(key => ( 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>
</div> </div>
</EditorContainer> </EditorContainer>

View file

@ -1,20 +1,99 @@
import React from 'react'; 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) => ( const ButtonStyle = styled.button<Omit<Props, 'isLoading'>>`
<button ${tw`relative inline-block rounded p-2 uppercase tracking-wide text-sm transition-all duration-150 border`};
{...props}
className={classNames('btn btn-sm relative', className)} ${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 && {isLoading &&
<div className={'w-full flex absolute justify-center'} style={{ marginLeft: '-0.75rem' }}> <div css={tw`flex absolute justify-center items-center w-full h-full left-0 top-0`}>
<div className={'spinner-circle spinner-white spinner-sm'}/> <Spinner size={'small'}/>
</div> </div>
} }
<span className={isLoading ? 'text-transparent' : undefined}> <span css={isLoading ? tw`text-transparent` : undefined}>
{children} {children}
</span> </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 React from 'react';
import { Field, FieldProps } from 'formik'; import { Field, FieldProps } from 'formik';
import Input from '@/components/elements/Input';
interface Props { interface Props {
name: string; name: string;
value: 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) => ( const Checkbox = ({ name, value, ...props }: Props & InputProps) => (
<Field name={name}> <Field name={name}>
@ -20,7 +21,7 @@ const Checkbox = ({ name, value, ...props }: Props & InputProps) => (
} }
return ( return (
<input <Input
{...field} {...field}
{...props} {...props}
type={'checkbox'} type={'checkbox'}

View file

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

View file

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

View file

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

View file

@ -1,6 +1,7 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { createRef } from 'react';
import { CSSTransition } from 'react-transition-group'; import styled from 'styled-components/macro';
import styled from 'styled-components'; import tw from 'twin.macro';
import Fade from '@/components/elements/Fade';
interface Props { interface Props {
children: React.ReactNode; children: React.ReactNode;
@ -12,76 +13,95 @@ export const DropdownButtonRow = styled.button<{ danger?: boolean }>`
transition: 150ms all ease; transition: 150ms all ease;
&:hover { &:hover {
${props => props.danger ${props => props.danger ? tw`text-red-700 bg-red-100` : tw`text-neutral-700 bg-neutral-100`};
? tw`text-red-700 bg-red-100`
: tw`text-neutral-700 bg-neutral-100`
};
} }
`; `;
const DropdownMenu = ({ renderToggle, children }: Props) => { interface State {
const menu = useRef<HTMLDivElement>(null); posX: number;
const [ posX, setPosX ] = useState(0); visible: boolean;
const [ visible, setVisible ] = useState(false); }
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(); e.preventDefault();
this.triggerMenu(e.clientX);
!visible && setPosX(e.clientX);
setVisible(s => !s);
}; };
const windowListener = (e: MouseEvent) => { contextMenuListener = () => this.setState({ visible: false });
if (e.button === 2 || !visible || !menu.current) {
windowListener = (e: MouseEvent) => {
const menu = this.menu.current;
if (e.button === 2 || !this.state.visible || !menu) {
return; return;
} }
if (e.target === menu.current || menu.current.contains(e.target as Node)) { if (e.target === menu || menu.contains(e.target as Node)) {
return; return;
} }
if (e.target !== menu.current && !menu.current.contains(e.target as Node)) { if (e.target !== menu && !menu.contains(e.target as Node)) {
setVisible(false); this.setState({ visible: false });
} }
}; };
useEffect(() => { triggerMenu = (posX: number) => this.setState(s => ({
if (!visible || !menu.current) { posX: !s.visible ? posX : s.posX,
return; visible: !s.visible,
} }));
document.addEventListener('click', windowListener); render () {
menu.current.setAttribute( return (
'style', `left: ${Math.round(posX - menu.current.clientWidth)}px`, <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; 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 { Field as FormikField, FieldProps } from 'formik';
import classNames from 'classnames'; import Input from '@/components/elements/Input';
import Label from '@/components/elements/Label';
interface OwnProps { interface OwnProps {
name: string; name: string;
@ -12,21 +13,20 @@ interface OwnProps {
type Props = OwnProps & Omit<React.InputHTMLAttributes<HTMLInputElement>, 'name'>; type Props = OwnProps & Omit<React.InputHTMLAttributes<HTMLInputElement>, 'name'>;
const Field = ({ id, name, light = false, label, description, validate, className, ...props }: Props) => ( const Field = forwardRef<HTMLInputElement, Props>(({ id, name, light = false, label, description, validate, ...props }, ref) => (
<FormikField name={name} validate={validate}> <FormikField innerRef={ref} name={name} validate={validate}>
{ {
({ field, form: { errors, touched } }: FieldProps) => ( ({ field, form: { errors, touched } }: FieldProps) => (
<React.Fragment> <>
{label && {label &&
<label htmlFor={id} className={light ? undefined : 'input-dark-label'}>{label}</label> <Label htmlFor={id} isLight={light}>{label}</Label>
} }
<input <Input
id={id} id={id}
{...field} {...field}
{...props} {...props}
className={classNames((className || (light ? 'input' : 'input-dark')), { isLight={light}
error: touched[field.name] && errors[field.name], hasError={!!(touched[field.name] && errors[field.name])}
})}
/> />
{touched[field.name] && errors[field.name] ? {touched[field.name] && errors[field.name] ?
<p className={'input-help error'}> <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 description ? <p className={'input-help'}>{description}</p> : null
} }
</React.Fragment> </>
) )
} }
</FormikField> </FormikField>
); ));
Field.displayName = 'Field';
export default Field; export default Field;

View file

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { Field, FieldProps } from 'formik'; import { Field, FieldProps } from 'formik';
import classNames from 'classnames';
import InputError from '@/components/elements/InputError'; import InputError from '@/components/elements/InputError';
import Label from '@/components/elements/Label';
interface Props { interface Props {
id?: string; id?: string;
@ -17,11 +17,11 @@ const FormikFieldWrapper = ({ id, name, label, className, description, validate,
<Field name={name} validate={validate}> <Field name={name} validate={validate}>
{ {
({ field, form: { errors, touched } }: FieldProps) => ( ({ field, form: { errors, touched } }: FieldProps) => (
<div className={classNames(className, { 'has-error': touched[field.name] && errors[field.name] })}> <div className={`${className} ${(touched[field.name] && errors[field.name]) ? 'has-error' : undefined}`}>
{label && <label htmlFor={id} className={'input-dark-label'}>{label}</label>} {label && <Label htmlFor={id}>{label}</Label>}
{children} {children}
<InputError errors={errors} touched={touched} name={field.name}> <InputError errors={errors} touched={touched} name={field.name}>
{description ? <p className={'input-help'}>{description}</p> : null} {description || null}
</InputError> </InputError>
</div> </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 React from 'react';
import capitalize from 'lodash-es/capitalize';
import { FormikErrors, FormikTouched } from 'formik'; import { FormikErrors, FormikTouched } from 'formik';
import tw from 'twin.macro';
import { capitalize } from '@/helpers';
interface Props { interface Props {
errors: FormikErrors<any>; errors: FormikErrors<any>;
touched: FormikTouched<any>; touched: FormikTouched<any>;
name: string; name: string;
children?: React.ReactNode; children?: string | number | null | undefined;
} }
const InputError = ({ errors, touched, name, children }: Props) => ( const InputError = ({ errors, touched, name, children }: Props) => (
touched[name] && errors[name] ? touched[name] && errors[name] ?
<p className={'input-help error'}> <p css={tw`text-xs text-red-400 pt-2`}>
{typeof errors[name] === 'string' ? {typeof errors[name] === 'string' ?
capitalize(errors[name] as string) capitalize(errors[name] as string)
: :
@ -19,9 +20,9 @@ const InputError = ({ errors, touched, name, children }: Props) => (
} }
</p> </p>
: :
<React.Fragment> <>
{children} {children ? <p css={tw`text-xs text-neutral-400 pt-2`}>{children}</p> : null}
</React.Fragment> </>
); );
export default InputError; export default InputError;

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