forked from Alex/Pterodactyl-Panel
Merge branch 'feature/react' into develop
This commit is contained in:
commit
6618a124e7
2
.gitignore
vendored
2
.gitignore
vendored
@ -12,7 +12,7 @@ node_modules
|
||||
_ide_helper.php
|
||||
.phpstorm.meta.php
|
||||
.php_cs.cache
|
||||
public/assets/*
|
||||
public/assets/manifest.json
|
||||
|
||||
# For local development with docker
|
||||
# Remove if we ever put the Dockerfile in the repo
|
||||
|
15
CHANGELOG.md
15
CHANGELOG.md
@ -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.
|
||||
|
||||
## 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)
|
||||
### Fixed
|
||||
* Fixes a bug with the location update API endpoint throwing an error due to an unexected response value.
|
||||
|
@ -6,45 +6,17 @@ use Illuminate\Http\Request;
|
||||
use Pterodactyl\Models\User;
|
||||
use Illuminate\Auth\AuthManager;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use PragmaRX\Google2FA\Google2FA;
|
||||
use Illuminate\Auth\Events\Failed;
|
||||
use Illuminate\Contracts\Config\Repository;
|
||||
use Pterodactyl\Exceptions\DisplayException;
|
||||
use Pterodactyl\Http\Controllers\Controller;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
use Illuminate\Contracts\Encryption\Encrypter;
|
||||
use Illuminate\Foundation\Auth\AuthenticatesUsers;
|
||||
use Illuminate\Contracts\Cache\Repository as CacheRepository;
|
||||
use Pterodactyl\Contracts\Repository\UserRepositoryInterface;
|
||||
|
||||
abstract class AbstractLoginController extends Controller
|
||||
{
|
||||
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.
|
||||
*
|
||||
@ -66,30 +38,29 @@ abstract class AbstractLoginController extends Controller
|
||||
*/
|
||||
protected $redirectTo = '/';
|
||||
|
||||
/**
|
||||
* @var \Illuminate\Auth\AuthManager
|
||||
*/
|
||||
protected $auth;
|
||||
|
||||
/**
|
||||
* @var \Illuminate\Contracts\Config\Repository
|
||||
*/
|
||||
protected $config;
|
||||
|
||||
/**
|
||||
* LoginController constructor.
|
||||
*
|
||||
* @param \Illuminate\Auth\AuthManager $auth
|
||||
* @param \Illuminate\Contracts\Cache\Repository $cache
|
||||
* @param \Illuminate\Contracts\Encryption\Encrypter $encrypter
|
||||
* @param \PragmaRX\Google2FA\Google2FA $google2FA
|
||||
* @param \Pterodactyl\Contracts\Repository\UserRepositoryInterface $repository
|
||||
* @param \Illuminate\Auth\AuthManager $auth
|
||||
* @param \Illuminate\Contracts\Config\Repository $config
|
||||
*/
|
||||
public function __construct(
|
||||
AuthManager $auth,
|
||||
CacheRepository $cache,
|
||||
Encrypter $encrypter,
|
||||
Google2FA $google2FA,
|
||||
UserRepositoryInterface $repository
|
||||
) {
|
||||
$this->auth = $auth;
|
||||
$this->cache = $cache;
|
||||
$this->encrypter = $encrypter;
|
||||
$this->google2FA = $google2FA;
|
||||
$this->repository = $repository;
|
||||
public function __construct(AuthManager $auth, Repository $config)
|
||||
{
|
||||
$this->lockoutTime = $config->get('auth.lockout.time');
|
||||
$this->maxLoginAttempts = $config->get('auth.lockout.attempts');
|
||||
|
||||
$this->lockoutTime = config('auth.lockout.time');
|
||||
$this->maxLoginAttempts = config('auth.lockout.attempts');
|
||||
$this->auth = $auth;
|
||||
$this->config = $config;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -128,10 +99,12 @@ abstract class AbstractLoginController extends Controller
|
||||
|
||||
$this->auth->guard()->login($user, true);
|
||||
|
||||
return response()->json([
|
||||
'complete' => true,
|
||||
'intended' => $this->redirectPath(),
|
||||
'user' => $user->toVueObject(),
|
||||
return JsonResponse::create([
|
||||
'data' => [
|
||||
'complete' => true,
|
||||
'intended' => $this->redirectPath(),
|
||||
'user' => $user->toVueObject(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
|
@ -33,10 +33,11 @@ class ForgotPasswordController extends Controller
|
||||
/**
|
||||
* Get the response for a successful password reset link.
|
||||
*
|
||||
* @param string $response
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @param string $response
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*/
|
||||
protected function sendResetLinkResponse($response): JsonResponse
|
||||
protected function sendResetLinkResponse(Request $request, $response): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'status' => trans($response),
|
||||
|
@ -2,12 +2,64 @@
|
||||
|
||||
namespace Pterodactyl\Http\Controllers\Auth;
|
||||
|
||||
use Illuminate\Auth\AuthManager;
|
||||
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 Illuminate\Contracts\Cache\Repository as CacheRepository;
|
||||
use Pterodactyl\Contracts\Repository\UserRepositoryInterface;
|
||||
use Pterodactyl\Exceptions\Repository\RecordNotFoundException;
|
||||
|
||||
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
|
||||
* 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
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*
|
||||
* @throws \PragmaRX\Google2FA\Exceptions\IncompatibleWithGoogleAuthenticatorException
|
||||
* @throws \PragmaRX\Google2FA\Exceptions\InvalidCharactersException
|
||||
* @throws \PragmaRX\Google2FA\Exceptions\SecretKeyTooShortException
|
||||
* @throws \Pterodactyl\Exceptions\DisplayException
|
||||
*/
|
||||
public function __invoke(LoginCheckpointRequest $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$cache = $this->cache->pull($request->input('confirmation_token'), []);
|
||||
$user = $this->repository->find(array_get($cache, 'user_id', 0));
|
||||
$user = $this->repository->find(
|
||||
$this->cache->pull($request->input('confirmation_token'), 0)
|
||||
);
|
||||
} catch (RecordNotFoundException $exception) {
|
||||
return $this->sendFailedLoginResponse($request);
|
||||
}
|
||||
|
||||
if (array_get($cache, 'request_ip') !== $request->ip()) {
|
||||
return $this->sendFailedLoginResponse($request, $user);
|
||||
$decrypted = $this->encrypter->decrypt($user->totp_secret);
|
||||
$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(
|
||||
$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);
|
||||
return $this->sendFailedLoginResponse($request, $user);
|
||||
}
|
||||
}
|
||||
|
@ -2,13 +2,57 @@
|
||||
|
||||
namespace Pterodactyl\Http\Controllers\Auth;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Auth\AuthManager;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
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;
|
||||
|
||||
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
|
||||
* base authentication view component. Vuejs will take over at this point and
|
||||
@ -18,7 +62,7 @@ class LoginController extends AbstractLoginController
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
$token = str_random(128);
|
||||
$this->cache->put($token, [
|
||||
'user_id' => $user->id,
|
||||
'request_ip' => $request->ip(),
|
||||
], 5);
|
||||
$token = Str::random(64);
|
||||
$this->cache->put($token, $user->id, 5);
|
||||
|
||||
return response()->json([
|
||||
'complete' => false,
|
||||
'login_token' => $token,
|
||||
return JsonResponse::create([
|
||||
'data' => [
|
||||
'complete' => false,
|
||||
'confirmation_token' => $token,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
$this->auth->guard()->login($user, true);
|
||||
|
||||
return $this->sendLoginResponse($user, $request);
|
||||
}
|
||||
}
|
||||
|
@ -83,8 +83,8 @@ class SecurityController extends Controller
|
||||
|
||||
return JsonResponse::create([
|
||||
'enabled' => false,
|
||||
'qr_image' => $response->get('image'),
|
||||
'secret' => $response->get('secret'),
|
||||
'qr_image' => $response,
|
||||
'secret' => '',
|
||||
]);
|
||||
}
|
||||
|
||||
|
@ -25,7 +25,7 @@ class LoginCheckpointRequest extends FormRequest
|
||||
{
|
||||
return [
|
||||
'confirmation_token' => 'required|string',
|
||||
'authentication_code' => 'required|int',
|
||||
'authentication_code' => 'required|numeric',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
@ -1,23 +1,18 @@
|
||||
<?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;
|
||||
|
||||
use Exception;
|
||||
use RuntimeException;
|
||||
use Pterodactyl\Models\User;
|
||||
use Illuminate\Support\Collection;
|
||||
use PragmaRX\Google2FAQRCode\Google2FA;
|
||||
use Illuminate\Contracts\Encryption\Encrypter;
|
||||
use Pterodactyl\Contracts\Repository\UserRepositoryInterface;
|
||||
use Illuminate\Contracts\Config\Repository as ConfigRepository;
|
||||
|
||||
class TwoFactorSetupService
|
||||
{
|
||||
const VALID_BASE32_CHARACTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
|
||||
/**
|
||||
* @var \Illuminate\Contracts\Config\Repository
|
||||
*/
|
||||
@ -28,11 +23,6 @@ class TwoFactorSetupService
|
||||
*/
|
||||
private $encrypter;
|
||||
|
||||
/**
|
||||
* @var PragmaRX\Google2FAQRCode\Google2FA
|
||||
*/
|
||||
private $google2FA;
|
||||
|
||||
/**
|
||||
* @var \Pterodactyl\Contracts\Repository\UserRepositoryInterface
|
||||
*/
|
||||
@ -43,43 +33,51 @@ class TwoFactorSetupService
|
||||
*
|
||||
* @param \Illuminate\Contracts\Config\Repository $config
|
||||
* @param \Illuminate\Contracts\Encryption\Encrypter $encrypter
|
||||
* @param PragmaRX\Google2FAQRCode\Google2FA $google2FA
|
||||
* @param \Pterodactyl\Contracts\Repository\UserRepositoryInterface $repository
|
||||
*/
|
||||
public function __construct(
|
||||
ConfigRepository $config,
|
||||
Encrypter $encrypter,
|
||||
Google2FA $google2FA,
|
||||
UserRepositoryInterface $repository
|
||||
) {
|
||||
$this->config = $config;
|
||||
$this->encrypter = $encrypter;
|
||||
$this->google2FA = $google2FA;
|
||||
$this->repository = $repository;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @return \Illuminate\Support\Collection
|
||||
* @return string
|
||||
*
|
||||
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
|
||||
* @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'));
|
||||
$image = $this->google2FA->getQRCodeInline($this->config->get('app.name'), $user->email, $secret);
|
||||
$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, [
|
||||
'totp_secret' => $this->encrypter->encrypt($secret),
|
||||
]);
|
||||
|
||||
return new Collection([
|
||||
'image' => $image,
|
||||
'secret' => $secret,
|
||||
]);
|
||||
$company = $this->config->get('app.name');
|
||||
|
||||
return sprintf(
|
||||
'otpauth://totp/%1$s:%2$s?secret=%3$s&issuer=%1$s',
|
||||
rawurlencode($company),
|
||||
rawurlencode($user->email),
|
||||
rawurlencode($secret)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -30,7 +30,6 @@
|
||||
"matriphe/iso-639": "^1.2",
|
||||
"nesbot/carbon": "^1.22",
|
||||
"pragmarx/google2fa": "^5.0",
|
||||
"pragmarx/google2fa-qrcode": "^1.0.3",
|
||||
"predis/predis": "^1.1",
|
||||
"prologue/alerts": "^0.4",
|
||||
"ramsey/uuid": "^3.7",
|
||||
|
@ -9,7 +9,7 @@ return [
|
||||
| change this value if you are not maintaining your own internal versions.
|
||||
*/
|
||||
|
||||
'version' => 'canary',
|
||||
'version' => '0.7.14',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
@ -1,6 +1,5 @@
|
||||
<?php
|
||||
|
||||
|
||||
return [
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
65
package.json
65
package.json
@ -1,39 +1,57 @@
|
||||
{
|
||||
"name": "pterodactyl-panel",
|
||||
"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",
|
||||
"brace": "^0.11.1",
|
||||
"classnames": "^2.2.6",
|
||||
"date-fns": "^1.29.0",
|
||||
"easy-peasy": "^2.5.0",
|
||||
"feather-icons": "^4.10.0",
|
||||
"formik": "^1.5.7",
|
||||
"jquery": "^3.3.1",
|
||||
"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",
|
||||
"vee-validate": "^2.1.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",
|
||||
"use-react-router": "^1.0.7",
|
||||
"ws-wrapper": "^2.0.0",
|
||||
"xterm": "^3.5.1"
|
||||
"xterm": "^3.5.1",
|
||||
"yup": "^0.27.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.2.3",
|
||||
"@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/preset-env": "^7.3.1",
|
||||
"@babel/preset-react": "^7.0.0",
|
||||
"@types/classnames": "^2.2.8",
|
||||
"@types/feather-icons": "^4.7.0",
|
||||
"@types/lodash": "^4.14.119",
|
||||
"@types/node": "^10.12.15",
|
||||
"@types/socket.io-client": "^1.4.32",
|
||||
"@types/query-string": "^6.3.0",
|
||||
"@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/yup": "^0.26.17",
|
||||
"@typescript-eslint/eslint-plugin": "^1.10.1",
|
||||
"@typescript-eslint/parser": "^1.10.1",
|
||||
"babel-loader": "^8.0.5",
|
||||
"clean-webpack-plugin": "^0.1.19",
|
||||
"css-loader": "^2.1.0",
|
||||
"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",
|
||||
"glob-all": "^3.1.0",
|
||||
"html-webpack-plugin": "^3.2.0",
|
||||
@ -45,31 +63,24 @@
|
||||
"precss": "^3.1.2",
|
||||
"purgecss-webpack-plugin": "^1.1.0",
|
||||
"resolve-url-loader": "^3.0.0",
|
||||
"source-map-loader": "^0.2.4",
|
||||
"style-loader": "^0.23.1",
|
||||
"tailwindcss": "^0.7.4",
|
||||
"terser-webpack-plugin": "^1.3.0",
|
||||
"ts-loader": "^5.3.3",
|
||||
"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-assets-manifest": "^3.1.1",
|
||||
"webpack-cli": "^3.0.2",
|
||||
"webpack-dev-server": "^3.1.14",
|
||||
"webpack-manifest-plugin": "^2.0.3",
|
||||
"webpack-shell-plugin": "^0.5.0",
|
||||
"webpack-stream": "^4.0.3"
|
||||
"webpack-manifest-plugin": "^2.0.3"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf public/assets/*.js && rm -rf public/assets/*.css",
|
||||
"watch": "NODE_ENV=development ./node_modules/.bin/webpack --watch --progress",
|
||||
"build": "NODE_ENV=development ./node_modules/.bin/webpack --progress",
|
||||
"build:production": "NODE_ENV=production ./node_modules/.bin/webpack",
|
||||
"serve": "NODE_ENV=development webpack-dev-server --host 0.0.0.0 --hot",
|
||||
"v:serve": "PUBLIC_PATH=http://pterodactyl.test:8080 yarn run serve",
|
||||
"compile:assets": "php artisan vue-i18n:generate & php artisan ziggy:generate resources/assets/scripts/helpers/ziggy.js"
|
||||
"serve": "yarn run clean && NODE_ENV=development webpack-dev-server --host 0.0.0.0 --hot",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
1
public/assets/pterodactyl.svg
Executable file
1
public/assets/pterodactyl.svg
Executable file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 12 KiB |
@ -255,6 +255,31 @@ $(document).ready(function () {
|
||||
|
||||
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();
|
||||
MemoryChart.update();
|
||||
});
|
||||
@ -301,6 +326,13 @@ $(document).ready(function () {
|
||||
},
|
||||
animation: {
|
||||
duration: 1,
|
||||
},
|
||||
scales: {
|
||||
yAxes: [{
|
||||
ticks: {
|
||||
beginAtZero: true
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -346,6 +378,13 @@ $(document).ready(function () {
|
||||
},
|
||||
animation: {
|
||||
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
@ -29,6 +29,10 @@ class ActionsClass {
|
||||
this.element = undefined;
|
||||
}
|
||||
|
||||
sanitizedString(value) {
|
||||
return $('<div>').text(value).html();
|
||||
}
|
||||
|
||||
folder(path) {
|
||||
let inputValue
|
||||
if (path) {
|
||||
@ -296,7 +300,7 @@ class ActionsClass {
|
||||
swal({
|
||||
type: 'warning',
|
||||
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,
|
||||
showCancelButton: true,
|
||||
showConfirmButton: true,
|
||||
@ -394,7 +398,7 @@ class ActionsClass {
|
||||
let formattedItems = "";
|
||||
let i = 0;
|
||||
$.each(selectedItems, function(key, value) {
|
||||
formattedItems += ("<code>" + value + "</code>, ");
|
||||
formattedItems += ("<code>" + this.sanitizedString(value) + "</code>, ");
|
||||
i++;
|
||||
return i < 5;
|
||||
});
|
||||
@ -407,7 +411,7 @@ class ActionsClass {
|
||||
swal({
|
||||
type: 'warning',
|
||||
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,
|
||||
showCancelButton: true,
|
||||
showConfirmButton: true,
|
||||
@ -536,7 +540,7 @@ class ActionsClass {
|
||||
type: 'error',
|
||||
title: 'Whoops!',
|
||||
html: true,
|
||||
text: error
|
||||
text: this.sanitizedString(error)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -62,7 +62,7 @@ class ContextMenuClass {
|
||||
|
||||
if (Pterodactyl.permissions.createFiles) {
|
||||
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>';
|
||||
}
|
||||
|
||||
|
@ -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>
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
@ -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);
|
||||
});
|
||||
}
|
@ -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);
|
||||
});
|
||||
}
|
@ -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);
|
||||
})
|
||||
}
|
@ -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);
|
||||
});
|
||||
}
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
@ -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);
|
||||
});
|
||||
}
|
@ -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);
|
||||
});
|
||||
}
|
@ -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;
|
@ -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,
|
||||
}
|
@ -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');
|
@ -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.');
|
||||
}
|
@ -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>
|
@ -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>
|
@ -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 }"> </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>
|
@ -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>
|
@ -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 }"> </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>
|
@ -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 }"> </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>
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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 }"> </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>
|
@ -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"/> ••••••
|
||||
</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>
|
@ -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 }"> </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>
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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 }"> </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>
|
@ -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"> </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>
|
@ -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>
|
@ -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>
|
@ -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 }"> </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>
|
@ -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 }"> </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>
|
@ -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>
|
@ -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>
|
@ -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>
|
1
resources/assets/scripts/helpers/.gitignore
vendored
1
resources/assets/scripts/helpers/.gitignore
vendored
@ -1 +0,0 @@
|
||||
ziggy.js
|
@ -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;
|
@ -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');
|
||||
}
|
@ -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
@ -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();
|
||||
}
|
||||
},
|
||||
};
|
@ -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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
@ -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;
|
||||
},
|
||||
},
|
||||
};
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
44
resources/assets/scripts/pterodactyl-shims.d.ts
vendored
44
resources/assets/scripts/pterodactyl-shims.d.ts
vendored
@ -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,
|
||||
}
|
||||
}
|
@ -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;
|
@ -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;
|
@ -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;
|
||||
},
|
||||
},
|
||||
};
|
@ -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;
|
||||
},
|
||||
},
|
||||
};
|
@ -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);
|
||||
},
|
||||
},
|
||||
}
|
@ -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;
|
||||
},
|
||||
},
|
||||
};
|
@ -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,
|
||||
}
|
4
resources/assets/scripts/vue-shims.d.ts
vendored
4
resources/assets/scripts/vue-shims.d.ts
vendored
@ -1,4 +0,0 @@
|
||||
declare module '*.vue' {
|
||||
import Vue from 'vue'
|
||||
export default Vue
|
||||
}
|
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
36
resources/scripts/.eslintrc.yml
Normal file
36
resources/scripts/.eslintrc.yml
Normal 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"
|
32
resources/scripts/TransitionRouter.tsx
Normal file
32
resources/scripts/TransitionRouter.tsx
Normal 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'}>
|
||||
© 2015 - 2019
|
||||
<a
|
||||
href={'https://pterodactyl.io'}
|
||||
className={'no-underline text-neutral-500 hover:text-neutral-300'}
|
||||
>
|
||||
Pterodactyl Software
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</CSSTransition>
|
||||
</TransitionGroup>
|
||||
)}
|
||||
/>
|
||||
);
|
9
resources/scripts/api/account/updateAccountEmail.ts
Normal file
9
resources/scripts/api/account/updateAccountEmail.ts
Normal 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);
|
||||
});
|
||||
};
|
21
resources/scripts/api/account/updateAccountPassword.ts
Normal file
21
resources/scripts/api/account/updateAccountPassword.ts
Normal 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);
|
||||
});
|
||||
};
|
25
resources/scripts/api/auth/login.ts
Normal file
25
resources/scripts/api/auth/login.ts
Normal 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);
|
||||
});
|
||||
};
|
18
resources/scripts/api/auth/loginCheckpoint.ts
Normal file
18
resources/scripts/api/auth/loginCheckpoint.ts
Normal 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);
|
||||
});
|
||||
};
|
29
resources/scripts/api/auth/performPasswordReset.ts
Normal file
29
resources/scripts/api/auth/performPasswordReset.ts
Normal 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);
|
||||
});
|
||||
};
|
9
resources/scripts/api/auth/requestPasswordResetEmail.ts
Normal file
9
resources/scripts/api/auth/requestPasswordResetEmail.ts
Normal 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);
|
||||
});
|
||||
};
|
@ -1,5 +1,4 @@
|
||||
import axios, {AxiosError, AxiosInstance} from 'axios';
|
||||
import {ServerApplicationCredentials} from "@/store/types";
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
|
||||
// This token is set in the bootstrap.js file at the beginning of the request
|
||||
// and is carried through from there.
|
||||
@ -10,6 +9,7 @@ const http: AxiosInstance = axios.create({
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': '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;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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) {
|
||||
const { data } = error.response;
|
||||
if (data.errors && data.errors[0] && data.errors[0].detail) {
|
55
resources/scripts/components/App.tsx
Normal file
55
resources/scripts/components/App.tsx
Normal 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);
|
39
resources/scripts/components/FlashMessageRender.tsx
Normal file
39
resources/scripts/components/FlashMessageRender.tsx
Normal 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
Loading…
Reference in New Issue
Block a user