Merge branch 'feature/react' into develop

This commit is contained in:
Dane Everitt 2019-06-28 22:53:49 -07:00
commit 6618a124e7
No known key found for this signature in database
GPG key ID: EEA66103B3D71F53
148 changed files with 3105 additions and 6262 deletions

2
.gitignore vendored
View file

@ -12,7 +12,7 @@ node_modules
_ide_helper.php _ide_helper.php
.phpstorm.meta.php .phpstorm.meta.php
.php_cs.cache .php_cs.cache
public/assets/* public/assets/manifest.json
# For local development with docker # For local development with docker
# Remove if we ever put the Dockerfile in the repo # Remove if we ever put the Dockerfile in the repo

View file

@ -3,6 +3,21 @@ This file is a running track of new features and fixes to each version of the pa
This project follows [Semantic Versioning](http://semver.org) guidelines. This project follows [Semantic Versioning](http://semver.org) guidelines.
## v0.7.14 (Derelict Dermodactylus)
### Fixed
* **[SECURITY]** Fixes an XSS vulnerability when performing certain actions in the file manager.
* **[SECURITY]** Attempting to login as a user who has 2FA enabled will no longer request the 2FA token before validating
that their password is correct. This closes a user existence leak that would expose that an account exists if
it had 2FA enabled.
### Changed
* Support for setting a node to listen on ports lower than 1024.
* QR code URLs are now generated without the use of an external library to reduce the dependency tree.
* Regenerated database passwords now respect the same settings that were used when initially created.
* Cleaned up 2FA QR code generation to use a more up-to-date library and API.
* Console charts now properly start at 0 and scale based on server configuration. No more crazy spikes that
are due to a change of one unit.
## v0.7.13 (Derelict Dermodactylus) ## v0.7.13 (Derelict Dermodactylus)
### Fixed ### Fixed
* Fixes a bug with the location update API endpoint throwing an error due to an unexected response value. * Fixes a bug with the location update API endpoint throwing an error due to an unexected response value.

View file

@ -6,45 +6,17 @@ use Illuminate\Http\Request;
use Pterodactyl\Models\User; use Pterodactyl\Models\User;
use Illuminate\Auth\AuthManager; use Illuminate\Auth\AuthManager;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use PragmaRX\Google2FA\Google2FA;
use Illuminate\Auth\Events\Failed; use Illuminate\Auth\Events\Failed;
use Illuminate\Contracts\Config\Repository;
use Pterodactyl\Exceptions\DisplayException; use Pterodactyl\Exceptions\DisplayException;
use Pterodactyl\Http\Controllers\Controller; use Pterodactyl\Http\Controllers\Controller;
use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Encryption\Encrypter;
use Illuminate\Foundation\Auth\AuthenticatesUsers; use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Illuminate\Contracts\Cache\Repository as CacheRepository;
use Pterodactyl\Contracts\Repository\UserRepositoryInterface;
abstract class AbstractLoginController extends Controller abstract class AbstractLoginController extends Controller
{ {
use AuthenticatesUsers; use AuthenticatesUsers;
/**
* @var \Illuminate\Auth\AuthManager
*/
protected $auth;
/**
* @var \Illuminate\Contracts\Cache\Repository
*/
protected $cache;
/**
* @var \Illuminate\Contracts\Encryption\Encrypter
*/
protected $encrypter;
/**
* @var \PragmaRX\Google2FA\Google2FA
*/
protected $google2FA;
/**
* @var \Pterodactyl\Contracts\Repository\UserRepositoryInterface
*/
protected $repository;
/** /**
* Lockout time for failed login requests. * Lockout time for failed login requests.
* *
@ -66,30 +38,29 @@ abstract class AbstractLoginController extends Controller
*/ */
protected $redirectTo = '/'; protected $redirectTo = '/';
/**
* @var \Illuminate\Auth\AuthManager
*/
protected $auth;
/**
* @var \Illuminate\Contracts\Config\Repository
*/
protected $config;
/** /**
* LoginController constructor. * LoginController constructor.
* *
* @param \Illuminate\Auth\AuthManager $auth * @param \Illuminate\Auth\AuthManager $auth
* @param \Illuminate\Contracts\Cache\Repository $cache * @param \Illuminate\Contracts\Config\Repository $config
* @param \Illuminate\Contracts\Encryption\Encrypter $encrypter
* @param \PragmaRX\Google2FA\Google2FA $google2FA
* @param \Pterodactyl\Contracts\Repository\UserRepositoryInterface $repository
*/ */
public function __construct( public function __construct(AuthManager $auth, Repository $config)
AuthManager $auth, {
CacheRepository $cache, $this->lockoutTime = $config->get('auth.lockout.time');
Encrypter $encrypter, $this->maxLoginAttempts = $config->get('auth.lockout.attempts');
Google2FA $google2FA,
UserRepositoryInterface $repository
) {
$this->auth = $auth;
$this->cache = $cache;
$this->encrypter = $encrypter;
$this->google2FA = $google2FA;
$this->repository = $repository;
$this->lockoutTime = config('auth.lockout.time'); $this->auth = $auth;
$this->maxLoginAttempts = config('auth.lockout.attempts'); $this->config = $config;
} }
/** /**
@ -128,10 +99,12 @@ abstract class AbstractLoginController extends Controller
$this->auth->guard()->login($user, true); $this->auth->guard()->login($user, true);
return response()->json([ return JsonResponse::create([
'complete' => true, 'data' => [
'intended' => $this->redirectPath(), 'complete' => true,
'user' => $user->toVueObject(), 'intended' => $this->redirectPath(),
'user' => $user->toVueObject(),
],
]); ]);
} }

View file

@ -33,10 +33,11 @@ class ForgotPasswordController extends Controller
/** /**
* Get the response for a successful password reset link. * Get the response for a successful password reset link.
* *
* @param string $response * @param \Illuminate\Http\Request $request
* @param string $response
* @return \Illuminate\Http\JsonResponse * @return \Illuminate\Http\JsonResponse
*/ */
protected function sendResetLinkResponse($response): JsonResponse protected function sendResetLinkResponse(Request $request, $response): JsonResponse
{ {
return response()->json([ return response()->json([
'status' => trans($response), 'status' => trans($response),

View file

@ -2,12 +2,64 @@
namespace Pterodactyl\Http\Controllers\Auth; namespace Pterodactyl\Http\Controllers\Auth;
use Illuminate\Auth\AuthManager;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use PragmaRX\Google2FA\Google2FA;
use Illuminate\Contracts\Config\Repository;
use Illuminate\Contracts\Encryption\Encrypter;
use Pterodactyl\Http\Requests\Auth\LoginCheckpointRequest; use Pterodactyl\Http\Requests\Auth\LoginCheckpointRequest;
use Illuminate\Contracts\Cache\Repository as CacheRepository;
use Pterodactyl\Contracts\Repository\UserRepositoryInterface;
use Pterodactyl\Exceptions\Repository\RecordNotFoundException; use Pterodactyl\Exceptions\Repository\RecordNotFoundException;
class LoginCheckpointController extends AbstractLoginController class LoginCheckpointController extends AbstractLoginController
{ {
/**
* @var \Illuminate\Contracts\Cache\Repository
*/
private $cache;
/**
* @var \Pterodactyl\Contracts\Repository\UserRepositoryInterface
*/
private $repository;
/**
* @var \PragmaRX\Google2FA\Google2FA
*/
private $google2FA;
/**
* @var \Illuminate\Contracts\Encryption\Encrypter
*/
private $encrypter;
/**
* LoginCheckpointController constructor.
*
* @param \Illuminate\Auth\AuthManager $auth
* @param \Illuminate\Contracts\Encryption\Encrypter $encrypter
* @param \PragmaRX\Google2FA\Google2FA $google2FA
* @param \Illuminate\Contracts\Config\Repository $config
* @param \Illuminate\Contracts\Cache\Repository $cache
* @param \Pterodactyl\Contracts\Repository\UserRepositoryInterface $repository
*/
public function __construct(
AuthManager $auth,
Encrypter $encrypter,
Google2FA $google2FA,
Repository $config,
CacheRepository $cache,
UserRepositoryInterface $repository
) {
parent::__construct($auth, $config);
$this->google2FA = $google2FA;
$this->cache = $cache;
$this->repository = $repository;
$this->encrypter = $encrypter;
}
/** /**
* Handle a login where the user is required to provide a TOTP authentication * Handle a login where the user is required to provide a TOTP authentication
* token. Once a user has reached this stage it is assumed that they have already * token. Once a user has reached this stage it is assumed that they have already
@ -16,29 +68,28 @@ class LoginCheckpointController extends AbstractLoginController
* @param \Pterodactyl\Http\Requests\Auth\LoginCheckpointRequest $request * @param \Pterodactyl\Http\Requests\Auth\LoginCheckpointRequest $request
* @return \Illuminate\Http\JsonResponse * @return \Illuminate\Http\JsonResponse
* *
* @throws \PragmaRX\Google2FA\Exceptions\IncompatibleWithGoogleAuthenticatorException
* @throws \PragmaRX\Google2FA\Exceptions\InvalidCharactersException
* @throws \PragmaRX\Google2FA\Exceptions\SecretKeyTooShortException
* @throws \Pterodactyl\Exceptions\DisplayException * @throws \Pterodactyl\Exceptions\DisplayException
*/ */
public function __invoke(LoginCheckpointRequest $request): JsonResponse public function __invoke(LoginCheckpointRequest $request): JsonResponse
{ {
try { try {
$cache = $this->cache->pull($request->input('confirmation_token'), []); $user = $this->repository->find(
$user = $this->repository->find(array_get($cache, 'user_id', 0)); $this->cache->pull($request->input('confirmation_token'), 0)
);
} catch (RecordNotFoundException $exception) { } catch (RecordNotFoundException $exception) {
return $this->sendFailedLoginResponse($request); return $this->sendFailedLoginResponse($request);
} }
if (array_get($cache, 'request_ip') !== $request->ip()) { $decrypted = $this->encrypter->decrypt($user->totp_secret);
return $this->sendFailedLoginResponse($request, $user); $window = $this->config->get('pterodactyl.auth.2fa.window');
if ($this->google2FA->verifyKey($decrypted, $request->input('authentication_code'), $window)) {
return $this->sendLoginResponse($user, $request);
} }
if (! $this->google2FA->verifyKey( return $this->sendFailedLoginResponse($request, $user);
$this->encrypter->decrypt($user->totp_secret),
$request->input('authentication_code'),
config('pterodactyl.auth.2fa.window')
)) {
return $this->sendFailedLoginResponse($request, $user);
}
return $this->sendLoginResponse($user, $request);
} }
} }

View file

@ -2,13 +2,57 @@
namespace Pterodactyl\Http\Controllers\Auth; namespace Pterodactyl\Http\Controllers\Auth;
use Illuminate\Support\Str;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Auth\AuthManager;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
use Illuminate\Contracts\Config\Repository;
use Illuminate\Contracts\View\Factory as ViewFactory;
use Illuminate\Contracts\Cache\Repository as CacheRepository;
use Pterodactyl\Contracts\Repository\UserRepositoryInterface;
use Pterodactyl\Exceptions\Repository\RecordNotFoundException; use Pterodactyl\Exceptions\Repository\RecordNotFoundException;
class LoginController extends AbstractLoginController class LoginController extends AbstractLoginController
{ {
/**
* @var \Illuminate\Contracts\View\Factory
*/
private $view;
/**
* @var \Illuminate\Contracts\Cache\Repository
*/
private $cache;
/**
* @var \Pterodactyl\Contracts\Repository\UserRepositoryInterface
*/
private $repository;
/**
* LoginController constructor.
*
* @param \Illuminate\Auth\AuthManager $auth
* @param \Illuminate\Contracts\Config\Repository $config
* @param \Illuminate\Contracts\Cache\Repository $cache
* @param \Pterodactyl\Contracts\Repository\UserRepositoryInterface $repository
* @param \Illuminate\Contracts\View\Factory $view
*/
public function __construct(
AuthManager $auth,
Repository $config,
CacheRepository $cache,
UserRepositoryInterface $repository,
ViewFactory $view
) {
parent::__construct($auth, $config);
$this->view = $view;
$this->cache = $cache;
$this->repository = $repository;
}
/** /**
* Handle all incoming requests for the authentication routes and render the * Handle all incoming requests for the authentication routes and render the
* base authentication view component. Vuejs will take over at this point and * base authentication view component. Vuejs will take over at this point and
@ -18,7 +62,7 @@ class LoginController extends AbstractLoginController
*/ */
public function index(): View public function index(): View
{ {
return view('templates/auth.core'); return $this->view->make('templates/auth.core');
} }
/** /**
@ -54,21 +98,20 @@ class LoginController extends AbstractLoginController
return $this->sendFailedLoginResponse($request, $user); return $this->sendFailedLoginResponse($request, $user);
} }
// If the user is using 2FA we do not actually log them in at this step, we return
// a one-time token to link the 2FA credentials to this account via the UI.
if ($user->use_totp) { if ($user->use_totp) {
$token = str_random(128); $token = Str::random(64);
$this->cache->put($token, [ $this->cache->put($token, $user->id, 5);
'user_id' => $user->id,
'request_ip' => $request->ip(),
], 5);
return response()->json([ return JsonResponse::create([
'complete' => false, 'data' => [
'login_token' => $token, 'complete' => false,
'confirmation_token' => $token,
],
]); ]);
} }
$this->auth->guard()->login($user, true);
return $this->sendLoginResponse($user, $request); return $this->sendLoginResponse($user, $request);
} }
} }

View file

@ -83,8 +83,8 @@ class SecurityController extends Controller
return JsonResponse::create([ return JsonResponse::create([
'enabled' => false, 'enabled' => false,
'qr_image' => $response->get('image'), 'qr_image' => $response,
'secret' => $response->get('secret'), 'secret' => '',
]); ]);
} }

View file

@ -25,7 +25,7 @@ class LoginCheckpointRequest extends FormRequest
{ {
return [ return [
'confirmation_token' => 'required|string', 'confirmation_token' => 'required|string',
'authentication_code' => 'required|int', 'authentication_code' => 'required|numeric',
]; ];
} }
} }

View file

@ -1,23 +1,18 @@
<?php <?php
/**
* Pterodactyl - Panel
* Copyright (c) 2015 - 2017 Dane Everitt <dane@daneeveritt.com>.
*
* This software is licensed under the terms of the MIT license.
* https://opensource.org/licenses/MIT
*/
namespace Pterodactyl\Services\Users; namespace Pterodactyl\Services\Users;
use Exception;
use RuntimeException;
use Pterodactyl\Models\User; use Pterodactyl\Models\User;
use Illuminate\Support\Collection;
use PragmaRX\Google2FAQRCode\Google2FA;
use Illuminate\Contracts\Encryption\Encrypter; use Illuminate\Contracts\Encryption\Encrypter;
use Pterodactyl\Contracts\Repository\UserRepositoryInterface; use Pterodactyl\Contracts\Repository\UserRepositoryInterface;
use Illuminate\Contracts\Config\Repository as ConfigRepository; use Illuminate\Contracts\Config\Repository as ConfigRepository;
class TwoFactorSetupService class TwoFactorSetupService
{ {
const VALID_BASE32_CHARACTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
/** /**
* @var \Illuminate\Contracts\Config\Repository * @var \Illuminate\Contracts\Config\Repository
*/ */
@ -28,11 +23,6 @@ class TwoFactorSetupService
*/ */
private $encrypter; private $encrypter;
/**
* @var PragmaRX\Google2FAQRCode\Google2FA
*/
private $google2FA;
/** /**
* @var \Pterodactyl\Contracts\Repository\UserRepositoryInterface * @var \Pterodactyl\Contracts\Repository\UserRepositoryInterface
*/ */
@ -43,43 +33,51 @@ class TwoFactorSetupService
* *
* @param \Illuminate\Contracts\Config\Repository $config * @param \Illuminate\Contracts\Config\Repository $config
* @param \Illuminate\Contracts\Encryption\Encrypter $encrypter * @param \Illuminate\Contracts\Encryption\Encrypter $encrypter
* @param PragmaRX\Google2FAQRCode\Google2FA $google2FA
* @param \Pterodactyl\Contracts\Repository\UserRepositoryInterface $repository * @param \Pterodactyl\Contracts\Repository\UserRepositoryInterface $repository
*/ */
public function __construct( public function __construct(
ConfigRepository $config, ConfigRepository $config,
Encrypter $encrypter, Encrypter $encrypter,
Google2FA $google2FA,
UserRepositoryInterface $repository UserRepositoryInterface $repository
) { ) {
$this->config = $config; $this->config = $config;
$this->encrypter = $encrypter; $this->encrypter = $encrypter;
$this->google2FA = $google2FA;
$this->repository = $repository; $this->repository = $repository;
} }
/** /**
* Generate a 2FA token and store it in the database before returning the * Generate a 2FA token and store it in the database before returning the
* QR code image. * QR code URL. This URL will need to be attached to a QR generating service in
* order to function.
* *
* @param \Pterodactyl\Models\User $user * @param \Pterodactyl\Models\User $user
* @return \Illuminate\Support\Collection * @return string
* *
* @throws \Pterodactyl\Exceptions\Model\DataValidationException * @throws \Pterodactyl\Exceptions\Model\DataValidationException
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
*/ */
public function handle(User $user): Collection public function handle(User $user): string
{ {
$secret = $this->google2FA->generateSecretKey($this->config->get('pterodactyl.auth.2fa.bytes')); $secret = '';
$image = $this->google2FA->getQRCodeInline($this->config->get('app.name'), $user->email, $secret); try {
for ($i = 0; $i < $this->config->get('pterodactyl.auth.2fa.bytes', 16); $i++) {
$secret .= substr(self::VALID_BASE32_CHARACTERS, random_int(0, 31), 1);
}
} catch (Exception $exception) {
throw new RuntimeException($exception->getMessage(), 0, $exception);
}
$this->repository->withoutFreshModel()->update($user->id, [ $this->repository->withoutFreshModel()->update($user->id, [
'totp_secret' => $this->encrypter->encrypt($secret), 'totp_secret' => $this->encrypter->encrypt($secret),
]); ]);
return new Collection([ $company = $this->config->get('app.name');
'image' => $image,
'secret' => $secret, return sprintf(
]); 'otpauth://totp/%1$s:%2$s?secret=%3$s&issuer=%1$s',
rawurlencode($company),
rawurlencode($user->email),
rawurlencode($secret)
);
} }
} }

View file

@ -30,7 +30,6 @@
"matriphe/iso-639": "^1.2", "matriphe/iso-639": "^1.2",
"nesbot/carbon": "^1.22", "nesbot/carbon": "^1.22",
"pragmarx/google2fa": "^5.0", "pragmarx/google2fa": "^5.0",
"pragmarx/google2fa-qrcode": "^1.0.3",
"predis/predis": "^1.1", "predis/predis": "^1.1",
"prologue/alerts": "^0.4", "prologue/alerts": "^0.4",
"ramsey/uuid": "^3.7", "ramsey/uuid": "^3.7",

View file

@ -9,7 +9,7 @@ return [
| change this value if you are not maintaining your own internal versions. | change this value if you are not maintaining your own internal versions.
*/ */
'version' => 'canary', 'version' => '0.7.14',
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------

View file

@ -1,6 +1,5 @@
<?php <?php
return [ return [
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------

View file

@ -1,39 +1,57 @@
{ {
"name": "pterodactyl-panel", "name": "pterodactyl-panel",
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "^1.2.19",
"@fortawesome/free-solid-svg-icons": "^5.9.0",
"@fortawesome/react-fontawesome": "^0.1.4",
"@hot-loader/react-dom": "^16.8.6",
"axios": "^0.18.0", "axios": "^0.18.0",
"brace": "^0.11.1", "brace": "^0.11.1",
"classnames": "^2.2.6",
"date-fns": "^1.29.0", "date-fns": "^1.29.0",
"easy-peasy": "^2.5.0",
"feather-icons": "^4.10.0", "feather-icons": "^4.10.0",
"formik": "^1.5.7",
"jquery": "^3.3.1", "jquery": "^3.3.1",
"lodash": "^4.17.11", "lodash": "^4.17.11",
"query-string": "^6.7.0",
"react": "^16.8.6",
"react-dom": "^16.8.6",
"react-hot-loader": "^4.9.0",
"react-router-dom": "^5.0.1",
"react-transition-group": "^4.1.0",
"socket.io-client": "^2.2.0", "socket.io-client": "^2.2.0",
"vee-validate": "^2.1.7", "use-react-router": "^1.0.7",
"vue": "^2.6.4",
"vue-axios": "^2.1.1",
"vue-i18n": "^8.6.0",
"vue-router": "^3.0.1",
"vuex": "^3.0.1",
"vuex-router-sync": "^5.0.0",
"ws-wrapper": "^2.0.0", "ws-wrapper": "^2.0.0",
"xterm": "^3.5.1" "xterm": "^3.5.1",
"yup": "^0.27.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/cli": "^7.2.3",
"@babel/core": "^7.2.2", "@babel/core": "^7.2.2",
"@babel/plugin-proposal-class-properties": "^7.3.0",
"@babel/plugin-proposal-object-rest-spread": "^7.3.1",
"@babel/plugin-syntax-dynamic-import": "^7.2.0", "@babel/plugin-syntax-dynamic-import": "^7.2.0",
"@babel/preset-env": "^7.3.1", "@babel/preset-env": "^7.3.1",
"@babel/preset-react": "^7.0.0",
"@types/classnames": "^2.2.8",
"@types/feather-icons": "^4.7.0", "@types/feather-icons": "^4.7.0",
"@types/lodash": "^4.14.119", "@types/lodash": "^4.14.119",
"@types/node": "^10.12.15", "@types/query-string": "^6.3.0",
"@types/socket.io-client": "^1.4.32", "@types/react": "^16.8.19",
"@types/react-dom": "^16.8.4",
"@types/react-router-dom": "^4.3.3",
"@types/react-transition-group": "^2.9.2",
"@types/webpack-env": "^1.13.6", "@types/webpack-env": "^1.13.6",
"@types/yup": "^0.26.17",
"@typescript-eslint/eslint-plugin": "^1.10.1",
"@typescript-eslint/parser": "^1.10.1",
"babel-loader": "^8.0.5", "babel-loader": "^8.0.5",
"clean-webpack-plugin": "^0.1.19",
"css-loader": "^2.1.0", "css-loader": "^2.1.0",
"cssnano": "^4.0.3", "cssnano": "^4.0.3",
"eslint": "^5.16.0",
"eslint-config-standard": "^12.0.0",
"eslint-plugin-import": "^2.17.3",
"eslint-plugin-node": "^9.1.0",
"eslint-plugin-promise": "^4.1.1",
"eslint-plugin-standard": "^4.0.0",
"fork-ts-checker-webpack-plugin": "^0.5.2", "fork-ts-checker-webpack-plugin": "^0.5.2",
"glob-all": "^3.1.0", "glob-all": "^3.1.0",
"html-webpack-plugin": "^3.2.0", "html-webpack-plugin": "^3.2.0",
@ -45,31 +63,24 @@
"precss": "^3.1.2", "precss": "^3.1.2",
"purgecss-webpack-plugin": "^1.1.0", "purgecss-webpack-plugin": "^1.1.0",
"resolve-url-loader": "^3.0.0", "resolve-url-loader": "^3.0.0",
"source-map-loader": "^0.2.4",
"style-loader": "^0.23.1", "style-loader": "^0.23.1",
"tailwindcss": "^0.7.4", "tailwindcss": "^0.7.4",
"terser-webpack-plugin": "^1.3.0",
"ts-loader": "^5.3.3", "ts-loader": "^5.3.3",
"typescript": "^3.3.1", "typescript": "^3.3.1",
"uglifyjs-webpack-plugin": "^2.1.1",
"vue-devtools": "^3.1.9",
"vue-feather-icons": "^4.7.1",
"vue-loader": "^15.6.2",
"vue-mc": "^0.2.4",
"vue-template-compiler": "^2.6.4",
"vueify-insert-css": "^1.0.0",
"webpack": "^4.29.0", "webpack": "^4.29.0",
"webpack-assets-manifest": "^3.1.1", "webpack-assets-manifest": "^3.1.1",
"webpack-cli": "^3.0.2", "webpack-cli": "^3.0.2",
"webpack-dev-server": "^3.1.14", "webpack-dev-server": "^3.1.14",
"webpack-manifest-plugin": "^2.0.3", "webpack-manifest-plugin": "^2.0.3"
"webpack-shell-plugin": "^0.5.0",
"webpack-stream": "^4.0.3"
}, },
"scripts": { "scripts": {
"clean": "rm -rf public/assets/*.js && rm -rf public/assets/*.css",
"watch": "NODE_ENV=development ./node_modules/.bin/webpack --watch --progress", "watch": "NODE_ENV=development ./node_modules/.bin/webpack --watch --progress",
"build": "NODE_ENV=development ./node_modules/.bin/webpack --progress", "build": "NODE_ENV=development ./node_modules/.bin/webpack --progress",
"build:production": "NODE_ENV=production ./node_modules/.bin/webpack", "build:production": "NODE_ENV=production ./node_modules/.bin/webpack",
"serve": "NODE_ENV=development webpack-dev-server --host 0.0.0.0 --hot", "serve": "yarn run clean && NODE_ENV=development webpack-dev-server --host 0.0.0.0 --hot",
"v:serve": "PUBLIC_PATH=http://pterodactyl.test:8080 yarn run serve", "v:serve": "PUBLIC_PATH=https://pterodactyl.test:8080 yarn run serve --https --key /etc/ssl/private/pterodactyl.test-key.pem --cert /etc/ssl/private/pterodactyl.test.pem"
"compile:assets": "php artisan vue-i18n:generate & php artisan ziggy:generate resources/assets/scripts/helpers/ziggy.js"
} }
} }

1
public/assets/pterodactyl.svg Executable file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

View file

@ -255,6 +255,31 @@ $(document).ready(function () {
TimeLabels.push($.format.date(new Date(), 'HH:mm:ss')); TimeLabels.push($.format.date(new Date(), 'HH:mm:ss'));
// memory.cmax is the maximum given by the container
// memory.amax is given by the json config
// use the maximum of both
// with no limit memory.cmax will always be higher
// but with limit memory.amax is sometimes still smaller than memory.total
MemoryChart.config.options.scales.yAxes[0].ticks.max = Math.max(proc.data.memory.cmax, proc.data.memory.amax) / (1000 * 1000);
if (Pterodactyl.server.cpu > 0) {
// if there is a cpu limit defined use 100% as maximum
CPUChart.config.options.scales.yAxes[0].ticks.max = 100;
} else {
// if there is no cpu limit defined use linux percentage
// and find maximum in all values
var maxCpu = 1;
for(var i = 0; i < CPUData.length; i++) {
maxCpu = Math.max(maxCpu, parseFloat(CPUData[i]))
}
maxCpu = Math.ceil(maxCpu / 100) * 100;
CPUChart.config.options.scales.yAxes[0].ticks.max = maxCpu;
}
CPUChart.update(); CPUChart.update();
MemoryChart.update(); MemoryChart.update();
}); });
@ -301,6 +326,13 @@ $(document).ready(function () {
}, },
animation: { animation: {
duration: 1, duration: 1,
},
scales: {
yAxes: [{
ticks: {
beginAtZero: true
}
}]
} }
} }
}); });
@ -346,6 +378,13 @@ $(document).ready(function () {
}, },
animation: { animation: {
duration: 1, duration: 1,
},
scales: {
yAxes: [{
ticks: {
beginAtZero: true
}
}]
} }
} }
}); });

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -29,6 +29,10 @@ class ActionsClass {
this.element = undefined; this.element = undefined;
} }
sanitizedString(value) {
return $('<div>').text(value).html();
}
folder(path) { folder(path) {
let inputValue let inputValue
if (path) { if (path) {
@ -296,7 +300,7 @@ class ActionsClass {
swal({ swal({
type: 'warning', type: 'warning',
title: '', title: '',
text: 'Are you sure you want to delete <code>' + delName + '</code>?', text: 'Are you sure you want to delete <code>' + this.sanitizedString(delName) + '</code>?',
html: true, html: true,
showCancelButton: true, showCancelButton: true,
showConfirmButton: true, showConfirmButton: true,
@ -394,7 +398,7 @@ class ActionsClass {
let formattedItems = ""; let formattedItems = "";
let i = 0; let i = 0;
$.each(selectedItems, function(key, value) { $.each(selectedItems, function(key, value) {
formattedItems += ("<code>" + value + "</code>, "); formattedItems += ("<code>" + this.sanitizedString(value) + "</code>, ");
i++; i++;
return i < 5; return i < 5;
}); });
@ -407,7 +411,7 @@ class ActionsClass {
swal({ swal({
type: 'warning', type: 'warning',
title: '', title: '',
text: 'Are you sure you want to delete the following files: ' + formattedItems + '?', text: 'Are you sure you want to delete the following files: ' + this.sanitizedString(formattedItems) + '?',
html: true, html: true,
showCancelButton: true, showCancelButton: true,
showConfirmButton: true, showConfirmButton: true,
@ -536,7 +540,7 @@ class ActionsClass {
type: 'error', type: 'error',
title: 'Whoops!', title: 'Whoops!',
html: true, html: true,
text: error text: this.sanitizedString(error)
}); });
}); });
} }

View file

@ -62,7 +62,7 @@ class ContextMenuClass {
if (Pterodactyl.permissions.createFiles) { if (Pterodactyl.permissions.createFiles) {
buildMenu += '<li class="divider"></li> \ buildMenu += '<li class="divider"></li> \
<li data-action="file"><a href="/server/'+ Pterodactyl.server.uuidShort +'/files/add/?dir=' + newFilePath + '" class="text-muted"><i class="fa fa-fw fa-plus"></i> New File</a></li> \ <li data-action="file"><a href="/server/'+ Pterodactyl.server.uuidShort +'/files/add/?dir=' + $('<div>').text(newFilePath).html() + '" class="text-muted"><i class="fa fa-fw fa-plus"></i> New File</a></li> \
<li data-action="folder"><a tabindex="-1" href="#"><i class="fa fa-fw fa-folder"></i> New Folder</a></li>'; <li data-action="folder"><a tabindex="-1" href="#"><i class="fa fa-fw fa-folder"></i> New Folder</a></li>';
} }

View file

@ -1,18 +0,0 @@
<html>
<head>
<title>Pterodactyl Dev</title>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" name="viewport">
</head>
<body>
<div id="pterodactyl">
<router-view></router-view>
<div class="w-full m-auto mt-0 container">
<p class="text-right text-neutral-600 text-xs">
</p>
</div>
</div>
</body>
</html>

View file

@ -1,30 +0,0 @@
import http from '@/api/http';
// @ts-ignore
import route from '../../../../../vendor/tightenco/ziggy/src/js/route';
import {AxiosError} from "axios";
import {ServerDatabase} from "@/api/server/types";
/**
* Creates a new database on the system for the currently active server.
*/
export function createDatabase(server: string, database: string, remote: string): Promise<ServerDatabase> {
return new Promise((resolve, reject) => {
http.post(route('api.client.servers.databases', {server}), {database, remote})
.then(response => {
const copy: any = response.data.attributes;
copy.password = copy.relationships.password.attributes.password;
copy.showPassword = false;
delete copy.relationships;
resolve(copy);
})
.catch((err: AxiosError) => {
if (err.response && err.response.data && Array.isArray(err.response.data.errors)) {
return reject(err.response.data.errors[0].detail);
}
return reject(err);
});
});
}

View file

@ -1,13 +0,0 @@
import http from "@/api/http";
/**
* Creates a copy of the given file or directory on the Daemon. Expects a fully resolved path
* to be passed through for both data arguments.
*/
export function copyFile(server: string, location: string): Promise<void> {
return new Promise((resolve, reject) => {
http.post(`/api/client/servers/${server}/files/copy`, {location})
.then(() => resolve())
.catch(reject);
});
}

View file

@ -1,14 +0,0 @@
import http from "@/api/http";
/**
* Connects to the remote daemon and creates a new folder on the server.
*/
export function createFolder(server: string, directory: string, name: string): Promise<void> {
return new Promise((resolve, reject) => {
http.post(`/api/client/servers/${server}/files/create-folder`, {
directory, name,
})
.then(() => resolve())
.catch(reject);
});
}

View file

@ -1,13 +0,0 @@
import http from "@/api/http";
/**
* Deletes files and/or folders from the server. You should pass through an array of
* file or folder paths to be deleted.
*/
export function deleteFile(server: string, location: string): Promise<void> {
return new Promise((resolve, reject) => {
http.post(`/api/client/servers/${server}/files/delete`, {location})
.then(() => resolve())
.catch(reject);
})
}

View file

@ -1,14 +0,0 @@
import http from "@/api/http";
// @ts-ignore
import route from '../../../../../../vendor/tightenco/ziggy/src/js/route';
/**
* Gets a download token for a file on the server.
*/
export function getDownloadToken(server: string, file: string): Promise<string | null> {
return new Promise((resolve, reject) => {
http.post(route('api.client.servers.files.download', { server, file }))
.then(response => resolve(response.data ? response.data.token || null : null))
.catch(reject);
});
}

View file

@ -1,20 +0,0 @@
import http from "@/api/http";
import {AxiosError} from "axios";
export default (server: string, file: string): Promise<string> => {
return new Promise((resolve, reject) => {
http.get(`/api/client/servers/${server}/files/contents`, {
params: { file },
responseType: 'text',
transformResponse: res => res,
})
.then(response => resolve(response.data || ''))
.catch((error: AxiosError) => {
if (error.response && error.response.data) {
error.response.data = JSON.parse(error.response.data);
}
reject(error);
});
});
}

View file

@ -1,12 +0,0 @@
import http from "@/api/http";
export function renameFile(server: string, renameFrom: string, renameTo: string): Promise<void> {
return new Promise((resolve, reject) => {
http.put(`/api/client/servers/${server}/files/rename`, {
rename_from: renameFrom,
rename_to: renameTo,
})
.then(() => resolve())
.catch(reject);
});
}

View file

@ -1,14 +0,0 @@
import http from "@/api/http";
export default (server: string, file: string, content: string): Promise<void> => {
return new Promise((resolve, reject) => {
http.post(`/api/client/servers/${server}/files/write`, content, {
params: { file },
headers: {
'Content-Type': 'text/plain; charset=utf-8',
},
})
.then(() => resolve())
.catch(reject);
});
}

View file

@ -1,40 +0,0 @@
import http from '../http';
import {filter, isObject} from 'lodash';
import {DirectoryContentObject, DirectoryContents} from "./types";
/**
* Get the contents of a specific directory for a given server.
*/
export function getDirectoryContents(server: string, directory: string): Promise<DirectoryContents> {
return new Promise((resolve, reject) => {
http.get(`/api/client/servers/${server}/files/list`, {
params: {directory}
})
.then((response) => {
return resolve({
files: filter(response.data.contents, function (o: DirectoryContentObject) {
return o.file;
}),
directories: filter(response.data.contents, function (o: DirectoryContentObject) {
return o.directory;
}),
editable: response.data.editable,
});
})
.catch(err => {
if (err.response && err.response.status === 404) {
return reject('The directory you requested could not be located on the server');
}
if (err.response.data && isObject(err.response.data.errors)) {
err.response.data.errors.forEach((error: any) => {
return reject(error.detail);
});
}
return reject(err);
});
});
}
export default getDirectoryContents;

View file

@ -1,30 +0,0 @@
export type DirectoryContents = {
files: Array<DirectoryContentObject>,
directories: Array<DirectoryContentObject>,
editable: Array<string>
}
export type DirectoryContentObject = {
name: string,
created: string,
modified: string,
mode: string,
size: number,
directory: boolean,
file: boolean,
symlink: boolean,
mime: string,
}
export type ServerDatabase = {
id: string,
name: string,
connections_from: string,
username: string,
host: {
address: string,
port: number,
},
password: string,
showPassword: boolean,
}

View file

@ -1,41 +0,0 @@
import Vue from 'vue';
import Vuex from 'vuex';
import VueI18n from 'vue-i18n';
import VueRouter from 'vue-router';
import VeeValidate from 'vee-validate';
// Helpers
// @ts-ignore
import {Ziggy} from './helpers/ziggy';
// @ts-ignore
import Locales from './../../../resources/lang/locales';
import {FlashMixin} from './mixins/flash';
import store from './store/index';
import router from './router';
Vue.config.productionTip = false;
require('./bootstrap');
window.events = new Vue();
window.Ziggy = Ziggy;
Vue.use(Vuex);
Vue.use(VueRouter);
Vue.use(VeeValidate);
Vue.use(VueI18n);
const route = require('./../../../vendor/tightenco/ziggy/src/js/route').default;
Vue.mixin({methods: {route}});
Vue.mixin(FlashMixin);
const i18n = new VueI18n({
locale: 'en',
messages: {...Locales},
});
if (module.hot) {
module.hot.accept();
}
new Vue({store, router, i18n}).$mount('#pterodactyl');

View file

@ -1,33 +0,0 @@
import axios from './api/http';
window._ = require('lodash');
/**
* We'll load jQuery and the Bootstrap jQuery plugin which provides support
* for JavaScript based Bootstrap features such as modals and tabs. This
* code may be modified to fit the specific needs of your application.
*/
try {
window.$ = window.jQuery = require('jquery');
} catch (e) {
}
window.axios = axios;
/**
* Next we will register the CSRF Token as a common header with Axios so that
* all outgoing HTTP requests automatically have it attached. This is just
* a simple convenience so we don't have to attach every token manually.
*/
let token = document.head.querySelector('meta[name="csrf-token"]');
if (token) {
// @ts-ignore
window.axios.defaults.headers.common['X-CSRF-TOKEN'] = token.content;
// @ts-ignore
window.X_CSRF_TOKEN = token.content;
} else {
console.error('CSRF token not found in document.');
}

View file

@ -1,103 +0,0 @@
<template>
<div v-if="notifications.length > 0" :class="this.container">
<transition-group tag="div" name="fade">
<div v-for="(item, index) in notifications" :key="item.title">
<MessageBox
:class="[item.class, {'mb-2': index < notifications.length - 1}]"
:title="item.title"
:message="item.message"
/>
</div>
</transition-group>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import MessageBox from './MessageBox.vue';
type DataStructure = {
notifications: Array<{
message: string,
severity: string,
title: string,
class: string,
}>,
}
export default Vue.extend({
name: 'Flash',
components: {
MessageBox
},
props: {
container: {type: String, default: ''},
timeout: {type: Number, default: 0},
types: {
type: Object,
default: function () {
return {
base: 'alert',
success: 'alert success',
info: 'alert info',
warning: 'alert warning',
error: 'alert error',
}
}
}
},
data: function (): DataStructure {
return {
notifications: [],
};
},
/**
* Listen for flash events.
*/
created: function () {
const self = this;
window.events.$on('flash', function (data: any) {
self.flash(data.message, data.title, data.severity);
});
window.events.$on('clear-flashes', function () {
self.clear();
});
},
methods: {
/**
* Flash a message to the screen when a flash event is emitted over
* the global event stream.
*/
flash: function (message: string, title: string, severity: string) {
this.notifications.push({
message, severity, title, class: this.$props.types[severity] || this.$props.types.base,
});
if (this.$props.timeout > 0) {
setTimeout(this.hide, this.$props.timeout);
}
},
/**
* Clear all of the flash messages from the screen.
*/
clear: function () {
this.notifications = [];
window.events.$emit('flashes-cleared');
},
/**
* Hide a notification after a given amount of time.
*/
hide: function (item?: number) {
// @ts-ignore
let key = this.notifications.indexOf(item || this.notifications[0]);
this.notifications.splice(key, 1);
},
},
});
</script>

View file

@ -1,18 +0,0 @@
<template>
<div class="lg:inline-flex" role="alert">
<span class="title" v-html="title" v-if="title && title.length > 0"></span>
<span class="message" v-html="message"></span>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
name: 'MessageBox',
props: {
title: {type: String, required: false},
message: {type: String, required: true}
},
});
</script>

View file

@ -1,98 +0,0 @@
<template>
<form class="login-box" method="post" v-on:submit.prevent="submitForm">
<div class="flex flex-wrap -mx-3 mb-6">
<div class="input-open">
<input class="input open-label" id="grid-email" type="email" aria-labelledby="grid-email-label" required
ref="email"
v-bind:class="{ 'has-content': email.length > 0 }"
v-bind:readonly="showSpinner"
v-bind:value="email"
v-on:input="updateEmail($event)"
/>
<label for="grid-email" id="grid-email-label">{{ $t('strings.email') }}</label>
<p class="text-neutral-800 text-xs">{{ $t('auth.forgot_password.label_help') }}</p>
</div>
</div>
<div>
<button class="btn btn-primary btn-jumbo" type="submit" v-bind:disabled="submitDisabled">
<span class="spinner white" v-bind:class="{ hidden: ! showSpinner }">&nbsp;</span>
<span v-bind:class="{ hidden: showSpinner }">
{{ $t('auth.forgot_password.button') }}
</span>
</button>
</div>
<div class="pt-6 text-center">
<router-link class="text-xs text-neutral-500 tracking-wide no-underline uppercase hover:text-neutral-600"
aria-label="Go to login"
:to="{ name: 'login' }"
>
{{ $t('auth.go_to_login') }}
</router-link>
</div>
</form>
</template>
<script lang="ts">
import Vue from 'vue';
import {isObject} from 'lodash';
import {AxiosError, AxiosResponse} from "axios";
export default Vue.extend({
name: 'ForgotPassword',
mounted: function () {
if (this.$refs.email) {
(this.$refs.email as HTMLElement).focus();
}
},
data: function () {
return {
X_CSRF_TOKEN: window.X_CSRF_TOKEN,
submitDisabled: false,
showSpinner: false,
email: '',
};
},
methods: {
updateEmail: function (event: { target: HTMLInputElement }) {
this.submitDisabled = false;
this.$emit('update-email', event.target.value);
},
submitForm: function () {
this.submitDisabled = true;
this.showSpinner = true;
this.$flash.clear();
window.axios.post(this.route('auth.forgot-password'), {
email: this.email,
})
.then((response: AxiosResponse) => {
if (!(response.data instanceof Object)) {
throw new Error('An error was encountered while processing this request.');
}
this.submitDisabled = false;
this.showSpinner = false;
this.$flash.success(response.data.status);
this.$router.push({name: 'login'});
})
.catch((err: AxiosError) => {
this.showSpinner = false;
if (!err.response) {
return console.error(err);
}
const response = err.response;
if (response.data && isObject(response.data.errors)) {
response.data.errors.forEach((error: any) => {
this.$flash.error(error.detail);
});
}
});
}
},
});
</script>

View file

@ -1,18 +0,0 @@
<template>
<div>
<Flash container="mb-2"/>
<div>
<router-view/>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import Flash from "../Flash.vue";
export default Vue.extend({
name: 'Login',
components: {Flash},
});
</script>

View file

@ -1,104 +0,0 @@
<template>
<form class="login-box" method="post"
v-on:submit.prevent="submitForm"
>
<div class="flex flex-wrap -mx-3 mb-6">
<div class="input-open">
<input class="input open-label" id="grid-username" type="text" name="user" aria-labelledby="grid-username-label" required
ref="email"
:class="{ 'has-content' : user.email.length > 0 }"
:readonly="showSpinner"
v-model="user.email"
/>
<label id="grid-username-label" for="grid-username">{{ $t('strings.user_identifier') }}</label>
</div>
</div>
<div class="flex flex-wrap -mx-3 mb-6">
<div class="input-open">
<input class="input open-label" id="grid-password" type="password" name="password" aria-labelledby="grid-password-label" required
ref="password"
:class="{ 'has-content' : user.password && user.password.length > 0 }"
:readonly="showSpinner"
v-model="user.password"
/>
<label id="grid-password-label" for="grid-password">{{ $t('strings.password') }}</label>
</div>
</div>
<div>
<button id="grid-login-button" class="btn btn-primary btn-jumbo" type="submit" aria-label="Log in"
v-bind:disabled="showSpinner">
<span class="spinner white" v-bind:class="{ hidden: ! showSpinner }">&nbsp;</span>
<span v-bind:class="{ hidden: showSpinner }">
{{ $t('auth.sign_in') }}
</span>
</button>
</div>
<div class="pt-6 text-center">
<router-link class="text-xs text-neutral-500 tracking-wide no-underline uppercase hover:text-neutral-600" aria-label="Forgot password"
:to="{ name: 'forgot-password' }">
{{ $t('auth.forgot_password.label') }}
</router-link>
</div>
</form>
</template>
<script lang="ts">
import Vue from 'vue';
import {isObject} from 'lodash';
export default Vue.extend({
name: 'LoginForm',
data: function () {
return {
showSpinner: false,
user: {
email: '',
password: '',
}
}
},
mounted: function () {
(this.$refs.email as HTMLElement).focus();
},
methods: {
// Handle a login request eminating from the form. If 2FA is required the
// user will be presented with the 2FA modal window.
submitForm: function () {
this.showSpinner = true;
this.$flash.clear();
this.$store.dispatch('auth/login', {user: this.user.email, password: this.user.password})
.then(response => {
if (response.complete) {
return window.location = response.intended;
}
this.user.password = '';
this.showSpinner = false;
this.$router.push({name: 'checkpoint', query: {token: response.token}});
})
.catch(err => {
this.user.password = '';
this.showSpinner = false;
(this.$refs.password as HTMLElement).focus();
this.$store.commit('auth/logout');
if (!err.response) {
this.$flash.error('There was an error with the network request. Please try again.');
return console.error(err);
}
const response = err.response;
if (response.data && isObject(response.data.errors)) {
response.data.errors.forEach((error: any) => {
this.$flash.error(error.detail);
});
}
});
},
},
});
</script>

View file

@ -1,128 +0,0 @@
<template>
<form class="bg-white shadow-lg rounded-lg pt-10 px-8 pb-6 mb-4 animate fadein" method="post"
v-on:submit.prevent="submitForm"
>
<div class="flex flex-wrap -mx-3 mb-6">
<div class="input-open">
<input class="input open-label" id="grid-email" type="email" aria-labelledby="grid-email" required
ref="email"
:class="{ 'has-content': email.length > 0 }"
:readonly="showSpinner"
v-on:input="updateEmailField"
/>
<label for="grid-email">{{ $t('strings.email') }}</label>
</div>
</div>
<div class="flex flex-wrap -mx-3 mb-6">
<div class="input-open">
<input class="input open-label" id="grid-password" type="password" aria-labelledby="grid-password" required
ref="password"
:class="{ 'has-content' : password.length > 0 }"
:readonly="showSpinner"
v-model="password"
/>
<label for="grid-password">{{ $t('strings.password') }}</label>
<p class="text-neutral-800 text-xs">{{ $t('auth.password_requirements') }}</p>
</div>
</div>
<div class="flex flex-wrap -mx-3 mb-6">
<div class="input-open">
<input class="input open-label" id="grid-password-confirmation" type="password" aria-labelledby="grid-password-confirmation" required
:class="{ 'has-content' : passwordConfirmation.length > 0 }"
:readonly="showSpinner"
v-model="passwordConfirmation"
/>
<label for="grid-password-confirmation">{{ $t('strings.confirm_password') }}</label>
</div>
</div>
<div>
<button class="btn btn-primary btn-jumbo" type="submit" v-bind:class="{ disabled: showSpinner }">
<span class="spinner white" v-bind:class="{ hidden: ! showSpinner }">&nbsp;</span>
<span v-bind:class="{ hidden: showSpinner }">
{{ $t('auth.reset_password.button') }}
</span>
</button>
</div>
<div class="pt-6 text-center">
<router-link class="text-xs text-neutral-500 tracking-wide no-underline uppercase hover:text-neutral-600"
:to="{ name: 'login' }"
>
{{ $t('auth.go_to_login') }}
</router-link>
</div>
</form>
</template>
<script lang="ts">
import Vue from 'vue';
import {isObject} from 'lodash';
import {AxiosError, AxiosResponse} from "axios";
export default Vue.component('reset-password', {
props: {
token: {type: String, required: true},
email: {type: String, required: false},
},
mounted: function () {
if (this.$props.email.length > 0) {
(this.$refs.email as HTMLElement).setAttribute('value', this.$props.email);
(this.$refs.password as HTMLElement).focus();
}
},
data: function () {
return {
errors: [],
showSpinner: false,
password: '',
passwordConfirmation: '',
submitDisabled: true,
};
},
methods: {
updateEmailField: function (event: { target: HTMLInputElement }) {
this.submitDisabled = event.target.value.length === 0;
},
submitForm: function () {
this.showSpinner = true;
this.$flash.clear();
window.axios.post(this.route('auth.reset-password'), {
email: this.$props.email,
password: this.password,
password_confirmation: this.passwordConfirmation,
token: this.$props.token,
})
.then((response: AxiosResponse) => {
if (!(response.data instanceof Object)) {
throw new Error('An error was encountered while processing this login.');
}
if (response.data.send_to_login) {
this.$flash.success('Your password has been reset, please login to continue.');
return this.$router.push({name: 'login'});
}
return window.location = response.data.redirect_to;
})
.catch((err: AxiosError) => {
this.showSpinner = false;
if (!err.response) {
return console.error(err);
}
const response = err.response;
if (response.data && isObject(response.data.errors)) {
response.data.errors.forEach((error: any) => {
this.$flash.error(error.detail);
});
(this.$refs.password as HTMLElement).focus();
}
});
}
},
});
</script>

View file

@ -1,87 +0,0 @@
<template>
<form class="login-box" method="post"
v-on:submit.prevent="submitToken"
>
<div class="flex flex-wrap -mx-3 mb-6">
<div class="input-open">
<input class="input open-label" id="grid-code" type="number" name="token" aria-labelledby="grid-username" required
ref="code"
:class="{ 'has-content' : code.length > 0 }"
v-model="code"
/>
<label for="grid-code">{{ $t('auth.two_factor.label') }}</label>
<p class="text-neutral-800 text-xs">{{ $t('auth.two_factor.label_help') }}</p>
</div>
</div>
<div>
<button class="btn btn-primary btn-jumbo" type="submit">
{{ $t('auth.sign_in') }}
</button>
</div>
<div class="pt-6 text-center">
<router-link class="text-xs text-neutral-500 tracking-wide no-underline uppercase hover:text-neutral-600"
:to="{ name: 'login' }"
>
Back to Login
</router-link>
</div>
</form>
</template>
<script lang="ts">
import Vue from 'vue';
import {AxiosError, AxiosResponse} from "axios";
import {isObject} from 'lodash';
export default Vue.extend({
name: 'TwoFactorForm',
data: function () {
return {
code: '',
};
},
mounted: function () {
if ((this.$route.query.token || '').length < 1) {
return this.$router.push({name: 'login'});
}
(this.$refs.code as HTMLElement).focus();
},
methods: {
submitToken: function () {
this.$flash.clear();
window.axios.post(this.route('auth.login-checkpoint'), {
confirmation_token: this.$route.query.token,
authentication_code: this.$data.code,
})
.then((response: AxiosResponse) => {
if (!(response.data instanceof Object)) {
throw new Error('An error was encountered while processing this login.');
}
localStorage.setItem('token', response.data.token);
this.$store.dispatch('login');
window.location = response.data.intended;
})
.catch((err: AxiosError) => {
this.$store.dispatch('logout');
if (!err.response) {
return console.error(err);
}
const response = err.response;
if (response.data && isObject(response.data.errors)) {
response.data.errors.forEach((error: any) => {
this.$flash.error(error.detail);
});
this.$router.push({name: 'login'});
}
});
}
},
});
</script>

View file

@ -1,18 +0,0 @@
<template>
<i :data-feather="name"></i>
</template>
<script lang="ts">
import Vue from 'vue';
import {replace} from 'feather-icons';
export default Vue.extend({
name: 'Icon',
props: {
name: {type: String, default: 'circle'},
},
mounted: function () {
replace();
},
});
</script>

View file

@ -1,54 +0,0 @@
<template>
<transition name="modal">
<div class="modal-mask" v-show="isVisible" v-on:click="closeOnBackground && close()">
<div class="modal-container top" :class="{ 'full-screen': isFullScreen }" @click.stop>
<div class="modal-close-icon" v-on:click="close" v-if="dismissable && showCloseIcon">
<Icon name="x" aria-label="Close modal" role="button"/>
</div>
<div class="modal-content p-8">
<slot/>
</div>
</div>
</div>
</transition>
</template>
<script lang="ts">
import Vue from 'vue';
import Icon from "./Icon.vue";
export default Vue.extend({
name: 'Modal',
components: {Icon},
props: {
modalName: {type: String, default: 'modal'},
isVisible: {type: Boolean, default: false},
closeOnEsc: {type: Boolean, default: true},
dismissable: {type: Boolean, default: true},
showCloseIcon: {type: Boolean, default: true},
isFullScreen: {type: Boolean, default: false},
closeOnBackground: {type: Boolean, default: true},
},
mounted: function () {
if (this.$props.closeOnEsc) {
document.addEventListener('keydown', e => {
if (this.isVisible && e.key === 'Escape') {
this.close();
}
})
}
},
methods: {
close: function () {
if (!this.$props.dismissable) {
return;
}
this.$emit('close', this.$props.modalName);
}
},
});
</script>

View file

@ -1,142 +0,0 @@
<template>
<div class="nav flex flex-grow">
<div class="flex flex-1 justify-center items-center container">
<div class="logo">
<router-link :to="{ name: 'dashboard' }">
Pterodactyl
</router-link>
</div>
<div class="menu flex-1">
<router-link :to="{ name: 'dashboard' }">
<Icon name="server" aria-label="Server dashboard" class="h-4 self-center"/>
</router-link>
<router-link :to="{ name: 'account' }">
<Icon name="user" aria-label="Profile management" class="h-4"/>
</router-link>
<a :href="this.route('admin.index')">
<Icon name="settings" aria-label="Administrative controls" class="h-4"/>
</a>
</div>
<div class="search-box flex-none" v-if="$route.name !== 'dashboard'" ref="searchContainer">
<input type="text" class="search-input" id="searchInput" placeholder="Search..."
:class="{ 'has-search-results': ((servers.length > 0 && searchTerm.length >= 3) || loadingResults) && searchActive }"
v-on:focus="searchActive = true"
v-on:input="search"
v-model="searchTerm"
/>
<div class="search-results select-none" :class="{ 'hidden': (servers.length === 0 && !loadingResults) || !searchActive || searchTerm.length < 3 }">
<div v-if="loadingResults">
<a href="#" class="no-hover cursor-default">
<div class="flex items-center">
<div class="flex-1">
<span class="text-sm text-neutral-500">Loading...</span>
</div>
<div class="flex-none">
<span class="spinner spinner-relative"></span>
</div>
</div>
</a>
</div>
<div v-else v-for="server in servers" :key="server.identifier">
<router-link :to="{ name: 'server', params: { id: server.identifier }}" v-on:click.native="searchActive = false">
<div class="flex items-center">
<div class="flex-1">
<span class="font-bold text-neutral-900">{{ server.name }}</span><br/>
<span class="text-neutral-600 text-sm" v-if="server.description.length > 0">{{ server.description }}</span>
</div>
<div class="flex-none">
<span class="pillbox bg-neutral-900">{{ server.node }}</span>
</div>
</div>
</router-link>
</div>
</div>
</div>
<div class="menu">
<a :href="this.route('auth.logout')" v-on:click.prevent="doLogout">
<Icon name="log-out" aria-label="Sign out" class="h-4"/>
</a>
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import {debounce, isObject} from 'lodash';
import {mapState} from 'vuex';
import {AxiosError} from "axios";
import Icon from "@/components/core/Icon.vue";
export default Vue.extend({
name: 'Navigation',
components: {Icon},
data: function () {
return {
loadingResults: false,
searchActive: false,
};
},
computed: {
...mapState('dashboard', ['servers']),
searchTerm: {
get: function (): string {
return this.$store.getters['dashboard/getSearchTerm'];
},
set: function (value: string): void {
this.$store.dispatch('dashboard/setSearchTerm', value);
}
}
},
created: function () {
document.addEventListener('click', this.documentClick);
},
beforeDestroy: function () {
document.removeEventListener('click', this.documentClick);
},
methods: {
search: debounce(function (this: any): void {
if (this.searchTerm.length >= 3) {
this.loadingResults = true;
this.gatherSearchResults();
}
}, 500),
gatherSearchResults: function (): void {
this.$store.dispatch('dashboard/loadServers')
.catch((err: AxiosError) => {
console.error(err);
const response = err.response;
if (response && isObject(response.data.errors)) {
response.data.errors.forEach((error: any) => {
this.$flash.error(error.detail);
});
}
})
.then(() => {
this.loadingResults = false;
});
},
doLogout: function () {
this.$store.commit('auth/logout');
window.location.assign(this.route('auth.logout'));
},
documentClick: function (e: Event) {
if (this.$refs.searchContainer) {
if (this.$refs.searchContainer !== e.target && !(this.$refs.searchContainer as HTMLElement).contains(e.target as HTMLElement)) {
this.searchActive = false;
}
}
},
},
})
</script>

View file

@ -1,24 +0,0 @@
<template>
<transition name="modal">
<div class="modal-mask" v-show="visible">
<div class="modal-container w-auto">
<div class="modal-content p-8 pb-0">
<div class="spinner spinner-thick spinner-relative blue spinner-xl"></div>
<p class="text-neutral-700 mt-8 text-sm">
<slot/>
</p>
</div>
</div>
</div>
</transition>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: {
visible: { type: Boolean, default: false },
},
});
</script>

View file

@ -1,62 +0,0 @@
<template>
<div>
<Navigation/>
<div class="container animate fadein mt-2 sm:mt-6">
<Modal :isVisible="modalVisible" v-on:close="modalVisible = false">
<TwoFactorAuthentication v-on:close="modalVisible = false"/>
</Modal>
<Flash container="mt-2 sm:mt-6 mb-2"/>
<div class="flex flex-wrap">
<div class="w-full md:w-1/2">
<div class="sm:m-4 md:ml-0">
<UpdateEmail class="mb-4 sm:mb-8"/>
<div class="content-box text-center mb-4 sm:mb-0">
<button class="btn btn-green btn-sm" type="submit" id="grid-open-two-factor-modal"
v-on:click="openModal"
>Configure 2-Factor Authentication
</button>
</div>
</div>
</div>
<div class="w-full md:w-1/2">
<ChangePassword class="sm:m-4 md:mr-0"/>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import Navigation from "../core/Navigation.vue";
import Flash from "@/components/Flash.vue";
import UpdateEmail from "./account/UpdateEmail.vue";
import ChangePassword from "./account/ChangePassword.vue";
import TwoFactorAuthentication from "./account/TwoFactorAuthentication.vue";
import Modal from "../core/Modal.vue";
export default Vue.extend({
name: 'Account',
components: {
TwoFactorAuthentication,
Modal,
ChangePassword,
UpdateEmail,
Flash,
Navigation
},
data: function () {
return {
modalVisible: false,
};
},
methods: {
openModal: function () {
this.modalVisible = true;
window.events.$emit('two_factor:open');
},
},
});
</script>

View file

@ -1,128 +0,0 @@
<template>
<div>
<Navigation/>
<div class="container">
<Flash container="mt-4"/>
<div class="server-search animate fadein">
<input type="text"
:placeholder="$t('dashboard.index.search')"
@input="onChange"
v-model="searchTerm"
ref="search"
/>
</div>
<div v-if="this.loading" class="my-4 animate fadein">
<div class="text-center h-16 my-20">
<span class="spinner spinner-xl spinner-thick blue"></span>
</div>
</div>
<TransitionGroup class="flex flex-wrap justify-center sm:justify-start" tag="div" v-else>
<ServerBox
v-for="(server, index) in servers"
:key="index"
:server="server"
/>
</TransitionGroup>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import {debounce, isObject} from 'lodash';
import {mapState} from 'vuex';
import Flash from "./../Flash.vue";
import Navigation from "./../core/Navigation.vue";
import {AxiosError} from "axios";
import ServerBox from "./ServerBox.vue";
type DataStructure = {
backgroundedAt: Date,
documentVisible: boolean,
loading: boolean,
servers?: Array<any>,
searchTerm?: string,
}
export default Vue.extend({
name: 'Dashboard',
components: {
ServerBox,
Navigation,
Flash
},
data: function (): DataStructure {
return {
backgroundedAt: new Date(),
documentVisible: true,
loading: false,
}
},
/**
* Start loading the servers before the DOM $.el is created. If we already have servers
* stored in vuex shows those and don't fire another API call just to load them again.
*/
created: function () {
if (!this.servers || this.servers.length === 0) {
this.loadServers();
}
},
/**
* Once the page is mounted set a function to run every 10 seconds that will
* iterate through the visible servers and fetch their resource usage.
*/
mounted: function () {
(this.$refs.search as HTMLElement).focus();
},
computed: {
...mapState('dashboard', ['servers']),
searchTerm: {
get: function (): string {
return this.$store.getters['dashboard/getSearchTerm'];
},
set: function (value: string): void {
this.$store.dispatch('dashboard/setSearchTerm', value);
},
},
},
methods: {
/**
* Load the user's servers and render them onto the dashboard.
*/
loadServers: function () {
this.loading = true;
this.$flash.clear();
this.$store.dispatch('dashboard/loadServers')
.then(() => {
if (!this.servers || this.servers.length === 0) {
this.$flash.info(this.$t('dashboard.index.no_matches'));
}
})
.catch((err: AxiosError) => {
console.error(err);
const response = err.response;
if (response && isObject(response.data.errors)) {
response.data.errors.forEach((error: any) => {
this.$flash.error(error.detail);
});
}
})
.then(() => this.loading = false);
},
/**
* Handle a search for servers but only call the search function every 500ms
* at the fastest.
*/
onChange: debounce(function (this: any): void {
this.loadServers();
}, 500),
},
});
</script>

View file

@ -1,200 +0,0 @@
<template>
<div class="server-card-container animated-fade-in">
<div>
<div class="server-card">
<router-link :to="link" class="block">
<h2 class="text-xl flex flex-row items-center mb-2">
<div class="identifier-icon select-none" :class="{
'bg-neutral-400': status === '',
'bg-red-500': status === 'offline',
'bg-green-500': status === 'online'
}">
{{ server.name[0] }}
</div>
{{ server.name }}
</h2>
</router-link>
<div class="flex-1 py-3">
<p v-if="server.description.length" class="text-neutral-500 text-sm">{{ server.description }}</p>
</div>
<div class="flex flex-none pt-2">
<div class="flex-1">
<span class="font-semibold text-cyan-800">{{ server.node }}</span>
</div>
<div>
<span class="text-neutral-300">{{ server.allocation.ip }}:{{ server.allocation.port }}</span>
</div>
</div>
</div>
<div class="footer p-4 text-sm">
<div class="inline-block pr-2">
<div class="pillbox bg-neutral-700"><span class="select-none">MEM:</span> {{ memory }} Mb</div>
</div>
<div class="inline-block">
<div class="pillbox bg-neutral-700"><span class="select-none">CPU:</span> {{ cpu }} %</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import {get} from 'lodash';
import {differenceInSeconds} from 'date-fns';
import {AxiosError, AxiosResponse} from "axios";
type DataStructure = {
backgroundedAt: Date,
documentVisible: boolean,
resources: null | { [s: string]: any },
cpu: number,
memory: number,
status: string,
link: { name: string, params: { id: string } },
dataGetTimeout: undefined | number,
}
export default Vue.extend({
name: 'ServerBox',
props: {
server: {type: Object, required: true},
},
data: function (): DataStructure {
return {
backgroundedAt: new Date(),
documentVisible: true,
resources: null,
cpu: 0,
memory: 0,
status: '',
link: {name: 'server', params: {id: this.server.identifier}},
dataGetTimeout: undefined,
};
},
watch: {
/**
* Watch the documentVisible item and perform actions when it is changed. If it becomes
* true, we want to check how long ago the last poll was, if it was more than 30 seconds
* we want to immediately trigger the resourceUse api call, otherwise we just want to restart
* the time.
*
* If it is now false, we want to clear the timer that checks resource use, since we know
* we won't be doing anything with them anyways. Might as well avoid extraneous resource
* usage by the browser.
*/
documentVisible: function (value) {
if (!value) {
window.clearTimeout(this.dataGetTimeout);
return;
}
if (differenceInSeconds(new Date(), this.backgroundedAt) >= 30) {
this.getResourceUse();
}
this.dataGetTimeout = window.setInterval(() => {
this.getResourceUse();
}, 10000);
},
},
/**
* Grab the initial resource usage for this specific server instance and add a listener
* to monitor when this window is no longer visible. We don't want to needlessly poll the
* API when we aren't looking at the page.
*/
created: function () {
this.getResourceUse();
document.addEventListener('visibilitychange', this._visibilityChange.bind(this));
},
/**
* Poll the API for changes every 10 seconds when the component is mounted.
*/
mounted: function () {
this.dataGetTimeout = window.setInterval(() => {
this.getResourceUse();
}, 10000);
},
/**
* Clear the timer and event listeners when we destroy the component.
*/
beforeDestroy: function () {
window.clearInterval(this.dataGetTimeout);
document.removeEventListener('visibilitychange', this._visibilityChange.bind(this), false);
},
methods: {
/**
* Query the resource API to determine what this server's state and resource usage is.
*/
getResourceUse: function () {
window.axios.get(this.route('api.client.servers.resources', {server: this.server.identifier}))
.then((response: AxiosResponse) => {
if (!(response.data instanceof Object)) {
throw new Error('Received an invalid response object back from status endpoint.');
}
this.resources = response.data.attributes;
this.status = this.getServerStatus();
this.memory = parseInt(parseFloat(get(this.resources, 'memory.current', '0')).toFixed(0));
this.cpu = this._calculateCpu(
parseFloat(get(this.resources, 'cpu.current', '0')),
parseFloat(this.server.limits.cpu)
);
})
.catch((err: AxiosError) => console.warn('Error fetching server resource usage', {...err}));
},
/**
* Set the CSS to use for displaying the server's current status.
*/
getServerStatus: function () {
if (!this.resources || !this.resources.installed || this.resources.suspended) {
return '';
}
switch (this.resources.state) {
case 'off':
return 'offline';
case 'on':
case 'starting':
case 'stopping':
return 'online';
default:
return '';
}
},
/**
* Calculate the CPU usage for a given server relative to their set maximum.
*
* @private
*/
_calculateCpu: function (current: number, max: number) {
if (max === 0) {
return parseFloat(current.toFixed(1));
}
return parseFloat((current / max * 100).toFixed(1));
},
/**
* Handle document visibility changes.
*
* @private
*/
_visibilityChange: function () {
this.documentVisible = document.visibilityState === 'visible';
if (!this.documentVisible) {
this.backgroundedAt = new Date();
}
},
},
});
</script>

View file

@ -1,94 +0,0 @@
<template>
<div id="change-password-container" :class>
<form method="post" v-on:submit.prevent="submitForm">
<div class="content-box">
<h2 class="mb-6 text-neutral-900 font-medium">{{ $t('dashboard.account.password.title') }}</h2>
<div class="mt-6">
<label for="grid-password-current" class="input-label">{{ $t('strings.password') }}</label>
<input id="grid-password-current" name="current_password" type="password" class="input" required
ref="current"
v-model="current"
>
</div>
<div class="mt-6">
<label for="grid-password-new" class="input-label">{{ $t('strings.new_password') }}</label>
<input id="grid-password-new" name="password" type="password" class="input" required
:class="{ error: errors.has('password') }"
v-model="newPassword"
v-validate="'min:8'"
>
<p class="input-help error" v-show="errors.has('password')">{{ errors.first('password') }}</p>
<p class="input-help">{{ $t('dashboard.account.password.requirements') }}</p>
</div>
<div class="mt-6">
<label for="grid-password-new-confirm" class="input-label">{{ $t('strings.confirm_password') }}</label>
<input id="grid-password-new-confirm" name="password_confirmation" type="password" class="input" required
:class="{ error: errors.has('password_confirmation') }"
v-model="confirmNew"
v-validate="{is: newPassword}"
data-vv-as="password"
>
<p class="input-help error" v-show="errors.has('password_confirmation')">{{ errors.first('password_confirmation') }}</p>
</div>
<div class="mt-6 text-right">
<button class="btn btn-primary btn-sm text-right" type="submit">{{ $t('strings.save') }}</button>
</div>
</div>
</form>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import {isObject} from 'lodash';
import {AxiosError} from "axios";
export default Vue.extend({
name: 'ChangePassword',
data: function () {
return {
current: '',
newPassword: '',
confirmNew: '',
};
},
methods: {
submitForm: function () {
this.$flash.clear();
this.$validator.pause();
window.axios.put(this.route('api.client.account.update-password'), {
current_password: this.current,
password: this.newPassword,
password_confirmation: this.confirmNew,
})
.then(() => this.current = '')
.then(() => {
this.newPassword = '';
this.confirmNew = '';
this.$flash.success(this.$t('dashboard.account.password.updated'));
})
.catch((err: AxiosError) => {
if (!err.response) {
this.$flash.error('There was an error with the network request. Please try again.');
console.error(err);
return;
}
const response = err.response;
if (response.data && isObject(response.data.errors)) {
response.data.errors.forEach((error: any) => {
this.$flash.error(error.detail);
});
}
})
.then(() => {
this.$validator.resume();
(this.$refs.current as HTMLElement).focus();
})
}
},
});
</script>

View file

@ -1,193 +0,0 @@
<template>
<div id="configure-two-factor">
<div class="h-16 text-center" v-show="spinner">
<span class="spinner spinner-xl text-primary-500"></span>
</div>
<div id="container-disable-two-factor" v-if="response.enabled" v-show="!spinner">
<h2 class="font-medium text-neutral-900">{{ $t('dashboard.account.two_factor.disable.title') }}</h2>
<div class="mt-6">
<label class="input-label" for="grid-two-factor-token-disable">{{ $t('dashboard.account.two_factor.disable.field') }}</label>
<input id="grid-two-factor-token-disable" type="number" class="input"
name="token"
v-model="token"
ref="token"
v-validate="'length:6'"
:class="{ error: errors.has('token') }"
>
<p class="input-help error" v-show="errors.has('token')">{{ errors.first('token') }}</p>
</div>
<div class="mt-6 w-full text-right">
<button class="btn btn-sm btn-secondary mr-4" v-on:click="$emit('close')">
Cancel
</button>
<button class="btn btn-sm btn-red" type="submit"
:disabled="submitDisabled"
v-on:click.prevent="disableTwoFactor"
>{{ $t('strings.disable') }}
</button>
</div>
</div>
<div id="container-enable-two-factor" v-else v-show="!spinner">
<h2 class="font-medium text-neutral-900">{{ $t('dashboard.account.two_factor.setup.title') }}</h2>
<div class="flex mt-6">
<div class="flex-none w-full sm:w-1/2 text-center">
<div class="h-48">
<img :src="response.qr_image" id="grid-qr-code" alt="Two-factor qr image" class="h-48">
</div>
<div>
<p class="text-xs text-neutral-800 mb-2">{{ $t('dashboard.account.two_factor.setup.help') }}</p>
<p class="text-xs"><code class="clean">{{response.secret}}</code></p>
</div>
</div>
<div class="flex-none w-full sm:w-1/2">
<div>
<label class="input-label" for="grid-two-factor-token">{{ $t('dashboard.account.two_factor.setup.field') }}</label>
<input id="grid-two-factor-token" type="number" class="input"
name="token"
v-model="token"
ref="token"
v-validate="'length:6'"
:class="{ error: errors.has('token') }"
>
<p class="input-help error" v-show="errors.has('token')">{{ errors.first('token') }}</p>
</div>
<div class="mt-6">
<button class="btn btn-primary btn-jumbo" type="submit"
:disabled="submitDisabled"
v-on:click.prevent="enableTwoFactor"
>{{ $t('strings.enable') }}
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import {isObject} from 'lodash';
import {AxiosError, AxiosResponse} from "axios";
export default Vue.extend({
name: 'TwoFactorAuthentication',
data: function () {
return {
spinner: true,
token: '',
submitDisabled: true,
response: {
enabled: false,
qr_image: '',
secret: '',
},
};
},
/**
* Before the component is mounted setup the event listener. This event is fired when a user
* presses the 'Configure 2-Factor' button on their account page. Once this happens we fire off
* a HTTP request to get their information.
*/
mounted: function () {
window.events.$on('two_factor:open', () => {
this.prepareModalContent();
});
},
watch: {
token: function (value) {
this.submitDisabled = value.length !== 6;
},
},
methods: {
/**
* Determine the correct content to show in the modal.
*/
prepareModalContent: function () {
// Reset the data object when the modal is opened again.
// @ts-ignore
Object.assign(this.$data, this.$options.data());
this.$flash.clear();
window.axios.get(this.route('account.two_factor'))
.then((response: AxiosResponse) => {
this.response = response.data;
this.spinner = false;
Vue.nextTick().then(() => {
(this.$refs.token as HTMLElement).focus();
})
})
.catch((err: AxiosError) => {
if (!err.response) {
this.$flash.error(err.message);
console.error(err);
return;
}
const response = err.response;
if (response.data && isObject(response.data.errors)) {
response.data.errors.forEach((error: any) => {
this.$flash.error(error.detail);
});
}
this.$emit('close');
});
},
/**
* Enable two-factor authentication on the account by validating the token provided by the user.
* Close the modal once the request completes so that the success or error message can be shown
* to the user.
*/
enableTwoFactor: function () {
return this._callInternalApi('account.two_factor.enable', 'enabled');
},
/**
* Disables two-factor authentication for the client account and closes the modal.
*/
disableTwoFactor: function () {
return this._callInternalApi('account.two_factor.disable', 'disabled');
},
/**
* Call the Panel API endpoint and handle errors.
*
* @private
*/
_callInternalApi: function (route: string, langKey: string) {
this.$flash.clear();
this.spinner = true;
window.axios.post(this.route(route), {token: this.token})
.then((response: AxiosResponse) => {
if (response.data.success) {
this.$flash.success(this.$t(`dashboard.account.two_factor.${langKey}`));
} else {
this.$flash.error(this.$t('dashboard.account.two_factor.invalid'));
}
})
.catch((error: AxiosError) => {
if (!error.response) {
this.$flash.error(error.message);
return;
}
const response = error.response;
if (response.data && isObject(response.data.errors)) {
response.data.errors.forEach((e: any) => {
this.$flash.error(e.detail);
});
}
})
.then(() => {
this.spinner = false;
this.$emit('close');
});
}
},
})
</script>

View file

@ -1,80 +0,0 @@
<template>
<div id="update-email-container" :class>
<form method="post" v-on:submit.prevent="submitForm">
<div class="content-box">
<h2 class="mb-6 text-neutral-900 font-medium">{{ $t('dashboard.account.email.title') }}</h2>
<div>
<label for="grid-email" class="input-label">{{ $t('strings.email_address') }}</label>
<input id="grid-email" name="email" type="email" class="input" required
:class="{ error: errors.has('email') }"
v-validate
v-model="email"
>
<p class="input-help error" v-show="errors.has('email')">{{ errors.first('email') }}</p>
</div>
<div class="mt-6">
<label for="grid-password" class="input-label">{{ $t('strings.password') }}</label>
<input id="grid-password" name="password" type="password" class="input" required
v-model="password"
>
</div>
<div class="mt-6 text-right">
<button class="btn btn-primary btn-sm text-right" type="submit">{{ $t('strings.save') }}</button>
</div>
</div>
</form>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import {get, isObject} from 'lodash';
import {mapState} from 'vuex';
import {ApplicationState} from "@/store/types";
import {AxiosError} from "axios";
export default Vue.extend({
name: 'UpdateEmail',
data: function () {
return {
email: get(this.$store.state, 'auth.user.email', ''),
password: '',
};
},
computed: {
...mapState({
user: (state: ApplicationState) => state.auth.user,
})
},
methods: {
/**
* Update a user's email address on the Panel.
*/
submitForm: function () {
this.$flash.clear();
this.$store.dispatch('auth/updateEmail', {email: this.email, password: this.password})
.then(() => {
this.$flash.success(this.$t('dashboard.account.email.updated'));
})
.catch((error: AxiosError) => {
if (!error.response) {
this.$flash.error(error.message);
return;
}
const response = error.response;
if (response.data && isObject(response.data.errors)) {
response.data.errors.forEach((e: any) => {
this.$flash.error(e.detail);
});
}
})
.then(() => {
this.password = '';
});
},
},
});
</script>

View file

@ -1,16 +0,0 @@
<template>
<input type="hidden" name="_token" v-bind:value="X_CSRF_TOKEN"/>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
name: 'CSRF',
data: function () {
return {
X_CSRF_TOKEN: window.X_CSRF_TOKEN,
};
},
});
</script>

View file

@ -1,121 +0,0 @@
<template>
<div>
<Navigation/>
<Flash class="m-6"/>
<div v-if="loadingServerData" class="container">
<div class="mt-6 h-16">
<div class="spinner spinner-xl spinner-thick blue"></div>
</div>
</div>
<div v-else class="container">
<div class="my-6 flex flex-no-shrink rounded animate fadein">
<div class="sidebar flex-no-shrink w-1/3 max-w-xs">
<div class="mr-6">
<div class="p-6 text-center bg-white rounded shadow">
<h3 class="mb-2 text-primary-500 font-medium">{{server.name}}</h3>
<span class="text-neutral-600 text-sm">{{server.node}}</span>
<PowerButtons class="mt-6 pt-6 text-center border-t border-neutral-100"/>
</div>
</div>
<div class="sidenav mt-6 mr-6">
<ul>
<li>
<router-link :to="{ name: 'server', params: { id: $route.params.id } }">
Console
</router-link>
</li>
<li>
<router-link :to="{ name: 'server-files' }">
File Manager
</router-link>
</li>
<li>
<router-link :to="{ name: 'server-databases' }">
Databases
</router-link>
</li>
</ul>
</div>
</div>
<div class="h-full w-full">
<router-view :key="server.identifier"></router-view>
</div>
</div>
</div>
<div class="fixed pin-r pin-b m-6 max-w-sm" v-show="connectionError">
<div class="alert error">
There was an error while attempting to connect to the Daemon websocket. Error: {{connectionError}}
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import Navigation from '@/components/core/Navigation.vue';
import {mapState} from 'vuex';
import {Socketio} from "@/mixins/socketio";
import PowerButtons from "@/components/server/components/PowerButtons.vue";
import Flash from "@/components/Flash.vue";
export default Vue.extend({
name: 'Server',
components: {Flash, PowerButtons, Navigation},
computed: {
...mapState('server', ['server', 'credentials']),
...mapState('socket', ['connected', 'connectionError']),
},
mixins: [Socketio],
// Watch for route changes that occur with different server parameters. This occurs when a user
// uses the search bar. Because of the way vue-router works, it won't re-mount the server component
// so we will end up seeing the wrong server data if we don't perform this watch.
watch: {
'$route': function (toRoute, fromRoute) {
if (toRoute.params.id !== fromRoute.params.id) {
this.loadingServerData = true;
this.loadServer();
}
}
},
data: function () {
return {
loadingServerData: true,
};
},
mounted: function () {
this.loadServer();
},
beforeDestroy: function () {
this.removeSocket();
},
methods: {
/**
* Load the core server information needed for these pages to be functional.
*/
loadServer: function () {
Promise.all([
this.$store.dispatch('server/getServer', {server: this.$route.params.id}),
this.$store.dispatch('server/getCredentials', {server: this.$route.params.id})
])
.then(() => {
// Configure the websocket implementation and assign it to the mixin.
this.$socket().connect(
`ws://192.168.50.3:8080/api/servers/${this.server.uuid}/ws`,
'CC8kHCuMkXPosgzGO6d37wvhNcksWxG6kTrA',
);
this.loadingServerData = false;
})
.catch(err => {
console.error('There was an error performing Server::loadServer', {err});
});
},
},
});
</script>

View file

@ -1,52 +0,0 @@
<template>
<div>
<div v-if="connected">
<transition name="slide-fade" mode="out-in">
<button class="btn btn-green uppercase text-xs px-4 py-2"
v-if="status === 'offline'"
v-on:click.prevent="sendPowerAction('start')"
>Start
</button>
<div v-else>
<button class="btn btn-red uppercase text-xs px-4 py-2" v-on:click.prevent="sendPowerAction('stop')">Stop</button>
<button class="btn btn-secondary uppercase text-xs px-4 py-2" v-on:click.prevent="sendPowerAction('restart')">Restart</button>
<button class="btn btn-secondary btn-red uppercase text-xs px-4 py-2" v-on:click.prevent="sendPowerAction('kill')">Kill</button>
</div>
</transition>
</div>
<div v-else>
<div class="text-center">
<div class="spinner"></div>
<div class="pt-2 text-xs text-neutral-400">Connecting to node</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import {mapState} from 'vuex';
import Status from '../../../helpers/statuses';
import {Socketio} from "@/mixins/socketio";
export default Vue.extend({
name: 'PowerButtons',
computed: {
...mapState('socket', ['connected', 'status']),
},
mixins: [Socketio],
data: function () {
return {
statuses: Status,
};
},
methods: {
sendPowerAction: function (action: string) {
this.$socket().emit('set state', action)
},
},
});
</script>

View file

@ -1,105 +0,0 @@
<template>
<Modal :isVisible="isVisible" :dismissable="!showSpinner" v-on:close="closeModal">
<MessageBox class="alert error mb-6" :message="errorMessage" v-show="errorMessage.length"/>
<h2 class="font-medium text-neutral-900 mb-6">Create a new database</h2>
<div class="mb-6">
<label class="input-label" for="grid-database-name">Database name</label>
<input
id="grid-database-name" type="text" class="input" name="database_name" required
v-model="database"
v-validate="{ alpha_dash: true, max: 100 }"
:class="{ error: errors.has('database_name') }"
>
<p class="input-help error" v-show="errors.has('database_name')">{{ errors.first('database_name') }}</p>
</div>
<div class="mb-6">
<label class="input-label" for="grid-database-remote">Allow connections from</label>
<input
id="grid-database-remote" type="text" class="input" name="remote" required
v-model="remote"
v-validate="{ regex: /^[0-9%.]{1,15}$/ }"
:class="{ error: errors.has('remote') }"
>
<p class="input-help error" v-show="errors.has('remote')">{{ errors.first('remote') }}</p>
</div>
<div class="text-right">
<button class="btn btn-secondary btn-sm mr-2" v-on:click.once="closeModal">Cancel</button>
<button
class="btn btn-primary btn-sm"
:disabled="errors.any() || !canSubmit || showSpinner"
v-on:click="submit"
>
<span class="spinner white" v-bind:class="{ hidden: !showSpinner }">&nbsp;</span>
<span :class="{ hidden: showSpinner }">
Create
</span>
</button>
</div>
</Modal>
</template>
<script lang="ts">
import Vue from 'vue';
import MessageBox from "@/components/MessageBox.vue";
import {createDatabase} from "@/api/server/createDatabase";
import Modal from "@/components/core/Modal.vue";
export default Vue.extend({
name: 'CreateDatabaseModal',
components: {Modal, MessageBox},
props: {
isVisible: { type: Boolean, default: false },
},
data: function () {
return {
loading: false,
showSpinner: false,
database: '',
remote: '%',
errorMessage: '',
};
},
computed: {
canSubmit: function () {
return this.database.length && this.remote.length;
},
},
methods: {
submit: function () {
this.showSpinner = true;
this.errorMessage = '';
this.loading = true;
createDatabase(this.$route.params.id, this.database, this.remote)
.then((response) => {
this.$emit('database', response);
this.$emit('close');
})
.catch((err: Error | string): void => {
if (typeof err === 'string') {
this.errorMessage = err;
return;
}
console.error('A network error was encountered while processing this request.', {err});
})
.then(() => {
this.loading = false;
this.showSpinner = false;
});
},
/**
* Closes the modal and resets the entry field.
*/
closeModal: function () {
this.showSpinner = false;
this.$emit('close');
}
},
});
</script>

View file

@ -1,70 +0,0 @@
<template>
<div class="content-box mb-6 hover:border-neutral-200">
<div class="flex items-center text-neutral-800">
<Icon name="database" class="flex-none text-green-500"></icon>
<div class="flex-1 px-4">
<p class="uppercase text-xs text-neutral-500 pb-1 select-none">Database Name</p>
<p>{{database.name}}</p>
</div>
<div class="flex-1 px-4">
<p class="uppercase text-xs text-neutral-500 pb-1 select-none">Username</p>
<p>{{database.username}}</p>
</div>
<div class="flex-1 px-4">
<p class="uppercase text-xs text-neutral-500 pb-1 select-none">Password</p>
<p>
<code class="text-sm cursor-pointer" v-on:click="revealPassword">
<span class="select-none" v-if="!database.showPassword">
<Icon name="lock" class="h-3"/> &bull;&bull;&bull;&bull;&bull;&bull;
</span>
<span v-else>{{database.password}}</span>
</code>
</p>
</div>
<div class="flex-1 px-4">
<p class="uppercase text-xs text-neutral-500 pb-1 select-none">Server</p>
<p><code class="text-sm">{{database.host.address}}:{{database.host.port}}</code></p>
</div>
<div class="flex-none px-4">
<button class="btn btn-xs btn-secondary btn-red" v-on:click="showDeleteModal = true">
<Icon name="trash-2" class="w-3 h-3 mx-1"/>
</button>
</div>
</div>
<DeleteDatabaseModal
:database="database"
:isVisible="showDeleteModal"
v-on:close="showDeleteModal = false"
/>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import Icon from "@/components/core/Icon.vue";
import {ServerDatabase} from "@/api/server/types";
import DeleteDatabaseModal from "@/components/server/components/database/DeleteDatabaseModal.vue";
export default Vue.extend({
name: 'DatabaseRow',
components: {DeleteDatabaseModal, Icon},
props: {
database: {
type: Object as () => ServerDatabase,
required: true,
}
},
data: function () {
return {
showDeleteModal: false,
};
},
methods: {
revealPassword: function () {
this.database.showPassword = !this.database.showPassword;
},
},
})
</script>

View file

@ -1,99 +0,0 @@
<template>
<Modal v-on:close="closeModal" :isVisible="isVisible" :dismissable="!showSpinner">
<h2 class="font-medium text-neutral-900 mb-6">Delete this database?</h2>
<p class="text-neutral-900 text-sm">This action
<strong>cannot</strong> be undone. This will permanetly delete the
<strong>{{database.name}}</strong> database and remove all associated data.</p>
<div class="mt-6">
<label class="input-label">Confirm database name</label>
<input type="text" class="input" v-model="nameConfirmation"/>
</div>
<div class="mt-6 text-right">
<button class="btn btn-sm btn-secondary mr-2" v-on:click="closeModal">Cancel</button>
<button class="btn btn-sm btn-red" :disabled="disabled" v-on:click="deleteDatabase">
<span class="spinner white" v-bind:class="{ hidden: !showSpinner }">&nbsp;</span>
<span :class="{ hidden: showSpinner }">
Confirm Deletion
</span>
</button>
</div>
</Modal>
</template>
<script lang="ts">
import Vue from 'vue';
import {ServerDatabase} from "@/api/server/types";
import Modal from '@/components/core/Modal.vue';
export default Vue.extend({
name: 'DeleteDatabaseModal',
components: {Modal},
props: {
isVisible: {type: Boolean, default: false },
database: { type: Object as () => ServerDatabase, required: true },
},
data: function () {
return {
showSpinner: false,
nameConfirmation: '',
};
},
computed: {
/**
* Determine if the 'Delete' button should be enabled or not. This requires the user
* to enter the database name before actually deleting the DB.
*/
disabled: function () {
const splits: Array<string> = this.database.name.split('_');
return (
this.nameConfirmation !== this.database.name && this.nameConfirmation !== splits.slice(1).join('_')
);
}
},
methods: {
/**
* Handle deleting the database for the server instance.
*/
deleteDatabase: function () {
this.nameConfirmation = '';
this.showSpinner = true;
window.axios.delete(this.route('api.client.servers.databases.delete', {
server: this.$route.params.id,
database: this.database.id,
}))
.then(() => {
window.events.$emit('server:deleted-database', this.database.id);
})
.catch(err => {
this.$flash.clear();
console.error({err});
const response = err.response;
if (response.data && typeof response.data.errors === 'object') {
response.data.errors.forEach((error: any) => {
this.$flash.error(error.detail);
});
}
})
.then(() => {
this.$emit('close');
})
},
/**
* Closes the modal and resets the entry field.
*/
closeModal: function () {
this.showSpinner = false;
this.nameConfirmation = '';
this.$emit('close');
}
},
});
</script>

View file

@ -1,86 +0,0 @@
<template>
<div class="context-menu">
<div>
<div class="context-row" v-on:click="triggerAction('rename')">
<div class="icon">
<Icon name="edit-3"/>
</div>
<div class="action"><span>Rename</span></div>
</div>
<div class="context-row" v-on:click="triggerAction('move')">
<div class="icon">
<Icon name="corner-up-left" class="h-4"/>
</div>
<div class="action"><span class="text-left">Move</span></div>
</div>
<div class="context-row" v-on:click="triggerAction('copy')">
<div class="icon">
<Icon name="copy" class="h-4"/>
</div>
<div class="action">Copy</div>
</div>
<div class="context-row" v-on:click="triggerAction('download')" v-if="!object.directory">
<div class="icon">
<Icon name="download" class="h-4"/>
</div>
<div class="action">Download</div>
</div>
</div>
<div>
<div class="context-row" v-on:click="openNewFileModal">
<div class="icon">
<Icon name="file-plus" class="h-4"/>
</div>
<div class="action">New File</div>
</div>
<div class="context-row" v-on:click="openFolderModal">
<div class="icon">
<Icon name="folder-plus" class="h-4"/>
</div>
<div class="action">New Folder</div>
</div>
</div>
<div>
<div class="context-row danger" v-on:click="triggerAction('delete')">
<div class="icon">
<Icon name="delete" class="h-4"/>
</div>
<div class="action">Delete</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import Icon from "../../../core/Icon.vue";
import {DirectoryContentObject} from "@/api/server/types";
export default Vue.extend({
name: 'FileContextMenu',
components: {Icon},
props: {
object: {
type: Object as () => DirectoryContentObject,
required: true,
},
},
methods: {
openFolderModal: function () {
window.events.$emit('server:files:open-directory-modal');
this.$emit('close');
},
openNewFileModal: function () {
window.events.$emit('server:files:open-edit-file-modal');
this.$emit('close');
},
triggerAction: function (action: string) {
this.$emit(`action:${action}`);
}
}
});
</script>

View file

@ -1,193 +0,0 @@
<template>
<div>
<div v-on:contextmenu="showContextMenu">
<div
class="row"
:class="{ 'cursor-pointer': canEdit(file), 'active-selection': contextMenuVisible }"
v-if="!file.directory"
v-on:click="openFileEditModal(file)"
>
<div class="flex-none icon">
<Icon name="file-text" v-if="!file.symlink"/>
<Icon name="link2" v-else/>
</div>
<div class="flex-1">{{file.name}}</div>
<div class="w-1/6 text-right text-neutral-600">{{readableSize(file.size)}}</div>
<div class="w-1/5 text-right text-neutral-600">{{formatDate(file.modified)}}</div>
<div class="flex-none icon cursor-pointer" v-on:click="showContextMenu" ref="menuTriggerIcon" @click.stop>
<Icon name="more-vertical" class="text-neutral-500"/>
</div>
</div>
<router-link class="row"
:class="{ 'active-selection': contextMenuVisible }"
:to="{ name: 'server-files', params: { path: getClickablePath(file.name) }}"
v-else
>
<div class="flex-none icon text-primary-700">
<Icon name="folder"/>
</div>
<div class="flex-1">{{file.name}}</div>
<div class="w-1/6 text-right text-neutral-600"></div>
<div class="w-1/5 text-right text-neutral-600">{{formatDate(file.modified)}}</div>
<div class="flex-none icon" v-on:click="showContextMenu" ref="menuTriggerIcon">
<Icon name="more-vertical" class="text-neutral-500"/>
</div>
</router-link>
</div>
<FileContextMenu
class="context-menu"
:object="file"
v-show="contextMenuVisible"
v-on:close="contextMenuVisible = false"
v-on:action:delete="showModal('delete')"
v-on:action:rename="showModal('rename')"
v-on:action:copy="showModal('copy')"
v-on:action:move="showModal('move')"
v-on:action:download="showModal('download')"
ref="contextMenu"
/>
<CopyFileModal :file="file" v-if="modals.copy" v-on:close="$emit('list')"/>
<DownloadFileModal :file="file" v-if="!file.directory && modals.download" v-on:close="modals.download = false"/>
<DeleteFileModal :visible.sync="modals.delete" :object="file" v-on:deleted="$emit('deleted')" v-on:close="modal.delete = false"/>
<RenameModal :visible.sync="modals.rename" :object="file" v-on:renamed="$emit('list')" v-on:close="modal.rename = false"/>
<MoveFileModal :visible.sync="modals.move" :file="file" v-on:moved="$emit('list')" v-on:close="modal.move = false"/>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import Icon from "../../../core/Icon.vue";
import {Vue as VueType} from "vue/types/vue";
import {formatDate, readableSize} from '../../../../helpers'
import FileContextMenu from "./FileContextMenu.vue";
import {DirectoryContentObject} from "@/api/server/types";
import DeleteFileModal from "@/components/server/components/filemanager/modals/DeleteFileModal.vue";
import RenameModal from "@/components/server/components/filemanager/modals/RenameModal.vue";
import CopyFileModal from "@/components/server/components/filemanager/modals/CopyFileModal.vue";
import DownloadFileModal from "@/components/server/components/filemanager/modals/DownloadFileModal.vue";
import MoveFileModal from "@/components/server/components/filemanager/modals/MoveFileModal.vue";
type DataStructure = {
currentDirectory: string,
contextMenuVisible: boolean,
modals: { [key: string]: boolean },
};
export default Vue.extend({
name: 'FileRow',
components: {CopyFileModal, DownloadFileModal, DeleteFileModal, MoveFileModal, Icon, FileContextMenu, RenameModal},
props: {
file: {
type: Object as () => DirectoryContentObject,
required: true,
},
editable: {
type: Array as () => Array<string>,
default: () => [],
required: false,
},
},
data: function (): DataStructure {
return {
currentDirectory: this.$route.params.path || '/',
contextMenuVisible: false,
modals: {
rename: false,
delete: false,
copy: false,
move: false,
download: false,
},
};
},
mounted: function () {
document.addEventListener('click', this._clickListener);
// If the parent component emits the collapse menu event check if the unique ID of the component
// is this one. If not, collapse the menu (we right clicked into another element).
this.$parent.$on('collapse-menus', (uid: string) => {
// @ts-ignore
if (this._uid !== uid) {
this.contextMenuVisible = false;
}
})
},
beforeDestroy: function () {
document.removeEventListener('click', this._clickListener, false);
},
methods: {
showModal: function (name: string) {
this.contextMenuVisible = false;
Object.keys(this.modals).forEach(k => {
this.modals[k] = k === name;
});
},
/**
* Handle a right-click action on a file manager row.
*/
showContextMenu: function (e: MouseEvent) {
e.preventDefault();
// @ts-ignore
this.$parent.$emit('collapse-menus', this._uid);
this.contextMenuVisible = true;
this.$nextTick(() => {
const menuWidth = (this.$refs.contextMenu as VueType).$el.clientWidth;
const positionElement = e.clientX - Math.round(menuWidth / 2);
(this.$refs.contextMenu as VueType).$el.setAttribute('style', `left: ${positionElement}px; top: ${e.layerY}px`);
});
},
openFileEditModal: function (file: DirectoryContentObject) {
if (!file.directory && this.canEdit(file)) {
window.events.$emit('server:files:open-edit-file-modal', file);
}
},
/**
* Determine if a file can be edited on the Panel.
*/
canEdit: function (file: DirectoryContentObject): boolean {
return !file.directory && this.editable.indexOf(file.mime) >= 0;
},
/**
* Handle a click anywhere in the document and hide the context menu if that click is not
* a right click and isn't occurring somewhere in the currently visible context menu.
*
* @private
*/
_clickListener: function (e: MouseEvent) {
if (e.button !== 2 && this.contextMenuVisible) {
// If we're clicking the trigger icon don't discard the event.
if (this.$refs.menuTriggerIcon) {
if (e.target === this.$refs.menuTriggerIcon || (this.$refs.menuTriggerIcon as HTMLDivElement).contains(e.target as Node)) {
return;
}
}
// If the target is outside the scope of the context menu, hide it.
if (e.target !== (this.$refs.contextMenu as VueType).$el && !(this.$refs.contextMenu as VueType).$el.contains(e.target as Node)) {
this.contextMenuVisible = false;
}
}
},
getClickablePath(directory: string): string {
return `${this.currentDirectory.replace(/\/$/, '')}/${directory}`;
},
readableSize: readableSize,
formatDate: formatDate,
},
});
</script>

View file

@ -1,58 +0,0 @@
<template>
<SpinnerModal :visible="true">
Copying {{ file.directory ? 'directory' : 'file' }}...
</SpinnerModal>
</template>
<script lang="ts">
import Vue from 'vue';
import SpinnerModal from "../../../../core/SpinnerModal.vue";
import {DirectoryContentObject} from '@/api/server/types';
import {mapState} from "vuex";
import {ServerState} from '@/store/types';
import {join} from 'path';
import {copyFile} from '@/api/server/files/copyFile';
import {AxiosError} from "axios";
export default Vue.extend({
components: {SpinnerModal},
computed: mapState('server', {
server: (state: ServerState) => state.server,
credentials: (state: ServerState) => state.credentials,
fm: (state: ServerState) => state.fm,
}),
props: {
file: {type: Object as () => DirectoryContentObject, required: true},
},
/**
* This modal works differently than the other modals that exist for the file manager.
* When it is mounted we will immediately show the spinner, and begin the copy operation
* on the give file or directory. Once that operation is complete we will emit the event
* and allow the parent to close the modal and do whatever else it thinks is needed.
*/
mounted: function () {
let newPath = join(this.fm.currentDirectory, `${this.file.name} copy`);
if (!this.file.directory) {
const extension = this.file.name.substring(this.file.name.lastIndexOf('.') + 1);
if (extension !== this.file.name && extension.length > 0) {
const name = this.file.name.substring(0, this.file.name.lastIndexOf('.'));
newPath = join(this.fm.currentDirectory, `${name} copy.${extension}`)
}
}
copyFile(this.server.uuid, join(this.fm.currentDirectory, this.file.name))
.then(() => this.$emit('close'))
.catch((error: AxiosError) => {
alert(`There was an error creating a copy of this item: ${error.message}`);
console.error('Error at Server::Files::Copy', {error});
})
.then(() => this.$emit('close'));
},
})
</script>

View file

@ -1,103 +0,0 @@
<template>
<Modal :isVisible="visible" v-on:close="onModalClose" :isVisibleCloseIcon="false" :dismissable="!isLoading">
<div>
<label class="input-label">
Directory Name
</label>
<input
type="text" class="input" name="folder_name"
ref="folderNameField"
v-model="folderName"
v-validate.disabled="'required'"
v-validate="'alpha_dash'"
data-vv-as="Folder Name"
v-on:keyup.enter="submit"
/>
<p class="input-help">A new directory with this name will be created in the current directory.</p>
</div>
<div class="mt-8 text-right">
<button class="btn btn-secondary btn-sm" v-on:click="onModalClose">Cancel</button>
<button type="submit"
class="ml-2 btn btn-primary btn-sm"
v-on:click.prevent="submit"
:disabled="errors.any() || isLoading"
>
<span class="spinner white" v-bind:class="{ hidden: !isLoading }">&nbsp;</span>
<span :class="{ hidden: isLoading }">
Create Directory
</span>
</button>
</div>
<p class="input-help error">
{{ errors.first('folder_name') }}
</p>
</Modal>
</template>
<script lang="ts">
import Vue from 'vue';
import Modal from '@/components/core/Modal.vue';
import {mapState} from "vuex";
import {createFolder} from "@/api/server/files/createFolder";
export default Vue.extend({
name: 'CreateFolderModal',
components: {Modal},
computed: {
...mapState('server', ['server', 'credentials', 'fm']),
},
data: function () {
return {
isLoading: false,
visible: false,
folderName: '',
};
},
mounted: function () {
/**
* When we mark the modal as visible, focus the user into the input field on the next
* tick operation so that they can begin typing right away.
*/
window.events.$on('server:files:open-directory-modal', () => {
this.visible = true;
this.$nextTick(() => {
if (this.$refs.folderNameField) {
(this.$refs.folderNameField as HTMLInputElement).focus();
}
});
});
},
beforeDestroy: function () {
window.events.$off('server:files:open-directory-modal');
},
methods: {
submit: function () {
this.$validator.validate().then((result) => {
if (!result) {
return;
}
this.isLoading = true;
createFolder(this.server.uuid, this.fm.currentDirectory, this.folderName.replace(/^\//, ''))
.then(() => {
this.$emit('created', this.folderName.replace(/^\//, ''));
this.onModalClose();
})
.catch(console.error.bind(this))
.then(() => this.isLoading = false)
});
},
onModalClose: function () {
this.visible = false;
this.folderName = '';
this.$validator.reset();
},
}
});
</script>

View file

@ -1,88 +0,0 @@
<template>
<Modal :isVisible="isVisible" v-on:close="isVisible = false" :dismissable="!isLoading">
<MessageBox
class="alert error mb-8"
title="Error"
:message="error"
v-if="error"
/>
<div v-if="object">
<h3 class="font-medium mb-6">Really delete {{ object.name }}?</h3>
<p class="text-sm text-neutral-700">
Deletion is a permanent operation: <strong>{{ object.name }}</strong><span v-if="object.directory">, as well as its contents,</span> will be removed immediately.
</p>
<div class="mt-8 text-right">
<button class="btn btn-secondary btn-sm" v-on:click.prevent="isVisible = false">Cancel</button>
<button class="btn btn-red btn-sm ml-2" v-on:click="deleteItem" :disabled="isLoading">
<span v-if="isLoading" class="spinner white">&nbsp;</span>
<span v-else>Yes, Delete</span>
</button>
</div>
</div>
</Modal>
</template>
<script lang="ts">
import Vue from 'vue';
import Modal from '@/components/core/Modal.vue';
import {DirectoryContentObject} from "@/api/server/types";
import {deleteFile} from '@/api/server/files/deleteFile';
import {mapState} from "vuex";
import {AxiosError} from "axios";
import { join } from 'path';
import {ApplicationState} from '@/store/types';
type DataStructure = {
isLoading: boolean,
error: string | null,
};
export default Vue.extend({
name: 'DeleteFileModal',
components: {Modal},
props: {
visible: { type: Boolean, default: false },
object: { type: Object as () => DirectoryContentObject, required: true }
},
data: function (): DataStructure {
return {
isLoading: false,
error: null,
};
},
computed: {
...mapState({
server: (state: ApplicationState) => state.server.server,
credentials: (state: ApplicationState) => state.server.credentials,
fm: (state: ApplicationState) => state.server.fm,
}),
isVisible: {
get: function (): boolean {
return this.visible;
},
set: function (value: boolean) {
this.$emit('update:visible', value);
},
},
},
methods: {
deleteItem: function () {
this.isLoading = true;
// @ts-ignore
deleteFile(this.server.uuid, join(this.fm.currentDirectory, this.object.name))
.then(() => this.$emit('deleted'))
.catch((error: AxiosError) => {
this.error = `There was an error deleting the requested ${(this.object.directory) ? 'folder' : 'file'}. Response was: ${error.message}`;
console.error('Error at Server::Files::Delete', {error});
})
.then(() => this.isLoading = false);
},
},
});
</script>

View file

@ -1,50 +0,0 @@
<template>
<SpinnerModal :visible="true">
Downloading {{ file.name }}...
</SpinnerModal>
</template>
<script lang="ts">
import Vue from 'vue';
import SpinnerModal from "../../../../core/SpinnerModal.vue";
import {DirectoryContentObject} from '@/api/server/types';
import {mapState} from "vuex";
import {ServerState} from '@/store/types';
import { join } from 'path';
import {AxiosError} from "axios";
import {getDownloadToken} from '@/api/server/files/getDownloadToken';
export default Vue.extend({
components: { SpinnerModal },
computed: mapState('server', {
credentials: (state: ServerState) => state.credentials,
fm: (state: ServerState) => state.fm,
}),
props: {
file: { type: Object as () => DirectoryContentObject, required: true },
},
/**
* This modal works differently than the other modals that exist for the file manager.
* When it is mounted we will immediately show the spinner, and then begin the operation
* to get the download token and redirect the user to that new URL.
*/
mounted: function () {
const path = join(this.fm.currentDirectory, this.file.name);
getDownloadToken(this.$route.params.id, path)
.then((token) => {
if (token) {
window.location.href = `${this.credentials.node}/v1/server/file/download/${token}`;
}
})
.catch((error: AxiosError) => {
alert(`There was an error trying to download this ${this.file.directory ? 'folder' : 'file'}: ${error.message}`);
console.error('Error at Server::Files::Download', {error});
})
.then(() => this.$emit('close'));
},
})
</script>

View file

@ -1,298 +0,0 @@
<template>
<transition name="modal">
<div class="modal-mask" v-show="isVisible">
<div class="modal-container full-screen" @click.stop>
<SpinnerModal :visible="isVisible && isLoading"/>
<div class="modal-close-icon" v-on:click="closeModal">
<Icon name="x" aria-label="Close modal" role="button"/>
</div>
<MessageBox class="alert error mb-4" title="Error" :message="error" v-if="error"/>
<div class="flex items-center mb-4 bg-white rounded p-2">
<div class="mx-2">
<label class="input-label mb-0" for="file-name-input">File name:</label>
</div>
<div class="flex-1">
<input
type="text"
name="file_name"
class="input"
id="file-name-input"
:disabled="typeof file !== 'undefined'"
v-model="fileName"
v-validate="'required'"
/>
<p class="input-help error" v-show="errors.has('file_name')">{{ errors.first('file_name') }}</p>
</div>
</div>
<div id="editor"></div>
<div class="flex mt-4 bg-white rounded p-2">
<div class="flex-1">
<select v-on:change="updateFileLanguage" ref="fileLanguageSelector">
<option v-for="item in supportedTypes" :value="item.type">
{{ item.name }}
</option>
</select>
</div>
<button class="btn btn-secondary btn-sm" v-on:click="closeModal">
Cancel
</button>
<button class="ml-2 btn btn-primary btn-sm" v-on:click="submit">
Save
</button>
</div>
</div>
</div>
</transition>
</template>
<script lang="ts">
import Vue from 'vue';
import Icon from "@/components/core/Icon.vue";
import MessageBox from "@/components/MessageBox.vue";
import {ApplicationState, FileManagerState} from '@/store/types';
import {mapState} from "vuex";
import * as Ace from 'brace';
import {join} from 'path';
import {DirectoryContentObject} from "@/api/server/types";
import getFileContents from '@/api/server/files/getFileContents';
import SpinnerModal from "@/components/core/SpinnerModal.vue";
import writeFileContents from '@/api/server/files/writeFileContents';
import {httpErrorToHuman} from '@/api/http';
interface Data {
file?: DirectoryContentObject,
serverUuid?: string,
fm?: FileManagerState,
fileName?: string,
error: string | null,
editor: Ace.Editor | null,
isVisible: boolean,
isLoading: boolean,
supportedTypes: { type: string, name: string, default?: boolean }[],
}
const defaults = {
error: null,
editor: null,
isVisible: false,
isLoading: true,
file: undefined,
fileName: undefined,
};
export default Vue.extend({
name: 'NewFileModal',
components: {Icon, SpinnerModal, MessageBox},
data: function (): Data {
return {
...defaults,
supportedTypes: [
{type: 'text', name: 'Text'},
{type: 'dockerfile', name: 'Docker'},
{type: 'golang', name: 'Go'},
{type: 'html', name: 'HTML'},
{type: 'java', name: 'Java'},
{type: 'javascript', name: 'Javascript'},
{type: 'json', name: 'JSON'},
{type: 'kotlin', name: 'Kotlin'},
{type: 'lua', name: 'Lua'},
{type: 'markdown', name: 'Markdown'},
{type: 'php', name: 'PHP'},
{type: 'properties', name: 'Properties'},
{type: 'python', name: 'Python'},
{type: 'ruby', name: 'Ruby'},
{type: 'sh', name: 'Shell'},
{type: 'sql', name: 'SQL'},
{type: 'xml', name: 'XML'},
{type: 'yaml', name: 'YAML'},
],
};
},
computed: mapState({
fm: (state: ApplicationState) => state.server.fm,
serverUuid: (state: ApplicationState) => state.server.server.uuid,
}),
mounted: function () {
window.events.$on('server:files:open-edit-file-modal', (file?: DirectoryContentObject) => {
this.file = file;
this.isVisible = true;
this.isLoading = true;
this.fileName = file ? file.name : undefined;
this.errors.clear();
this.$nextTick(() => {
this.editor = Ace.edit('editor');
this.loadDependencies()
.then(() => this.loadLanguages())
.then(() => this.configureEditor())
.then(() => this.loadFileContent())
.then(() => {
this.isLoading = false;
})
.catch(error => {
console.error(error);
this.isLoading = false;
this.error = error.message;
});
});
});
},
watch: {
fileName: function (newValue?: string, oldValue?: string) {
if (newValue === oldValue || !newValue) {
return;
}
this.updateFileLanguageFromName(newValue);
},
},
methods: {
submit: function () {
if (!this.file && (!this.fileName || this.fileName.length === 0)) {
this.error = 'You must provide a file name before saving.';
return;
}
this.isLoading = true;
const content = this.editor!.getValue();
writeFileContents(this.serverUuid!, join(this.fm!.currentDirectory, this.fileName!), content)
.then(() => {
this.error = null;
// @todo come up with a more graceful solution here
if (!this.file) {
this.$emit('refresh');
this.closeModal();
}
})
.catch(error => {
console.log(error);
this.error = httpErrorToHuman(error);
})
.then(() => this.isLoading = false);
},
loadFileContent: function (): Promise<void> {
return new Promise((resolve, reject) => {
const {editor, file} = this;
if (!file || !editor || file.directory) {
return resolve();
}
getFileContents(this.serverUuid!, join(this.fm!.currentDirectory, file.name))
.then(contents => {
editor.$blockScrolling = Infinity;
editor.setValue(contents, 1);
})
.then(() => this.updateFileLanguageFromName(file.name))
.then(() => resolve())
.catch(reject);
});
},
updateFileLanguageFromName: function (name: string) {
const modelist = Ace.acequire('ace/ext/modelist');
if (!modelist || !this.editor) {
return;
}
const mode = modelist.getModeForPath(name).mode || 'ace/mode/text';
const parts = mode.split('/');
const element = (this.$refs.fileLanguageSelector as HTMLSelectElement | null);
if (element) {
const index = this.supportedTypes.findIndex(value => value.type === parts[parts.length - 1]);
if (index >= 0) {
element.selectedIndex = index;
this.editor.getSession().setMode(mode);
}
}
},
updateFileLanguage: function (e: MouseEvent) {
if (!this.editor) {
return;
}
this.editor.getSession().setMode(`ace/mode/${(<HTMLSelectElement>e.target).value}`);
},
loadLanguages: function (): Promise<any[]> {
return Promise.all(
this.supportedTypes.map(o => import(
/* webpackChunkName: "ace_editor" */
/* webpackMode: "lazy-once" */
/* webpackInclude: /(dockerfile|golang|html|java|javascript|json|kotlin|lua|markdown|text|php|properties|python|ruby|sh|sql|xml|yaml).js$/ */
`brace/mode/${o.type}`
))
);
},
loadDependencies: function (): Promise<any[]> {
return Promise.all([
// @ts-ignore
import(/* webpackChunkName: "ace_editor" */ 'brace/ext/whitespace'),
// @ts-ignore
import(/* webpackChunkName: "ace_editor" */ 'brace/ext/modelist'),
// @ts-ignore
import(/* webpackChunkName: "ace_editor" */ 'brace/theme/chrome'),
]);
},
configureEditor: function () {
if (!this.editor) {
return;
}
const whitespace = Ace.acequire('ace/ext/whitespace');
this.editor.setTheme('ace/theme/chrome');
this.editor.setOptions({
fontFamily: '"SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace',
});
this.editor.getSession().setUseWrapMode(true);
this.editor.setShowPrintMargin(true);
whitespace.commands.forEach((c: Ace.EditorCommand) => {
this.editor!.commands.addCommand(c);
});
whitespace.detectIndentation(this.editor.session);
},
closeModal: function () {
if (this.editor) {
this.editor.setValue('', -1);
}
Object.assign(this.$data, defaults);
},
}
})
</script>
<style>
#editor {
@apply .h-full .relative;
& > .ace_gutter > .ace_layer, & > .ace_scroller {
@apply .py-1;
}
& .ace_gutter-active-line {
@apply .mt-1;
}
}
.ace_editor {
@apply .rounded .p-1;
}
</style>

View file

@ -1,125 +0,0 @@
<template>
<Modal :isVisible="visible" v-on:close="isVisible = false" :dismissable="!isLoading">
<MessageBox class="alert error mb-8" title="Error" :message="error" v-if="error"/>
<div class="flex items-end">
<div class="flex-1">
<label class="input-label">
Move {{ file.name}}
</label>
<input
type="text" class="input" name="move_to"
:placeholder="file.name"
ref="moveToField"
v-model="moveTo"
v-validate="{ required: true, regex: /(^[\w\d.\-\/]+$)/}"
v-on:keyup.enter="submit"
/>
</div>
<div class="ml-4">
<button type="submit"
class="btn btn-primary btn-sm"
v-on:click.prevent="submit"
:disabled="errors.any() || isLoading"
>
<span class="spinner white" v-bind:class="{ hidden: !isLoading }">&nbsp;</span>
<span :class="{ hidden: isLoading }">
Move {{ file.directory ? 'Folder' : 'File' }}
</span>
</button>
</div>
</div>
<p class="input-help error" v-if="errors.count()">
{{ errors.first('move_to') }}
</p>
<p class="input-help" v-else>
Enter the new name and path for this {{ file.directory ? 'folder' : 'file' }} in the field above. This will be relative to the current directory.
</p>
</Modal>
</template>
<script lang="ts">
import Vue from 'vue';
import Modal from "@/components/core/Modal.vue";
import MessageBox from "@/components/MessageBox.vue";
import {DirectoryContentObject} from "@/api/server/types";
import {renameFile} from '@/api/server/files/renameFile';
import {mapState} from "vuex";
import {ApplicationState} from "@/store/types";
import {join} from 'path';
import {AxiosError} from "axios";
type DataStructure = {
error: null | string,
isLoading: boolean,
moveTo: null | string,
};
export default Vue.extend({
name: 'MoveFileModal',
components: { MessageBox, Modal },
data: function (): DataStructure {
return {
error: null,
isLoading: false,
moveTo: null,
};
},
props: {
visible: { type: Boolean, default: false },
file: { type: Object as () => DirectoryContentObject, required: true }
},
computed: {
...mapState({
server: (state: ApplicationState) => state.server.server,
credentials: (state: ApplicationState) => state.server.credentials,
fm: (state: ApplicationState) => state.server.fm,
}),
isVisible: {
get: function (): boolean {
return this.visible;
},
set: function (value: boolean) {
this.$emit('update:visible', value)
},
}
},
watch: {
isVisible: function (n, o): void {
if (n !== o) {
this.resetModal();
}
if (n && !o) {
this.$nextTick(() => (this.$refs.moveToField as HTMLElement).focus());
}
},
},
methods: {
submit: function () {
this.isLoading = true;
// @ts-ignore
renameFile(this.server.uuid, join(this.fm.currentDirectory, this.file.name), join(this.fm.currentDirectory, this.moveTo))
.then(() => this.$emit('moved'))
.catch((error: AxiosError) => {
this.error = `There was an error moving the requested ${(this.file.directory) ? 'folder' : 'file'}. Response was: ${error.message}`;
console.error('Error at Server::Files::Move', {error});
})
.then(() => this.isLoading = false);
},
resetModal: function () {
this.isLoading = false;
this.moveTo = null;
this.error = null;
},
}
});
</script>

View file

@ -1,132 +0,0 @@
<template>
<Modal :isVisible="isVisible" v-on:close="closeModal" :isVisibleCloseIcon="false" :dismissable="!isLoading">
<MessageBox
class="alert error mb-8"
title="Error"
:message="error"
v-if="error"
/>
<div class="flex items-end" v-if="object">
<div class="flex-1">
<label class="input-label">
Rename {{ object.file ? 'File' : 'Folder' }}
</label>
<input
type="text" class="input" name="element_name"
:placeholder="object.name"
ref="elementNameField"
v-model="newName"
:data-vv-as="object.directory ? 'folder name' : 'file name'"
v-validate="{ required: true, regex: /(^[\w\d.\-\/]+$)/}"
v-on:keyup.enter="submit"
/>
</div>
<div class="ml-4">
<button type="submit"
class="btn btn-primary btn-sm"
v-on:click.prevent="submit"
:disabled="errors.any() || isLoading"
>
<span class="spinner white" v-bind:class="{ hidden: !isLoading }">&nbsp;</span>
<span :class="{ hidden: isLoading }">
Rename
</span>
</button>
</div>
</div>
<p class="input-help error" v-if="errors.count()">
{{ errors.first('element_name') }}
</p>
</Modal>
</template>
<script lang="ts">
import Vue from 'vue';
import Flash from '@/components/Flash.vue';
import Modal from '@/components/core/Modal.vue';
import MessageBox from '@/components/MessageBox.vue';
import {DirectoryContentObject} from "@/api/server/types";
import {mapState} from "vuex";
import {renameFile} from "@/api/server/files/renameFile";
import {AxiosError} from 'axios';
import {ApplicationState} from "@/store/types";
import {join} from "path";
type DataStructure = {
error: null | string,
newName: string,
isLoading: boolean,
};
export default Vue.extend({
name: 'RenameModal',
components: { Flash, Modal, MessageBox },
props: {
visible: { type: Boolean, default: false },
object: { type: Object as () => DirectoryContentObject, required: true },
},
computed: {
...mapState({
server: (state: ApplicationState) => state.server.server,
credentials: (state: ApplicationState) => state.server.credentials,
fm: (state: ApplicationState) => state.server.fm,
}),
isVisible: {
get: function (): boolean {
return this.visible;
},
set: function (value: boolean) {
this.$emit('update:visible', value);
},
},
},
watch: {
visible: function (newVal, oldVal) {
if (newVal && newVal !== oldVal) {
this.$nextTick(() => {
if (this.$refs.elementNameField) {
(this.$refs.elementNameField as HTMLInputElement).focus();
}
});
}
}
},
data: function (): DataStructure {
return {
newName: '',
error: null,
isLoading: false,
};
},
methods: {
submit: function () {
this.isLoading = true;
this.error = null;
// @ts-ignore
renameFile(this.server.uuid, join(this.fm.currentDirectory, this.object.name), join(this.fm.currentDirectory, this.newName))
.then(() => {
this.$emit('renamed', this.newName);
this.closeModal();
})
.catch((error: AxiosError) => {
this.error = `There was an error while renaming the requested ${this.object.file ? 'file' : 'folder'}. Response: ${error.message}`;
console.error('Error at Server::Files::Rename', { error });
})
.then(() => this.isLoading = false);
},
closeModal: function () {
this.newName = '';
this.error = null;
this.isVisible = false;
},
},
});
</script>

View file

@ -1,181 +0,0 @@
<template>
<div class="animate fadein shadow-md">
<div class="text-xs font-mono">
<div class="rounded-t p-2 bg-black overflow-scroll w-full" style="min-height: 16rem;max-height:64rem;">
<div class="mb-2 text-neutral-400" ref="terminal" v-if="connected"></div>
<div v-else>
<div class="spinner spinner-xl mt-24"></div>
</div>
</div>
<div class="rounded-b bg-neutral-900 text-white flex">
<div class="flex-no-shrink p-2">
<span class="font-bold">$</span>
</div>
<div class="w-full">
<input type="text" aria-label="Send console command" class="bg-transparent text-white p-2 pl-0 w-full" placeholder="enter command and press enter to send"
ref="command"
v-model="command"
v-on:keyup.enter="sendCommand"
v-on:keydown="handleArrowKey"
>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import {mapState} from "vuex";
import {Terminal} from 'xterm';
import * as TerminalFit from 'xterm/lib/addons/fit/fit';
import {Socketio} from "@/mixins/socketio";
type DataStructure = {
terminal: Terminal | null,
command: string,
commandHistory: Array<string>,
commandHistoryIndex: number,
}
export default Vue.extend({
name: 'ServerConsole',
mixins: [Socketio],
computed: {
...mapState('socket', ['connected', 'outputBuffer']),
},
watch: {
/**
* Watch the connected variable and when it becomes true request the server logs.
*/
connected: function (state: boolean) {
if (state) {
this.$nextTick(() => {
this.mountTerminal();
});
} else {
this.terminal && this.terminal.clear();
}
},
},
/**
* Listen for specific socket emits from the server.
*/
sockets: {
'console output': function (line: string) {
this.writeLineToConsole(line);
},
},
/**
* Mount the component and setup all of the terminal actions. Also fetches the initial
* logs from the server to populate into the terminal if the socket is connected. If the
* socket is not connected this will occur automatically when it connects.
*/
mounted: function () {
if (this.connected) {
this.mountTerminal();
}
},
data: function (): DataStructure {
return {
terminal: null,
command: '',
commandHistory: [],
commandHistoryIndex: -1,
};
},
methods: {
/**
* Mount the terminal and grab the most recent server logs.
*/
mountTerminal: function () {
// Get a new instance of the terminal setup.
this.terminal = this._terminalInstance();
this.terminal.open((this.$refs.terminal as HTMLElement));
// @ts-ignore
this.terminal.fit();
this.terminal.clear();
this.outputBuffer.forEach(this.writeLineToConsole);
},
/**
* Send a command to the server using the configured websocket.
*/
sendCommand: function () {
this.commandHistoryIndex = -1;
this.commandHistory.unshift(this.command);
this.$socket().emit('send command', this.command);
this.command = '';
},
writeLineToConsole: function (line: string) {
this.terminal && this.terminal.writeln(line.replace(/(?:\r\n|\r|\n)$/im, '') + '\u001b[0m');
},
/**
* Handle a user pressing up/down arrows when in the command field to scroll through thier
* command history for this server.
*/
handleArrowKey: function (e: KeyboardEvent) {
if (['ArrowUp', 'ArrowDown'].indexOf(e.key) < 0 || e.key === 'ArrowDown' && this.commandHistoryIndex < 0) {
return;
}
e.preventDefault();
e.stopPropagation();
if (e.key === 'ArrowUp' && (this.commandHistoryIndex + 1 > (this.commandHistory.length - 1))) {
return;
}
this.commandHistoryIndex += (e.key === 'ArrowUp') ? 1 : -1;
this.command = this.commandHistoryIndex < 0 ? '' : this.commandHistory[this.commandHistoryIndex];
},
/**
* Returns a new instance of the terminal to be used.
*
* @private
*/
_terminalInstance() {
Terminal.applyAddon(TerminalFit);
return new Terminal({
disableStdin: true,
cursorStyle: 'underline',
allowTransparency: true,
fontSize: 12,
fontFamily: 'Menlo, Monaco, Consolas, monospace',
rows: 30,
theme: {
background: 'transparent',
cursor: 'transparent',
black: '#000000',
red: '#E54B4B',
green: '#9ECE58',
yellow: '#FAED70',
blue: '#396FE2',
magenta: '#BB80B3',
cyan: '#2DDAFD',
white: '#d0d0d0',
brightBlack: 'rgba(255, 255, 255, 0.2)',
brightRed: '#FF5370',
brightGreen: '#C3E88D',
brightYellow: '#FFCB6B',
brightBlue: '#82AAFF',
brightMagenta: '#C792EA',
brightCyan: '#89DDFF',
brightWhite: '#ffffff',
},
});
}
},
});
</script>

View file

@ -1,112 +0,0 @@
<template>
<div>
<div v-if="loading">
<div class="spinner spinner-xl blue"></div>
</div>
<div class="animate fadein" v-else>
<div class="content-box mb-6" v-if="!databases.length">
<div class="flex items-center">
<Icon name="database" class="flex-none text-neutral-800"></icon>
<div class="flex-1 px-4 text-neutral-800">
<p>You have no databases.</p>
</div>
</div>
</div>
<div v-else>
<DatabaseRow v-for="database in databases" :database="database" :key="database.name"/>
</div>
<div>
<button class="btn btn-primary btn-lg" v-on:click="showCreateModal = true">Create new database</button>
</div>
<CreateDatabaseModal
:isVisible="showCreateModal"
v-on:database="handleModalCallback"
v-on:close="showCreateModal = false"
/>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import {filter, map} from 'lodash';
import CreateDatabaseModal from './../components/database/CreateDatabaseModal.vue';
import Icon from "@/components/core/Icon.vue";
import {ServerDatabase} from "@/api/server/types";
import DatabaseRow from "@/components/server/components/database/DatabaseRow.vue";
type DataStructure = {
loading: boolean,
showCreateModal: boolean,
databases: Array<ServerDatabase>,
}
export default Vue.extend({
name: 'ServerDatabases',
components: {DatabaseRow, CreateDatabaseModal, Icon},
data: function (): DataStructure {
return {
databases: [],
loading: true,
showCreateModal: false,
};
},
mounted: function () {
this.getDatabases();
window.events.$on('server:deleted-database', this.removeDatabase);
},
methods: {
/**
* Get all of the databases that exist for this server.
*/
getDatabases: function () {
this.$flash.clear();
this.loading = true;
window.axios.get(this.route('api.client.servers.databases', {
server: this.$route.params.id,
include: 'password'
}))
.then(response => {
this.databases = map(response.data.data, (object) => {
const data = object.attributes;
data.password = data.relationships.password.attributes.password;
data.showPassword = false;
delete data.relationships;
return data;
});
})
.catch(err => {
this.$flash.error('There was an error encountered while attempting to fetch databases for this server.');
console.error(err);
})
.then(() => {
this.loading = false;
});
},
/**
* Add the database to the list of existing databases automatically when the modal
* is closed with a successful callback.
*/
handleModalCallback: function (data: ServerDatabase) {
this.databases.push(data);
},
/**
* Handle event that is removing a database.
*/
removeDatabase: function (databaseId: string) {
this.databases = filter(this.databases, (database) => {
return database.id !== databaseId;
});
}
},
});
</script>

View file

@ -1,200 +0,0 @@
<template>
<div class="animated-fade-in">
<div class="filemanager-breadcrumbs">
/<span class="px-1">home</span><!--
-->/
<router-link :to="{ name: 'server-files' }" class="px-1">container</router-link><!--
--><span v-for="crumb in breadcrumbs" class="inline-block">
<span v-if="crumb.path">
/<router-link :to="{ name: 'server-files', params: { path: crumb.path } }" class="px-1">{{crumb.directoryName}}</router-link>
</span>
<span v-else>
/<span class="px-1 text-neutral-600 font-medium">{{crumb.directoryName}}</span>
</span>
</span>
</div>
<div class="content-box">
<div v-if="loading">
<div class="spinner spinner-xl blue"></div>
</div>
<div v-else-if="!loading && errorMessage">
<div class="alert error" v-text="errorMessage"></div>
</div>
<div v-else-if="!directories.length && !files.length">
<p class="text-neutral-500 text-sm text-center p-6 pb-4">This directory is empty.</p>
</div>
<div class="filemanager animated-fade-in" v-else>
<div class="header">
<div class="flex-none w-8"></div>
<div class="flex-1">Name</div>
<div class="w-1/6">Size</div>
<div class="w-1/5">Modified</div>
<div class="flex-none"></div>
</div>
<div v-for="file in Array.concat(directories, files)">
<FileRow
:key="file.directory ? `dir-${file.name}` : file.name"
:file="file"
:editable="editableFiles"
v-on:deleted="fileRowDeleted(file, file.directory)"
v-on:list="listDirectory"
/>
</div>
</div>
</div>
<div class="flex mt-6" v-if="!loading && !errorMessage">
<div class="flex-1"></div>
<div class="mr-4">
<a href="#" class="block btn btn-secondary btn-sm" v-on:click.prevent="openNewFolderModal">New Folder</a>
</div>
<div>
<a href="#" class="block btn btn-primary btn-sm" v-on:click.prevent="openNewFileModal">New File</a>
</div>
</div>
<CreateFolderModal v-on:created="directoryCreated"/>
<EditFileModal v-on:refresh="listDirectory"/>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { join } from 'path';
import {map} from 'lodash';
import getDirectoryContents from "@/api/server/getDirectoryContents";
import FileRow from "@/components/server/components/filemanager/FileRow.vue";
import CreateFolderModal from '../components/filemanager/modals/CreateFolderModal.vue';
import DeleteFileModal from '../components/filemanager/modals/DeleteFileModal.vue';
import {DirectoryContentObject} from "@/api/server/types";
import EditFileModal from "@/components/server/components/filemanager/modals/EditFileModal.vue";
type DataStructure = {
loading: boolean,
errorMessage: string | null,
currentDirectory: string,
files: Array<DirectoryContentObject>,
directories: Array<DirectoryContentObject>,
editableFiles: Array<string>,
}
export default Vue.extend({
name: 'FileManager',
components: {CreateFolderModal, DeleteFileModal, FileRow, EditFileModal},
computed: {
/**
* Configure the breadcrumbs that display on the filemanager based on the directory that the
* user is currently in.
*/
breadcrumbs: function () {
const directories = this.currentDirectory.replace(/^\/|\/$/, '').split('/');
if (directories.length < 1 || !directories[0]) {
return [];
}
return map(directories, function (value: string, key: number) {
if (key === directories.length - 1) {
return {directoryName: value};
}
return {
directoryName: value,
path: directories.slice(0, key + 1).join('/'),
};
});
},
},
watch: {
/**
* When the route changes reload the directory.
*/
'$route': function (to) {
this.currentDirectory = to.params.path || '/';
},
/**
* Watch the current directory setting and when it changes update the file listing.
*/
currentDirectory: function () {
this.listDirectory();
},
/**
* When we reconnect to the Daemon make sure we grab a listing of all of the files
* so that the error message disappears and we then load in a fresh listing.
*/
connected: function () {
// @ts-ignore
if (this.connected) {
this.listDirectory();
}
},
},
data: function (): DataStructure {
return {
currentDirectory: this.$route.params.path || '/',
loading: true,
errorMessage: null,
directories: [],
editableFiles: [],
files: [],
};
},
mounted: function () {
this.listDirectory();
},
methods: {
/**
* List the contents of a directory.
*/
listDirectory: function () {
this.loading = true;
const directory = encodeURI(this.currentDirectory.replace(/^\/|\/$/, ''));
this.$store.dispatch('server/updateCurrentDirectory', `/${directory}`);
getDirectoryContents(this.$route.params.id, directory)
.then((response) => {
this.files = response.files;
this.directories = response.directories;
this.editableFiles = response.editable;
this.errorMessage = null;
})
.catch((err) => {
if (typeof err === 'string') {
this.errorMessage = err;
return;
}
console.error('An error was encountered while processing this request.', {err});
})
.then(() => {
this.loading = false;
});
},
openNewFolderModal: function () {
window.events.$emit('server:files:open-directory-modal');
},
openNewFileModal: function () {
window.events.$emit('server:files:open-edit-file-modal');
},
fileRowDeleted: function (file: DirectoryContentObject, directory: boolean) {
if (directory) {
this.directories = this.directories.filter(data => data !== file);
} else {
this.files = this.files.filter(data => data !== file);
}
},
directoryCreated: function (directory: string) {
this.$router.push({ name: 'server-files', params: { path: join(this.currentDirectory, directory) }});
},
},
});
</script>

View file

@ -1 +0,0 @@
ziggy.js

View file

@ -1,22 +0,0 @@
import axios, {AxiosResponse} from 'axios';
/**
* We'll load the axios HTTP library which allows us to easily issue requests
* to our Laravel back-end. This library automatically handles sending the
* CSRF token as a header based on the value of the "XSRF" token cookie.
*/
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
axios.defaults.headers.common['Accept'] = 'application/json';
// Attach the response data to phpdebugbar so that we can see everything happening.
// @ts-ignore
if (typeof phpdebugbar !== 'undefined') {
axios.interceptors.response.use(function (response: AxiosResponse) {
// @ts-ignore
phpdebugbar.ajaxHandler.handle(response.request);
return response;
});
}
export default axios;

View file

@ -1,28 +0,0 @@
import {format} from 'date-fns';
/**
* Return the human readable filesize for a given number of bytes. This
* uses 1024 as the base, so the response is denoted accordingly.
*/
export function readableSize(bytes: number): string {
if (Math.abs(bytes) < 1024) {
return `${bytes} Bytes`;
}
let u: number = -1;
const units: Array<string> = ['KiB', 'MiB', 'GiB', 'TiB'];
do {
bytes /= 1024;
u++;
} while (Math.abs(bytes) >= 1024 && u < units.length - 1);
return `${bytes.toFixed(1)} ${units[u]}`;
}
/**
* Format the given date as a human readable string.
*/
export function formatDate(date: string): string {
return format(date, 'MMM D, YYYY [at] HH:MM');
}

View file

@ -1,6 +0,0 @@
export default {
STATUS_OFF: 'offline',
STATUS_ON: 'running',
STATUS_STARTING: 'starting',
STATUS_STOPPING: 'stopping',
};

File diff suppressed because one or more lines are too long

View file

@ -1,58 +0,0 @@
import {ComponentOptions} from "vue";
import {Vue} from "vue/types/vue";
import {TranslateResult} from "vue-i18n";
export interface FlashInterface {
flash(message: string | TranslateResult, title: string, severity: string): void;
clear(): void,
success(message: string | TranslateResult): void,
info(message: string | TranslateResult): void,
warning(message: string | TranslateResult): void,
error(message: string | TranslateResult): void,
}
class Flash implements FlashInterface {
flash(message: string, title: string, severity: string = 'info'): void {
severity = severity || 'info';
if (['danger', 'fatal', 'error'].includes(severity)) {
severity = 'error';
}
// @ts-ignore
window.events.$emit('flash', {message, title, severity});
}
clear(): void {
// @ts-ignore
window.events.$emit('clear-flashes');
}
success(message: string): void {
this.flash(message, 'Success', 'success');
}
info(message: string): void {
this.flash(message, 'Info', 'info');
}
warning(message: string): void {
this.flash(message, 'Warning', 'warning');
}
error(message: string): void {
this.flash(message, 'Error', 'error');
}
}
export const FlashMixin: ComponentOptions<Vue> = {
computed: {
'$flash': function () {
return new Flash();
}
},
};

View file

@ -1,228 +0,0 @@
import {camelCase} from 'lodash';
import SocketEmitter from './emitter';
import {Store} from "vuex";
const SOCKET_CONNECT = 'connect';
const SOCKET_ERROR = 'error';
const SOCKET_DISCONNECT = 'disconnect';
// This is defined in the wings daemon code and referenced here so that it is obvious
// where we are pulling these random data objects from.
type WingsWebsocketResponse = {
event: string,
args: Array<string>
}
export default class SocketioConnector {
/**
* The socket instance.
*/
socket: null | WebSocket;
/**
* The vuex store being used to persist data and socket state.
*/
store: Store<any> | undefined;
/**
* Tracks a reconnect attempt for the websocket. Will gradually back off on attempts
* after a certain period of time has elapsed.
*/
private reconnectTimeout: any;
/**
* Tracks the number of reconnect attempts which is used to determine the backoff
* throttle for connections.
*/
private reconnectAttempts: number = 0;
private socketProtocol?: string;
private socketUrl?: string;
constructor(store: Store<any> | undefined) {
this.socket = null;
this.store = store;
}
/**
* Initialize a new Socket connection.
*/
public connect(url: string, protocol?: string): void {
this.socketUrl = url;
this.socketProtocol = protocol;
this.connectToSocket()
.then(socket => {
this.socket = socket;
this.emitAndPassToStore(SOCKET_CONNECT);
this.registerEventListeners();
})
.catch(() => this.reconnectToSocket());
}
/**
* Return the socket instance we are working with.
*/
public instance(): WebSocket | null {
return this.socket;
}
/**
* Sends an event along to the websocket. If there is no active connection, a void
* result is returned.
*/
public emit(event: string, payload?: string | Array<string>): void | false {
if (!this.socket) {
return false
}
this.socket.send(JSON.stringify({
event, args: typeof payload === 'string' ? [payload] : payload
}));
}
/**
* Register the event listeners for this socket including user-defined ones in the store as
* well as global system events from Socekt.io.
*/
protected registerEventListeners() {
if (!this.socket) {
return;
}
this.socket.onclose = () => {
this.reconnectToSocket();
this.emitAndPassToStore(SOCKET_DISCONNECT);
};
this.socket.onerror = () => {
if (this.socket && this.socket.readyState !== WebSocket.OPEN) {
this.emitAndPassToStore(SOCKET_ERROR, ['Failed to connect to websocket.']);
}
};
this.socket.onmessage = (wse): void => {
try {
let {event, args}: WingsWebsocketResponse = JSON.parse(wse.data);
this.emitAndPassToStore(event, args);
} catch (ex) {
// do nothing, bad JSON response
console.error(ex);
return
}
};
}
/**
* Performs an actual socket connection, wrapped as a Promise for an easier interface.
*/
protected connectToSocket(): Promise<WebSocket> {
return new Promise((resolve, reject) => {
let hasReturned = false;
const socket = new WebSocket(this.socketUrl!, this.socketProtocol);
socket.onopen = () => {
if (hasReturned) {
socket && socket.close();
}
hasReturned = true;
this.resetConnectionAttempts();
resolve(socket);
};
const rejectFunc = () => {
if (!hasReturned) {
hasReturned = true;
this.emitAndPassToStore(SOCKET_ERROR, ['Failed to connect to websocket.']);
reject();
}
};
socket.onerror = rejectFunc;
socket.onclose = rejectFunc;
});
}
/**
* Attempts to reconnect to the socket instance if it becomes disconnected.
*/
private reconnectToSocket() {
const { socket } = this;
if (!socket) {
return;
}
// Clear the existing timeout if one exists for some reason.
this.reconnectTimeout && clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = setTimeout(() => {
console.warn(`Attempting to reconnect to websocket [${this.reconnectAttempts}]...`);
this.reconnectAttempts++;
this.connect(this.socketUrl!, this.socketProtocol);
}, this.getIntervalTimeout());
}
private resetConnectionAttempts() {
this.reconnectTimeout && clearTimeout(this.reconnectTimeout);
this.reconnectAttempts = 0;
}
/**
* Determine the amount of time we should wait before attempting to reconnect to the socket.
*/
private getIntervalTimeout(): number {
if (this.reconnectAttempts < 10) {
return 50;
} else if (this.reconnectAttempts < 25) {
return 500;
} else if (this.reconnectAttempts < 50) {
return 1000;
}
return 2500;
}
/**
* Emits the event over the event emitter and also passes it along to the vuex store.
*/
private emitAndPassToStore(event: string, payload?: Array<string>) {
payload ? SocketEmitter.emit(event, ...payload) : SocketEmitter.emit(event);
this.passToStore(event, payload);
}
/**
* Pass event calls off to the Vuex store if there is a corresponding function.
*/
private passToStore(event: string, payload?: Array<string>) {
if (!this.store) {
return;
}
const s: Store<any> = this.store;
const mutation = `SOCKET_${event.toUpperCase()}`;
const action = `socket_${camelCase(event)}`;
// @ts-ignore
Object.keys(this.store._mutations).filter((namespaced: string): boolean => {
return namespaced.split('/').pop() === mutation;
}).forEach((namespaced: string): void => {
s.commit(namespaced, payload ? this.unwrap(payload) : null);
});
// @ts-ignore
Object.keys(this.store._actions).filter((namespaced: string): boolean => {
return namespaced.split('/').pop() === action;
}).forEach((namespaced: string): void => {
s.dispatch(namespaced, payload ? this.unwrap(payload) : null).catch(console.error);
});
}
private unwrap(args: Array<string>) {
return (args && args.length <= 1) ? args[0] : args;
}
}

View file

@ -1,60 +0,0 @@
import {isFunction} from 'lodash';
import {ComponentOptions} from "vue";
import {Vue} from "vue/types/vue";
export default new class SocketEmitter {
listeners: Map<string | number, Array<{
callback: (a: ComponentOptions<Vue>) => void,
vm: ComponentOptions<Vue>,
}>>;
constructor() {
this.listeners = new Map();
}
/**
* Add an event listener for socket events.
*/
addListener(event: string | number, callback: (...data: any[]) => void, vm: ComponentOptions<Vue>) {
if (!isFunction(callback)) {
return;
}
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
// @ts-ignore
this.listeners.get(event).push({callback, vm});
}
/**
* Remove an event listener for socket events based on the context passed through.
*/
removeListener(event: string | number, callback: (...data: any[]) => void, vm: ComponentOptions<Vue>) {
if (!isFunction(callback) || !this.listeners.has(event)) {
return;
}
// @ts-ignore
const filtered = this.listeners.get(event).filter((listener) => {
return listener.callback !== callback || listener.vm !== vm;
});
if (filtered.length > 0) {
this.listeners.set(event, filtered);
} else {
this.listeners.delete(event);
}
}
/**
* Emit a socket event.
*/
emit(event: string | number, ...args: any) {
(this.listeners.get(event) || []).forEach((listener) => {
// @ts-ignore
listener.callback.call(listener.vm, ...args);
});
}
}

View file

@ -1,57 +0,0 @@
import SocketEmitter from './emitter';
import SocketioConnector from './connector';
import {ComponentOptions} from 'vue';
import {Vue} from "vue/types/vue";
let connector: SocketioConnector | null = null;
export const Socketio: ComponentOptions<Vue> = {
/**
* Setup the socket when we create the first component using the mixin. This is the Server.vue
* file, unless you mess up all of this code. Subsequent components to use this mixin will
* receive the existing connector instance, so it is very important that the top-most component
* calls the connectors destroy function when it is destroyed.
*/
created: function () {
if (!connector) {
connector = new SocketioConnector(this.$store);
}
const sockets = (this.$options || {}).sockets || {};
Object.keys(sockets).forEach((event) => {
SocketEmitter.addListener(event, sockets[event], this);
});
},
/**
* Before destroying the component we need to remove any event listeners registered for it.
*/
beforeDestroy: function () {
const sockets = (this.$options || {}).sockets || {};
Object.keys(sockets).forEach((event) => {
SocketEmitter.removeListener(event, sockets[event], this);
});
},
methods: {
'$socket': function (): SocketioConnector | null {
return connector;
},
/**
* Disconnects from the active socket and sets the connector to null.
*/
removeSocket: function () {
if (!connector) {
return;
}
const instance = connector.instance();
if (instance) {
instance.close();
}
connector = null;
},
},
};

View file

@ -1,87 +0,0 @@
type ServerAllocation = {
ip: string,
port: number,
};
type ServerLimits = {
memory: number,
swap: number,
disk: number,
io: number,
cpu: number,
}
type ServerFeatureLimits = {
databases: number,
allocations: number,
};
export type ServerData = {
identifier: string,
uuid: string,
name: string,
node: string,
description: string,
allocation: ServerAllocation,
limits: ServerLimits,
feature_limits: ServerFeatureLimits,
};
/**
* A model representing a server returned by the client API.
*/
export default class Server {
/**
* The server identifier, generally the 8-character representation of the server UUID.
*/
identifier: string;
/**
* The long form identifier for this server.
*/
uuid: string;
/**
* The human friendy name for this server.
*/
name: string;
/**
* The name of the node that this server belongs to.
*/
node: string;
/**
* A description of this server.
*/
description: string;
/**
* The primary allocation details for this server.
*/
allocation: ServerAllocation;
/**
* The base limits for this server when it comes to the actual docker container.
*/
limits: ServerLimits;
/**
* The feature limits for this server, database & allocations currently.
*/
featureLimits: ServerFeatureLimits;
/**
* Construct a new server model instance.
*/
constructor(data: ServerData) {
this.identifier = data.identifier;
this.uuid = data.uuid;
this.name = data.name;
this.node = data.node;
this.description = data.description;
this.allocation = data.allocation;
this.limits = data.limits;
this.featureLimits = data.feature_limits;
}
}

View file

@ -1,53 +0,0 @@
export type UserData = {
root_admin: boolean,
username: string,
email: string,
first_name: string,
last_name: string,
language: string,
};
/**
* A user model that represents an user in Pterodactyl.
*/
export default class User {
/**
* Determines wether or not the user is an admin.
*/
admin: boolean;
/**
* The username for the currently authenticated user.
*/
username: string;
/**
* The currently authenticated users email address.
*/
email: string;
/**
* The full name of the logged in user.
*/
name: string;
first_name: string;
last_name: string;
/**
* The language the user has selected to use.
*/
language: string;
/**
* Create a new user model.
*/
constructor(data: UserData) {
this.admin = data.root_admin;
this.username = data.username;
this.email = data.email;
this.name = `${data.first_name} ${data.last_name}`;
this.first_name = data.first_name;
this.last_name = data.last_name;
this.language = data.language;
}
}

View file

@ -1,44 +0,0 @@
import Vue from "vue";
import {Store} from "vuex";
import {FlashInterface} from "./mixins/flash";
import {AxiosInstance} from "axios";
import {Vue as VueType} from "vue/types/vue";
import {ApplicationState} from "./store/types";
import {Route} from "vue-router";
// @ts-ignore
import {Ziggy} from './helpers/ziggy';
declare global {
interface Window {
X_CSRF_TOKEN: string,
_: any,
$: any,
jQuery: any,
axios: AxiosInstance,
events: VueType,
Ziggy: Ziggy,
}
}
declare module 'vue/types/options' {
interface ComponentOptions<V extends Vue> {
$store?: Store<ApplicationState>,
$options?: {
sockets?: {
[s: string]: (data: any) => void,
}
},
sockets?: {
[s: string]: (data: any) => void,
}
}
}
declare module 'vue/types/vue' {
interface Vue {
$route: Route,
$store: Store<any>,
$flash: FlashInterface,
route: (name: string, params?: object, absolute?: boolean) => string,
}
}

View file

@ -1,80 +0,0 @@
import VueRouter, {Route} from 'vue-router';
import store from './store/index';
import User from './models/user';
// Base Vuejs Templates
import Login from './components/auth/Login.vue';
import Dashboard from './components/dashboard/Dashboard.vue';
import Account from './components/dashboard/Account.vue';
import ResetPassword from './components/auth/ResetPassword.vue';
import LoginForm from "@/components/auth/LoginForm.vue";
import ForgotPassword from "@/components/auth/ForgotPassword.vue";
import TwoFactorForm from "@/components/auth/TwoFactorForm.vue";
import Server from "@/components/server/Server.vue";
import ConsolePage from "@/components/server/subpages/Console.vue";
import FileManagerPage from "@/components/server/subpages/FileManager.vue";
import DatabasesPage from "@/components/server/subpages/Databases.vue";
const route = require('./../../../vendor/tightenco/ziggy/src/js/route').default;
const routes = [
{
path: '/auth', component: Login,
children: [
{name: 'login', path: 'login', component: LoginForm},
{name: 'forgot-password', path: 'password', component: ForgotPassword},
{name: 'checkpoint', path: 'checkpoint', component: TwoFactorForm},
]
},
{
name: 'reset-password',
path: '/auth/password/reset/:token',
component: ResetPassword,
props: function (route: Route) {
return {token: route.params.token, email: route.query.email || ''};
},
},
{name: 'dashboard', path: '/', component: Dashboard},
{name: 'account', path: '/account', component: Account},
{name: 'account.api', path: '/account/api', component: Account},
{name: 'account.security', path: '/account/security', component: Account},
{
path: '/server/:id', component: Server,
children: [
{name: 'server', path: '', component: ConsolePage},
{name: 'server-files', path: 'files/:path(.*)?', component: FileManagerPage},
// {name: 'server-subusers', path: 'subusers', component: ServerSubusers},
// {name: 'server-schedules', path: 'schedules', component: ServerSchedules},
{name: 'server-databases', path: 'databases', component: DatabasesPage},
// {name: 'server-allocations', path: 'allocations', component: ServerAllocations},
// {name: 'server-settings', path: 'settings', component: ServerSettings},
],
},
];
const router = new VueRouter({
mode: 'history', routes,
});
// Redirect the user to the login page if they try to access a protected route and
// have no JWT or the JWT is expired and wouldn't be accepted by the Panel.
router.beforeEach((to, from, next) => {
if (to.path === route('auth.logout')) {
return window.location = route('auth.logout');
}
const user = store.getters['auth/getUser'];
// Check that if we're accessing a non-auth route that a user exists on the page.
if (!to.path.startsWith('/auth') && !(user instanceof User)) {
store.commit('auth/logout');
return window.location = route('auth.logout');
}
// Continue on through the pipeline.
return next();
});
export default router;

View file

@ -1,34 +0,0 @@
import Vue from 'vue';
import Vuex from 'vuex';
import auth from './modules/auth';
import dashboard from './modules/dashboard';
import server from './modules/server';
import socket from './modules/socket';
import {ApplicationState} from "./types";
Vue.use(Vuex);
const store = new Vuex.Store<ApplicationState>({
strict: process.env.NODE_ENV !== 'production',
modules: {auth, dashboard, server, socket},
});
if (module.hot) {
module.hot.accept(['./modules/auth'], () => {
const newAuthModule = require('./modules/auth').default;
const newDashboardModule = require('./modules/dashboard').default;
const newServerModule = require('./modules/server').default;
const newSocketModule = require('./modules/socket').default;
store.hotUpdate({
modules: {
auth: newAuthModule,
dashboard: newDashboardModule,
server: newServerModule,
socket: newSocketModule
},
});
});
}
export default store;

View file

@ -1,106 +0,0 @@
import User, {UserData} from '../../models/user';
import {ActionContext} from "vuex";
import {AuthenticationState} from "../types";
const route = require('./../../../../../vendor/tightenco/ziggy/src/js/route').default;
type LoginAction = {
user: string,
password: string,
}
type UpdateEmailAction = {
email: string,
password: string,
}
export default {
namespaced: true,
state: {
// @ts-ignore
user: typeof window.PterodactylUser === 'object' ? new User(window.PterodactylUser) : null,
},
getters: {
/**
* Return the currently authenticated user.
*/
getUser: function (state: AuthenticationState): null | User {
return state.user;
},
},
setters: {},
actions: {
/**
* Log a user into the Panel.
*/
login: ({commit}: ActionContext<AuthenticationState, any>, {user, password}: LoginAction): Promise<{
complete: boolean,
intended?: string,
token?: string,
}> => {
return new Promise((resolve, reject) => {
// @ts-ignore
window.axios.post(route('auth.login'), {user, password})
// @ts-ignore
.then(response => {
commit('logout');
// If there is a 302 redirect or some other odd behavior (basically, response that isnt
// in JSON format) throw an error and don't try to continue with the login.
if (!(response.data instanceof Object)) {
return reject(new Error('An error was encountered while processing this request.'));
}
if (response.data.complete) {
commit('login', response.data.user);
return resolve({
complete: true,
intended: response.data.intended,
});
}
return resolve({
complete: false,
token: response.data.login_token,
});
})
.catch(reject);
});
},
/**
* Update a user's email address on the Panel and store the updated result in Vuex.
*/
updateEmail: function ({commit}: ActionContext<AuthenticationState, any>, {email, password}: UpdateEmailAction): Promise<void> {
return new Promise((resolve, reject) => {
// @ts-ignore
window.axios.put(route('api.client.account.update-email'), {email, password})
// @ts-ignore
.then(response => {
// If there is a 302 redirect or some other odd behavior (basically, response that isnt
// in JSON format) throw an error and don't try to continue with the login.
if (!(response.data instanceof Object) && response.status !== 201) {
return reject(new Error('An error was encountered while processing this request.'));
}
commit('setEmail', email);
return resolve();
})
.catch(reject);
});
},
},
mutations: {
setEmail: function (state: AuthenticationState, email: string) {
if (state.user) {
state.user.email = email;
}
},
login: function (state: AuthenticationState, data: UserData) {
state.user = new User(data);
},
logout: function (state: AuthenticationState) {
state.user = null;
},
},
};

View file

@ -1,66 +0,0 @@
import Server, {ServerData} from '../../models/server';
import {ActionContext} from "vuex";
import {DashboardState} from "../types";
const route = require('./../../../../../vendor/tightenco/ziggy/src/js/route').default;
export default {
namespaced: true,
state: {
servers: [],
searchTerm: '',
},
getters: {
getSearchTerm: function (state: DashboardState): string {
return state.searchTerm;
},
},
actions: {
/**
* Retrieve all of the servers for a user matching the query.
*/
loadServers: ({commit, state}: ActionContext<DashboardState, any>): Promise<void> => {
return new Promise((resolve, reject) => {
// @ts-ignore
window.axios.get(route('api.client.index'), {
params: {query: state.searchTerm},
})
// @ts-ignore
.then(response => {
// If there is a 302 redirect or some other odd behavior (basically, response that isnt
// in JSON format) throw an error and don't try to continue with the request processing.
if (!(response.data instanceof Object)) {
return reject(new Error('An error was encountered while processing this request.'));
}
// Remove all of the existing servers.
commit('clearServers');
response.data.data.forEach((obj: { attributes: ServerData }) => {
commit('addServer', obj.attributes);
});
resolve();
})
.catch(reject);
});
},
setSearchTerm: ({commit}: ActionContext<DashboardState, any>, term: string) => {
commit('setSearchTerm', term);
},
},
mutations: {
addServer: function (state: DashboardState, data: ServerData) {
state.servers.push(
new Server(data)
);
},
clearServers: function (state: DashboardState) {
state.servers = [];
},
setSearchTerm: function (state: DashboardState, term: string) {
state.searchTerm = term;
},
},
};

View file

@ -1,93 +0,0 @@
// @ts-ignore
import route from '../../../../../vendor/tightenco/ziggy/src/js/route';
import {ActionContext} from "vuex";
import {ServerData} from "@/models/server";
import {ServerApplicationCredentials, ServerState} from "../types";
export default {
namespaced: true,
state: {
server: {},
credentials: {node: '', key: ''},
console: [],
fm: {
currentDirectory: '/',
},
},
getters: {},
actions: {
/**
* Fetches the active server from the API and stores it in vuex.
*/
getServer: ({commit}: ActionContext<ServerState, any>, {server}: { server: string }): Promise<void> => {
return new Promise((resolve, reject) => {
// @ts-ignore
window.axios.get(route('api.client.servers.view', {server}))
// @ts-ignore
.then(response => {
// If there is a 302 redirect or some other odd behavior (basically, response that isnt
// in JSON format) throw an error and don't try to continue with the login.
if (!(response.data instanceof Object)) {
return reject(new Error('An error was encountered while processing this request.'));
}
if (response.data.object === 'server' && response.data.attributes) {
commit('SERVER_DATA', response.data.attributes)
}
return resolve();
})
.catch(reject);
});
},
/**
* Get authentication credentials that the client should use when connecting to the daemon to
* retrieve server information.
*/
getCredentials: ({commit}: ActionContext<ServerState, any>, {server}: { server: string }) => {
return new Promise((resolve, reject) => {
// @ts-ignore
window.axios.get(route('server.credentials', {server}))
// @ts-ignore
.then(response => {
// If there is a 302 redirect or some other odd behavior (basically, response that isnt
// in JSON format) throw an error and don't try to continue with the login.
if (!(response.data instanceof Object)) {
return reject(new Error('An error was encountered while processing this request.'));
}
if (response.data.key) {
commit('SERVER_CREDENTIALS', response.data)
}
return resolve();
})
.catch(reject);
});
},
/**
* Update the last viewed directory for the server the user is currently viewing. This allows
* us to quickly navigate back to that directory, as well as ensure that actions taken are
* performed aganist the correct directory without having to pass that mess everywhere.
*/
updateCurrentDirectory: ({commit}: ActionContext<ServerState, any>, directory: string) => {
commit('SET_CURRENT_DIRECTORY', directory);
},
},
mutations: {
SET_CURRENT_DIRECTORY: function (state: ServerState, directory: string) {
state.fm.currentDirectory = directory;
},
SERVER_DATA: function (state: ServerState, data: ServerData) {
state.server = data;
},
SERVER_CREDENTIALS: function (state: ServerState, credentials: ServerApplicationCredentials) {
state.credentials = credentials;
},
CONSOLE_DATA: function (state: ServerState, data: string) {
state.console.push(data);
},
},
}

View file

@ -1,47 +0,0 @@
import Status from '../../helpers/statuses';
import {SocketState} from "../types";
export default {
namespaced: true,
state: {
connected: false,
connectionError: false,
status: Status.STATUS_OFF,
outputBuffer: [],
},
mutations: {
SOCKET_CONNECT: (state: SocketState) => {
state.connected = true;
state.connectionError = false;
},
SOCKET_ERROR: (state: SocketState, err: Error) => {
state.connected = false;
state.connectionError = err;
},
'SOCKET_INITIAL STATUS': (state: SocketState, data: string) => {
state.status = data;
},
SOCKET_STATUS: (state: SocketState, data: string) => {
state.status = data;
},
'SOCKET_CONSOLE OUTPUT': (state: SocketState, data: string) => {
const { outputBuffer } = state;
if (outputBuffer.length >= 500) {
// Pop all of the output buffer items off the front until we only have 499
// items in the array.
for (let i = 0; i <= (outputBuffer.length - 500); i++) {
outputBuffer.shift();
i++;
}
}
outputBuffer.push(data);
state.outputBuffer = outputBuffer;
},
},
};

View file

@ -1,42 +0,0 @@
import Server, {ServerData} from "../models/server";
import User from "../models/user";
export type ApplicationState = {
socket: SocketState,
server: ServerState,
auth: AuthenticationState,
dashboard: DashboardState,
}
export type SocketState = {
connected: boolean,
connectionError: boolean | Error,
status: string,
outputBuffer: string[],
}
export type ServerApplicationCredentials = {
node: string,
key: string,
};
export type FileManagerState = {
currentDirectory: string,
}
export type ServerState = {
server: ServerData,
credentials: ServerApplicationCredentials,
console: Array<string>,
fm: FileManagerState,
};
export type DashboardState = {
searchTerm: string,
servers: Array<Server>,
};
export type AuthenticationState = {
user: null | User,
}

View file

@ -1,4 +0,0 @@
declare module '*.vue' {
import Vue from 'vue'
export default Vue
}

View file

@ -1,68 +0,0 @@
.animate {
&.fadein {
animation: fadein 500ms;
}
}
.animated-fade-in {
animation: fadein 500ms;
}
.fade-enter-active {
animation: fadein 500ms;
}
.fade-leave-active {
animation: fadein 500ms reverse;
}
@keyframes fadein {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes onlineblink {
0% {
@apply .bg-green-500;
}
100% {
@apply .bg-green-600;
}
}
@keyframes offlineblink {
0% {
@apply .bg-red-500;
}
100% {
@apply .bg-red-600;
}
}
/*
* transition="modal"
*/
.modal-enter, .modal-leave-active {
opacity: 0;
}
.modal-enter .modal-container,
.modal-leave-active .modal-container {
animation: opacity 250ms linear;
}
/**
* name="slide-fade" mode="out-in"
*/
.slide-fade-enter-active {
transition: all 250ms ease;
}
.slide-fade-leave-active {
transition: all 250ms cubic-bezier(1.0, 0.5, 0.8, 1.0);
}
.slide-fade-enter, .slide-fade-leave-to {
transform: translateX(10px);
opacity: 0;
}

View file

@ -1,117 +0,0 @@
.nav {
@apply .bg-primary-600 .border-b .border-t .border-primary-700;
height: 56px;
& .logo {
@apply .mr-8 .font-sans .font-thin .text-3xl .text-white .inline-block;
& a {
color: inherit;
text-decoration: none;
}
@screen xsx {
@apply .hidden;
}
}
& .search-box {
@apply .mr-2;
& > .search-input {
@apply .text-sm .p-2 .ml-8 .rounded .border .border-primary-600 .bg-white .text-neutral-900 .w-96;
transition: border 150ms ease-in;
&:focus {
@apply .border-primary-700;
}
&.has-search-results {
@apply .border-b-0 .rounded-b-none;
}
}
& .search-results {
@apply .absolute .bg-white .border .border-primary-700 .border-t-0 .rounded .rounded-t-none .p-2 .ml-8 .z-50 .w-96;
& a {
@apply .block .no-underline .p-2 .rounded;
&:not(.no-hover):hover {
@apply .bg-neutral-50;
}
}
}
}
& .menu {
@apply .flex .h-full .items-center;
& > a {
transition: background-color 150ms linear;
@apply .block .flex .self-stretch .items-center .no-underline .text-white .font-light .text-sm .px-5;
&:hover {
@apply .bg-primary-700;
}
}
}
}
.sidenav {
ul {
@apply .list-reset;
& li {
@apply .block;
& > a {
transition: border-left-color 250ms linear, color 250ms linear;
@apply .block .px-4 .py-3 .border-l-3 .border-neutral-100 .no-underline .text-neutral-400 .font-medium;
&:hover, &.router-link-exact-active, &.router-link-active {
@apply .text-neutral-800;
}
&.router-link-exact-active, &.router-link-active {
@apply .border-primary-500 .cursor-default;
}
&::-moz-focus-inner {
@apply .border-none;
}
}
/**
* Because of how the router works the first sidebar link is always active
* since that is the container for all of the server things. Override the
* style for active links if its the first one and not an exact route match.
*/
&:first-of-type > a {
&.router-link-active:not(.router-link-exact-active) {
@apply .border-neutral-100 .text-neutral-400 .cursor-pointer;
}
}
}
}
}
/*
.sidenav {
@apply .py-2;
a {
@apply .block .py-3 .px-6 .text-neutral-900 .no-underline .border .border-transparent;
&:hover, &.router-link-exact-active {
@apply .border-neutral-400 .bg-neutral-50;
border-left: 1px solid transparent;
border-right: 1px solid transparent;
}
&.router-link-exact-active + a:hover {
border-top: 1px solid transparent;
}
}
}
*/

View file

@ -0,0 +1,36 @@
parser: "@typescript-eslint/parser"
parserOptions:
ecmaVersion: 6
project: "./tsconfig.json"
tsconfigRootDir: "./"
env:
browser: true
es6: true
plugins:
- "@typescript-eslint"
extends:
- "standard"
- "plugin:@typescript-eslint/recommended"
rules:
semi:
- error
- always
comma-dangle:
- error
- always-multiline
"@typescript-eslint/explicit-function-return-type": 0
"@typescript-eslint/explicit-member-accessibility": 0
"@typescript-eslint/no-unused-vars": 0
"@typescript-eslint/no-explicit-any": 0
"@typescript-eslint/no-non-null-assertion": 0
overrides:
- files:
- "**/*.tsx"
rules:
operator-linebreak:
- error
- before
- overrides:
"&&": "after"
"?": "ignore"
":": "ignore"

View file

@ -0,0 +1,32 @@
import React from 'react';
import { Route } from 'react-router';
import { CSSTransition, TransitionGroup } from 'react-transition-group';
type Props = Readonly<{
children: React.ReactNode;
}>;
export default ({ children }: Props) => (
<Route
render={({ location }) => (
<TransitionGroup className={'route-transition-group'}>
<CSSTransition key={location.key} timeout={250} classNames={'fade'}>
<section>
{children}
<div className={'mx-auto w-full'} style={{ maxWidth: '1200px' }}>
<p className={'text-right text-neutral-500 text-xs'}>
&copy; 2015 - 2019&nbsp;
<a
href={'https://pterodactyl.io'}
className={'no-underline text-neutral-500 hover:text-neutral-300'}
>
Pterodactyl Software
</a>
</p>
</div>
</section>
</CSSTransition>
</TransitionGroup>
)}
/>
);

View file

@ -0,0 +1,9 @@
import http from '@/api/http';
export default (email: string, password: string): Promise<void> => {
return new Promise((resolve, reject) => {
http.put('/api/client/account/email', { email, password })
.then(() => resolve())
.catch(reject);
});
};

View file

@ -0,0 +1,21 @@
import http from '@/api/http';
interface Data {
current: string;
password: string;
confirmPassword: string;
}
export default ({ current, password, confirmPassword }: Data): Promise<void> => {
return new Promise((resolve, reject) => {
http.put('/api/client/account/password', {
// eslint-disable-next-line @typescript-eslint/camelcase
current_password: current,
password: password,
// eslint-disable-next-line @typescript-eslint/camelcase
password_confirmation: confirmPassword,
})
.then(() => resolve())
.catch(reject);
});
};

View file

@ -0,0 +1,25 @@
import http from '@/api/http';
export interface LoginResponse {
complete: boolean;
intended?: string;
confirmationToken?: string;
}
export default (user: string, password: string): Promise<LoginResponse> => {
return new Promise((resolve, reject) => {
http.post('/auth/login', { user, password })
.then(response => {
if (!(response.data instanceof Object)) {
return reject(new Error('An error occurred while processing the login request.'));
}
return resolve({
complete: response.data.data.complete,
intended: response.data.data.intended || undefined,
confirmationToken: response.data.data.confirmation_token || undefined,
});
})
.catch(reject);
});
};

View file

@ -0,0 +1,18 @@
import http from '@/api/http';
import { LoginResponse } from '@/api/auth/login';
export default (token: string, code: string): Promise<LoginResponse> => {
return new Promise((resolve, reject) => {
http.post('/auth/login/checkpoint', {
// eslint-disable-next-line @typescript-eslint/camelcase
confirmation_token: token,
// eslint-disable-next-line @typescript-eslint/camelcase
authentication_code: code,
})
.then(response => resolve({
complete: response.data.data.complete,
intended: response.data.data.intended || undefined,
}))
.catch(reject);
});
};

View file

@ -0,0 +1,29 @@
import http from '@/api/http';
interface Data {
token: string;
password: string;
passwordConfirmation: string;
}
interface PasswordResetResponse {
redirectTo?: string | null;
sendToLogin: boolean;
}
export default (email: string, data: Data): Promise<PasswordResetResponse> => {
return new Promise((resolve, reject) => {
http.post('/auth/password/reset', {
email,
token: data.token,
password: data.password,
// eslint-disable-next-line @typescript-eslint/camelcase
password_confirmation: data.passwordConfirmation,
})
.then(response => resolve({
redirectTo: response.data.redirect_to,
sendToLogin: response.data.send_to_login,
}))
.catch(reject);
});
};

View file

@ -0,0 +1,9 @@
import http from '@/api/http';
export default (email: string): Promise<string> => {
return new Promise((resolve, reject) => {
http.post('/auth/password', { email })
.then(response => resolve(response.data.status || ''))
.catch(reject);
});
};

View file

@ -1,5 +1,4 @@
import axios, {AxiosError, AxiosInstance} from 'axios'; import axios, { AxiosInstance } from 'axios';
import {ServerApplicationCredentials} from "@/store/types";
// This token is set in the bootstrap.js file at the beginning of the request // This token is set in the bootstrap.js file at the beginning of the request
// and is carried through from there. // and is carried through from there.
@ -10,6 +9,7 @@ const http: AxiosInstance = axios.create({
'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 || '',
}, },
}); });
@ -27,23 +27,11 @@ if (typeof window.phpdebugbar !== 'undefined') {
export default http; export default http;
/**
* Creates a request object for the node that uses the server UUID and connection
* credentials. Basically just a tiny wrapper to set this quickly.
*/
export function withCredentials(server: string, credentials: ServerApplicationCredentials): AxiosInstance {
http.defaults.baseURL = credentials.node;
http.defaults.headers['X-Access-Server'] = server;
http.defaults.headers['X-Access-Token'] = credentials.key;
return http;
}
/** /**
* Converts an error into a human readable response. Mostly just a generic helper to * Converts an error into a human readable response. Mostly just a generic helper to
* make sure we display the message from the server back to the user if we can. * make sure we display the message from the server back to the user if we can.
*/ */
export function httpErrorToHuman(error: any): string { export function httpErrorToHuman (error: any): string {
if (error.response && error.response.data) { if (error.response && error.response.data) {
const { data } = error.response; const { data } = error.response;
if (data.errors && data.errors[0] && data.errors[0].detail) { if (data.errors && data.errors[0] && data.errors[0].detail) {

View file

@ -0,0 +1,55 @@
import * as React from 'react';
import { hot } from 'react-hot-loader/root';
import { BrowserRouter, BrowserRouter as Router, Route, Switch } from 'react-router-dom';
import { StoreProvider } from 'easy-peasy';
import { store } from '@/state';
import DashboardRouter from '@/routers/DashboardRouter';
import ServerRouter from '@/routers/ServerRouter';
import AuthenticationRouter from '@/routers/AuthenticationRouter';
interface WindowWithUser extends Window {
PterodactylUser?: {
uuid: string;
username: string;
email: string;
root_admin: boolean;
use_totp: boolean;
language: string;
updated_at: string;
created_at: string;
};
}
const App = () => {
const data = (window as WindowWithUser).PterodactylUser;
if (data && !store.getState().user.data) {
store.getActions().user.setUserData({
uuid: data.uuid,
username: data.username,
email: data.email,
language: data.language,
rootAdmin: data.root_admin,
useTotp: data.use_totp,
createdAt: new Date(data.created_at),
updatedAt: new Date(data.updated_at),
});
}
return (
<StoreProvider store={store}>
<Router basename={'/'}>
<div className={'mx-auto w-auto'}>
<BrowserRouter basename={'/'}>
<Switch>
<Route path="/server/:id" component={ServerRouter}/>
<Route path="/auth" component={AuthenticationRouter}/>
<Route path="/" component={DashboardRouter}/>
</Switch>
</BrowserRouter>
</div>
</Router>
</StoreProvider>
);
};
export default hot(App);

View file

@ -0,0 +1,39 @@
import React from 'react';
import MessageBox from '@/components/MessageBox';
import { State, useStoreState } from 'easy-peasy';
import { ApplicationState } from '@/state/types';
type Props = Readonly<{
byKey?: string;
spacerClass?: string;
withBottomSpace?: boolean;
}>;
export default ({ withBottomSpace, spacerClass, byKey }: Props) => {
const flashes = useStoreState((state: State<ApplicationState>) => state.flashes.items);
let filtered = flashes;
if (byKey) {
filtered = flashes.filter(flash => flash.key === byKey);
}
if (filtered.length === 0) {
return null;
}
// noinspection PointlessBooleanExpressionJS
return (
<div className={withBottomSpace === false ? undefined : 'mb-2'}>
{
filtered.map((flash, index) => (
<React.Fragment key={flash.id || flash.type + flash.message}>
{index > 0 && <div className={spacerClass || 'mt-2'}></div>}
<MessageBox type={flash.type} title={flash.title}>
{flash.message}
</MessageBox>
</React.Fragment>
))
}
</div>
);
};

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