React 18 and Vite (#4510)
This commit is contained in:
parent
1bb1b13f6d
commit
21613fa602
244 changed files with 4547 additions and 8933 deletions
11
.eslintrc.js
11
.eslintrc.js
|
@ -1,9 +1,3 @@
|
||||||
const prettier = {
|
|
||||||
singleQuote: true,
|
|
||||||
jsxSingleQuote: true,
|
|
||||||
printWidth: 120,
|
|
||||||
};
|
|
||||||
|
|
||||||
/** @type {import('eslint').Linter.Config} */
|
/** @type {import('eslint').Linter.Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
parser: '@typescript-eslint/parser',
|
parser: '@typescript-eslint/parser',
|
||||||
|
@ -39,15 +33,16 @@ module.exports = {
|
||||||
// 'standard',
|
// 'standard',
|
||||||
'eslint:recommended',
|
'eslint:recommended',
|
||||||
'plugin:react/recommended',
|
'plugin:react/recommended',
|
||||||
|
'plugin:react/jsx-runtime',
|
||||||
'plugin:@typescript-eslint/recommended',
|
'plugin:@typescript-eslint/recommended',
|
||||||
'plugin:jest-dom/recommended',
|
|
||||||
],
|
],
|
||||||
rules: {
|
rules: {
|
||||||
eqeqeq: 'error',
|
eqeqeq: 'error',
|
||||||
'prettier/prettier': ['error', prettier],
|
'prettier/prettier': ['error', {}, {usePrettierrc: true}],
|
||||||
// TypeScript can infer this significantly better than eslint ever can.
|
// TypeScript can infer this significantly better than eslint ever can.
|
||||||
'react/prop-types': 0,
|
'react/prop-types': 0,
|
||||||
'react/display-name': 0,
|
'react/display-name': 0,
|
||||||
|
'react/no-unknown-property': ['error', {ignore: ['css']}],
|
||||||
'@typescript-eslint/no-explicit-any': 0,
|
'@typescript-eslint/no-explicit-any': 0,
|
||||||
'@typescript-eslint/no-non-null-assertion': 0,
|
'@typescript-eslint/no-non-null-assertion': 0,
|
||||||
// This setup is required to avoid a spam of errors when running eslint about React being
|
// This setup is required to avoid a spam of errors when running eslint about React being
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
name: Build
|
name: UI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
@ -11,13 +11,13 @@ on:
|
||||||
- "1.0-develop"
|
- "1.0-develop"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
ui:
|
build-and-test:
|
||||||
name: UI
|
name: Build and Test
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
node-version: [16]
|
node-version: [16, 18]
|
||||||
steps:
|
steps:
|
||||||
- name: Code Checkout
|
- name: Code Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
@ -32,4 +32,7 @@ jobs:
|
||||||
run: yarn install --frozen-lockfile
|
run: yarn install --frozen-lockfile
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: yarn build:production
|
run: yarn build
|
||||||
|
|
||||||
|
- name: Tests
|
||||||
|
run: yarn test
|
5
.gitignore
vendored
5
.gitignore
vendored
|
@ -29,3 +29,8 @@ misc
|
||||||
coverage.xml
|
coverage.xml
|
||||||
resources/lang/locales.js
|
resources/lang/locales.js
|
||||||
.phpunit.result.cache
|
.phpunit.result.cache
|
||||||
|
|
||||||
|
/public/build
|
||||||
|
/public/hot
|
||||||
|
result
|
||||||
|
docker-compose.yaml
|
||||||
|
|
4
.prettierignore
Normal file
4
.prettierignore
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
.github
|
||||||
|
public
|
||||||
|
node_modules
|
||||||
|
resources/views
|
22
.prettierrc.json
Normal file
22
.prettierrc.json
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"printWidth": 120,
|
||||||
|
"tabWidth": 4,
|
||||||
|
"useTabs": false,
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"jsxSingleQuote": false,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"arrowParens": "avoid",
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": ["**/*.md"],
|
||||||
|
"options": {
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"singleQuote": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
|
@ -3,23 +3,14 @@
|
||||||
namespace Pterodactyl\Http\ViewComposers;
|
namespace Pterodactyl\Http\ViewComposers;
|
||||||
|
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
use Pterodactyl\Services\Helpers\AssetHashService;
|
|
||||||
|
|
||||||
class AssetComposer
|
class AssetComposer
|
||||||
{
|
{
|
||||||
/**
|
|
||||||
* AssetComposer constructor.
|
|
||||||
*/
|
|
||||||
public function __construct(private AssetHashService $assetHashService)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provide access to the asset service in the views.
|
* Provide access to the asset service in the views.
|
||||||
*/
|
*/
|
||||||
public function compose(View $view): void
|
public function compose(View $view): void
|
||||||
{
|
{
|
||||||
$view->with('asset', $this->assetHashService);
|
|
||||||
$view->with('siteConfiguration', [
|
$view->with('siteConfiguration', [
|
||||||
'name' => config('app.name') ?? 'Pterodactyl',
|
'name' => config('app.name') ?? 'Pterodactyl',
|
||||||
'locale' => config('app.locale') ?? 'en',
|
'locale' => config('app.locale') ?? 'en',
|
||||||
|
|
|
@ -1,117 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace Pterodactyl\Services\Helpers;
|
|
||||||
|
|
||||||
use Illuminate\Support\Arr;
|
|
||||||
use Illuminate\Filesystem\FilesystemManager;
|
|
||||||
use Illuminate\Contracts\Filesystem\Filesystem;
|
|
||||||
use Pterodactyl\Exceptions\ManifestDoesNotExistException;
|
|
||||||
|
|
||||||
class AssetHashService
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Location of the manifest file generated by gulp.
|
|
||||||
*/
|
|
||||||
public const MANIFEST_PATH = './assets/manifest.json';
|
|
||||||
|
|
||||||
private Filesystem $filesystem;
|
|
||||||
|
|
||||||
protected static mixed $manifest = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* AssetHashService constructor.
|
|
||||||
*/
|
|
||||||
public function __construct(FilesystemManager $filesystem)
|
|
||||||
{
|
|
||||||
$this->filesystem = $filesystem->createLocalDriver(['root' => public_path()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Modify a URL to append the asset hash.
|
|
||||||
*/
|
|
||||||
public function url(string $resource): string
|
|
||||||
{
|
|
||||||
$file = last(explode('/', $resource));
|
|
||||||
$data = Arr::get($this->manifest(), $file) ?? $file;
|
|
||||||
|
|
||||||
return str_replace($file, Arr::get($data, 'src') ?? $file, $resource);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return the data integrity hash for a resource.
|
|
||||||
*/
|
|
||||||
public function integrity(string $resource): string
|
|
||||||
{
|
|
||||||
$file = last(explode('/', $resource));
|
|
||||||
$data = array_get($this->manifest(), $file, $file);
|
|
||||||
|
|
||||||
return Arr::get($data, 'integrity') ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return a built CSS import using the provided URL.
|
|
||||||
*/
|
|
||||||
public function css(string $resource): string
|
|
||||||
{
|
|
||||||
$attributes = [
|
|
||||||
'href' => $this->url($resource),
|
|
||||||
'rel' => 'stylesheet preload',
|
|
||||||
'as' => 'style',
|
|
||||||
'crossorigin' => 'anonymous',
|
|
||||||
'referrerpolicy' => 'no-referrer',
|
|
||||||
];
|
|
||||||
|
|
||||||
if (config('pterodactyl.assets.use_hash')) {
|
|
||||||
$attributes['integrity'] = $this->integrity($resource);
|
|
||||||
}
|
|
||||||
|
|
||||||
$output = '<link';
|
|
||||||
foreach ($attributes as $key => $value) {
|
|
||||||
$output .= " $key=\"$value\"";
|
|
||||||
}
|
|
||||||
|
|
||||||
return $output . '>';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return a built JS import using the provided URL.
|
|
||||||
*/
|
|
||||||
public function js(string $resource): string
|
|
||||||
{
|
|
||||||
$attributes = [
|
|
||||||
'src' => $this->url($resource),
|
|
||||||
'crossorigin' => 'anonymous',
|
|
||||||
];
|
|
||||||
|
|
||||||
if (config('pterodactyl.assets.use_hash')) {
|
|
||||||
$attributes['integrity'] = $this->integrity($resource);
|
|
||||||
}
|
|
||||||
|
|
||||||
$output = '<script';
|
|
||||||
foreach ($attributes as $key => $value) {
|
|
||||||
$output .= " $key=\"$value\"";
|
|
||||||
}
|
|
||||||
|
|
||||||
return $output . '></script>';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the asset manifest and store it in the cache for quicker lookups.
|
|
||||||
*/
|
|
||||||
protected function manifest(): array
|
|
||||||
{
|
|
||||||
if (static::$manifest === null) {
|
|
||||||
self::$manifest = json_decode(
|
|
||||||
$this->filesystem->get(self::MANIFEST_PATH),
|
|
||||||
true
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$manifest = static::$manifest;
|
|
||||||
if ($manifest === null) {
|
|
||||||
throw new ManifestDoesNotExistException();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $manifest;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,34 +0,0 @@
|
||||||
module.exports = function (api) {
|
|
||||||
let targets = {};
|
|
||||||
const plugins = [
|
|
||||||
'babel-plugin-macros',
|
|
||||||
'styled-components',
|
|
||||||
'react-hot-loader/babel',
|
|
||||||
'@babel/transform-runtime',
|
|
||||||
'@babel/transform-react-jsx',
|
|
||||||
'@babel/proposal-class-properties',
|
|
||||||
'@babel/proposal-object-rest-spread',
|
|
||||||
'@babel/proposal-optional-chaining',
|
|
||||||
'@babel/proposal-nullish-coalescing-operator',
|
|
||||||
'@babel/syntax-dynamic-import',
|
|
||||||
];
|
|
||||||
|
|
||||||
if (api.env('test')) {
|
|
||||||
targets = { node: 'current' };
|
|
||||||
plugins.push('@babel/transform-modules-commonjs');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
plugins,
|
|
||||||
presets: [
|
|
||||||
'@babel/typescript',
|
|
||||||
['@babel/env', {
|
|
||||||
modules: false,
|
|
||||||
useBuiltIns: 'entry',
|
|
||||||
corejs: 3,
|
|
||||||
targets,
|
|
||||||
}],
|
|
||||||
'@babel/react',
|
|
||||||
]
|
|
||||||
};
|
|
||||||
};
|
|
125
docker-compose.development.yaml
Normal file
125
docker-compose.development.yaml
Normal file
|
@ -0,0 +1,125 @@
|
||||||
|
# For more information: https://laravel.com/docs/sail
|
||||||
|
version: '3'
|
||||||
|
|
||||||
|
services:
|
||||||
|
caddy:
|
||||||
|
image: localhost/pterodactyl/development:panel
|
||||||
|
network_mode: host
|
||||||
|
command:
|
||||||
|
- caddy
|
||||||
|
- run
|
||||||
|
- --config
|
||||||
|
- /etc/caddy/Caddyfile
|
||||||
|
volumes:
|
||||||
|
- '.:/var/www/html'
|
||||||
|
depends_on:
|
||||||
|
- laravel
|
||||||
|
|
||||||
|
laravel:
|
||||||
|
image: localhost/pterodactyl/development:panel
|
||||||
|
network_mode: host
|
||||||
|
command:
|
||||||
|
- 'php-fpm'
|
||||||
|
- '--nodaemonize'
|
||||||
|
- '-y'
|
||||||
|
- '/etc/php-fpm.conf'
|
||||||
|
volumes:
|
||||||
|
- '.:/var/www/html'
|
||||||
|
tmpfs:
|
||||||
|
- '/tmp'
|
||||||
|
depends_on:
|
||||||
|
- pgsql
|
||||||
|
- mariadb
|
||||||
|
- redis
|
||||||
|
- mailhog
|
||||||
|
|
||||||
|
pgsql:
|
||||||
|
image: docker.io/library/postgres:14
|
||||||
|
ports:
|
||||||
|
- '127.0.0.1:${FORWARD_DB_PORT:-5432}:5432'
|
||||||
|
environment:
|
||||||
|
PGPASSWORD: '${DB_PASSWORD:-secret}'
|
||||||
|
POSTGRES_DB: '${DB_DATABASE}'
|
||||||
|
POSTGRES_USER: '${DB_USERNAME}'
|
||||||
|
POSTGRES_PASSWORD: '${DB_PASSWORD:-secret}'
|
||||||
|
volumes:
|
||||||
|
- 'sail-pgsql:/var/lib/postgresql/data'
|
||||||
|
- './vendor/laravel/sail/database/pgsql/create-testing-database.sql:/docker-entrypoint-initdb.d/10-create-testing-database.sql'
|
||||||
|
networks:
|
||||||
|
- sail
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "pg_isready", "-q", "-d", "${DB_DATABASE}", "-U", "${DB_USERNAME}"]
|
||||||
|
retries: 3
|
||||||
|
timeout: 5s
|
||||||
|
|
||||||
|
mariadb:
|
||||||
|
image: docker.io/library/mariadb:10
|
||||||
|
ports:
|
||||||
|
- '127.0.0.1:${FORWARD_DB_PORT:-3306}:3306'
|
||||||
|
environment:
|
||||||
|
MYSQL_DATABASE: '${DB_DATABASE}'
|
||||||
|
MYSQL_USER: '${DB_USERNAME}'
|
||||||
|
MYSQL_PASSWORD: '${DB_PASSWORD}'
|
||||||
|
MYSQL_ALLOW_EMPTY_PASSWORD: 'yes'
|
||||||
|
volumes:
|
||||||
|
- 'sail-mariadb:/var/lib/mysql'
|
||||||
|
- './vendor/laravel/sail/database/mysql/create-testing-database.sh:/docker-entrypoint-initdb.d/10-create-testing-database.sh'
|
||||||
|
networks:
|
||||||
|
- sail
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin", "ping", "-p${DB_PASSWORD}"]
|
||||||
|
retries: 3
|
||||||
|
timeout: 5s
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: docker.io/library/redis:7
|
||||||
|
ports:
|
||||||
|
- '127.0.0.1:${FORWARD_REDIS_PORT:-6379}:6379'
|
||||||
|
volumes:
|
||||||
|
- 'sail-redis:/data'
|
||||||
|
networks:
|
||||||
|
- sail
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
retries: 3
|
||||||
|
timeout: 5s
|
||||||
|
|
||||||
|
minio:
|
||||||
|
image: docker.io/minio/minio:latest
|
||||||
|
ports:
|
||||||
|
- '127.0.0.1:${FORWARD_MINIO_PORT:-9001}:9000'
|
||||||
|
- '127.0.0.1:${FORWARD_MINIO_CONSOLE_PORT:-8900}:8900'
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: 'sail'
|
||||||
|
MINIO_ROOT_PASSWORD: 'password'
|
||||||
|
volumes:
|
||||||
|
- 'sail-minio:/data/minio'
|
||||||
|
networks:
|
||||||
|
- sail
|
||||||
|
command: minio server /data/minio --console-address ":8900"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||||
|
retries: 3
|
||||||
|
timeout: 5s
|
||||||
|
|
||||||
|
mailhog:
|
||||||
|
image: docker.io/mailhog/mailhog:latest
|
||||||
|
ports:
|
||||||
|
- '127.0.0.1:${FORWARD_MAILHOG_PORT:-1025}:1025'
|
||||||
|
- '127.0.0.1:${FORWARD_MAILHOG_DASHBOARD_PORT:-8025}:8025'
|
||||||
|
networks:
|
||||||
|
- sail
|
||||||
|
|
||||||
|
networks:
|
||||||
|
sail:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
sail-pgsql:
|
||||||
|
driver: local
|
||||||
|
sail-mariadb:
|
||||||
|
driver: local
|
||||||
|
sail-redis:
|
||||||
|
driver: local
|
||||||
|
sail-minio:
|
||||||
|
driver: local
|
12
flake.lock
12
flake.lock
|
@ -2,11 +2,11 @@
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"flake-utils": {
|
"flake-utils": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1659877975,
|
"lastModified": 1667395993,
|
||||||
"narHash": "sha256-zllb8aq3YO3h8B/U0/J1WBgAL8EX5yWf5pMj3G0NAmc=",
|
"narHash": "sha256-nuEHfE/LcWyuSWnS8t12N1wc105Qtau+/OdUAjtQ0rA=",
|
||||||
"owner": "numtide",
|
"owner": "numtide",
|
||||||
"repo": "flake-utils",
|
"repo": "flake-utils",
|
||||||
"rev": "c0e246b9b83f637f4681389ecabcb2681b4f3af0",
|
"rev": "5aed5285a952e0b949eb3ba02c12fa4fcfef535f",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
@ -17,11 +17,11 @@
|
||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1666539104,
|
"lastModified": 1669140675,
|
||||||
"narHash": "sha256-jeuC+d375wHHxMOFLgu7etseCQVJuPNKoEc9X9CsErg=",
|
"narHash": "sha256-npzfyfLECsJWgzK/M4gWhykP2DNAJTYjgY2BWkz/oEQ=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "0e6df35f39651504249a05191f9a78d251707e22",
|
"rev": "2788904d26dda6cfa1921c5abb7a2466ffe3cb8c",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
112
flake.nix
112
flake.nix
|
@ -15,8 +15,118 @@
|
||||||
flake-utils.lib.eachDefaultSystem (
|
flake-utils.lib.eachDefaultSystem (
|
||||||
system: let
|
system: let
|
||||||
pkgs = import nixpkgs {inherit system;};
|
pkgs = import nixpkgs {inherit system;};
|
||||||
|
|
||||||
|
php81WithExtensions = with pkgs; (php81.buildEnv {
|
||||||
|
extensions = {
|
||||||
|
enabled,
|
||||||
|
all,
|
||||||
|
}:
|
||||||
|
enabled
|
||||||
|
++ (with all; [
|
||||||
|
redis
|
||||||
|
xdebug
|
||||||
|
]);
|
||||||
|
extraConfig = ''
|
||||||
|
xdebug.mode=debug
|
||||||
|
'';
|
||||||
|
});
|
||||||
|
|
||||||
|
caCertificates = pkgs.runCommand "ca-certificates" {} ''
|
||||||
|
mkdir -p $out/etc/ssl/certs $out/etc/pki/tls/certs
|
||||||
|
ln -s ${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt $out/etc/ssl/certs/ca-bundle.crt
|
||||||
|
ln -s ${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt $out/etc/ssl/certs/ca-certificates.crt
|
||||||
|
ln -s ${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt $out/etc/pki/tls/certs/ca-bundle.crt
|
||||||
|
'';
|
||||||
|
|
||||||
|
caddyfile = pkgs.writeText "Caddyfile" ''
|
||||||
|
:80 {
|
||||||
|
root * /var/www/html/public/
|
||||||
|
file_server
|
||||||
|
|
||||||
|
header {
|
||||||
|
-Server
|
||||||
|
-X-Powered-By
|
||||||
|
Referrer-Policy "same-origin"
|
||||||
|
X-Frame-Options "deny"
|
||||||
|
X-XSS-Protection "1; mode=block"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
}
|
||||||
|
|
||||||
|
encode gzip zstd
|
||||||
|
|
||||||
|
php_fastcgi localhost:9000
|
||||||
|
|
||||||
|
@startsWithDot {
|
||||||
|
path \/\.
|
||||||
|
not path .well-known
|
||||||
|
}
|
||||||
|
rewrite @startsWithDot /index.php{uri}
|
||||||
|
|
||||||
|
@phpRewrite {
|
||||||
|
not file favicon.ico
|
||||||
|
}
|
||||||
|
try_files @phpRewrite {path} {path}/ /index.php?{query}
|
||||||
|
}
|
||||||
|
'';
|
||||||
|
|
||||||
|
phpfpmConf = pkgs.writeText "php-fpm.conf" ''
|
||||||
|
[global]
|
||||||
|
error_log = /dev/stderr
|
||||||
|
daemonize = no
|
||||||
|
|
||||||
|
[www]
|
||||||
|
user = nobody
|
||||||
|
group = nobody
|
||||||
|
|
||||||
|
listen = 0.0.0.0:9000
|
||||||
|
|
||||||
|
pm = dynamic
|
||||||
|
pm.start_servers = 4
|
||||||
|
pm.min_spare_servers = 4
|
||||||
|
pm.max_spare_servers = 16
|
||||||
|
pm.max_children = 64
|
||||||
|
pm.max_requests = 256
|
||||||
|
|
||||||
|
clear_env = no
|
||||||
|
catch_workers_output = yes
|
||||||
|
|
||||||
|
decorate_workers_output = no
|
||||||
|
'';
|
||||||
|
|
||||||
|
configs = pkgs.runCommand "configs" {} ''
|
||||||
|
mkdir -p $out/etc/caddy
|
||||||
|
ln -s ${caddyfile} $out/etc/caddy/Caddyfile
|
||||||
|
ln -s ${phpfpmConf} $out/etc/php-fpm.conf
|
||||||
|
'';
|
||||||
in {
|
in {
|
||||||
devShell = import ./shell.nix {inherit pkgs;};
|
devShell = import ./shell.nix {inherit pkgs php81WithExtensions;};
|
||||||
|
|
||||||
|
packages = {
|
||||||
|
development = pkgs.dockerTools.buildImage {
|
||||||
|
name = "pterodactyl/development";
|
||||||
|
tag = "panel";
|
||||||
|
|
||||||
|
copyToRoot = pkgs.buildEnv {
|
||||||
|
name = "image-root";
|
||||||
|
paths = with pkgs; [
|
||||||
|
dockerTools.fakeNss
|
||||||
|
caCertificates
|
||||||
|
caddy
|
||||||
|
configs
|
||||||
|
coreutils
|
||||||
|
mysql80
|
||||||
|
nodejs-18_x
|
||||||
|
nodePackages.npm
|
||||||
|
nodePackages.pnpm
|
||||||
|
nodePackages.yarn
|
||||||
|
php81WithExtensions
|
||||||
|
(php81Packages.composer.override {php = php81WithExtensions;})
|
||||||
|
postgresql_14
|
||||||
|
];
|
||||||
|
pathsToLink = ["/bin" "/etc"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,28 +0,0 @@
|
||||||
const { pathsToModuleNameMapper } = require('ts-jest');
|
|
||||||
const { compilerOptions } = require('./tsconfig');
|
|
||||||
|
|
||||||
/** @type {import('ts-jest').InitialOptionsTsJest} */
|
|
||||||
module.exports = {
|
|
||||||
preset: 'ts-jest',
|
|
||||||
globals: {
|
|
||||||
'ts-jest': {
|
|
||||||
isolatedModules: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
moduleFileExtensions: ['js', 'ts', 'tsx', 'd.ts', 'json', 'node'],
|
|
||||||
moduleNameMapper: {
|
|
||||||
'\\.(jpe?g|png|gif|svg)$': '<rootDir>/resources/scripts/__mocks__/file.ts',
|
|
||||||
'\\.(s?css|less)$': 'identity-obj-proxy',
|
|
||||||
...pathsToModuleNameMapper(compilerOptions.paths, {
|
|
||||||
prefix: '<rootDir>/',
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
setupFilesAfterEnv: [
|
|
||||||
'<rootDir>/resources/scripts/setup-tests.ts',
|
|
||||||
],
|
|
||||||
transform: {
|
|
||||||
'.*\\.[t|j]sx$': 'babel-jest',
|
|
||||||
'.*\\.ts$': 'ts-jest',
|
|
||||||
},
|
|
||||||
testPathIgnorePatterns: ['/node_modules/'],
|
|
||||||
};
|
|
255
package.json
255
package.json
|
@ -1,142 +1,128 @@
|
||||||
{
|
{
|
||||||
"name": "pterodactyl-panel",
|
"name": "@pterodactyl/panel",
|
||||||
|
"license": "MIT",
|
||||||
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14"
|
"node": ">=16.0"
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@floating-ui/react-dom-interactions": "^0.6.6",
|
|
||||||
"@fortawesome/fontawesome-svg-core": "^1.2.32",
|
|
||||||
"@fortawesome/free-solid-svg-icons": "^5.15.1",
|
|
||||||
"@fortawesome/react-fontawesome": "^0.1.11",
|
|
||||||
"@headlessui/react": "^1.6.4",
|
|
||||||
"@heroicons/react": "^1.0.6",
|
|
||||||
"@hot-loader/react-dom": "^16.14.0",
|
|
||||||
"@preact/signals-react": "^1.2.1",
|
|
||||||
"@tailwindcss/forms": "^0.5.2",
|
|
||||||
"@tailwindcss/line-clamp": "^0.4.0",
|
|
||||||
"axios": "^0.27.2",
|
|
||||||
"boring-avatars": "^1.7.0",
|
|
||||||
"chart.js": "^3.8.0",
|
|
||||||
"classnames": "^2.3.1",
|
|
||||||
"codemirror": "^5.57.0",
|
|
||||||
"copy-to-clipboard": "^3.3.1",
|
|
||||||
"date-fns": "^2.28.0",
|
|
||||||
"debounce": "^1.2.0",
|
|
||||||
"deepmerge-ts": "^4.2.1",
|
|
||||||
"easy-peasy": "^4.0.1",
|
|
||||||
"events": "^3.0.0",
|
|
||||||
"formik": "^2.2.6",
|
|
||||||
"framer-motion": "^6.3.10",
|
|
||||||
"i18next": "^21.8.9",
|
|
||||||
"i18next-http-backend": "^1.4.1",
|
|
||||||
"i18next-multiload-backend-adapter": "^1.0.0",
|
|
||||||
"qrcode.react": "^1.0.1",
|
|
||||||
"react": "^16.14.0",
|
|
||||||
"react-chartjs-2": "^4.2.0",
|
|
||||||
"react-dom": "npm:@hot-loader/react-dom",
|
|
||||||
"react-fast-compare": "^3.2.0",
|
|
||||||
"react-hot-loader": "^4.12.21",
|
|
||||||
"react-i18next": "^11.2.1",
|
|
||||||
"react-router-dom": "^5.1.2",
|
|
||||||
"react-transition-group": "^4.4.1",
|
|
||||||
"reaptcha": "^1.7.2",
|
|
||||||
"sockette": "^2.0.6",
|
|
||||||
"styled-components": "^5.2.1",
|
|
||||||
"styled-components-breakpoint": "^3.0.0-preview.20",
|
|
||||||
"swr": "^0.2.3",
|
|
||||||
"tailwindcss": "^3.0.24",
|
|
||||||
"use-fit-text": "^2.4.0",
|
|
||||||
"uuid": "^8.3.2",
|
|
||||||
"xterm": "^4.19.0",
|
|
||||||
"xterm-addon-fit": "^0.5.0",
|
|
||||||
"xterm-addon-search": "^0.9.0",
|
|
||||||
"xterm-addon-search-bar": "^0.2.0",
|
|
||||||
"xterm-addon-web-links": "^0.6.0",
|
|
||||||
"yup": "^0.29.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@babel/core": "^7.12.1",
|
|
||||||
"@babel/plugin-proposal-class-properties": "^7.12.1",
|
|
||||||
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.12.1",
|
|
||||||
"@babel/plugin-proposal-object-rest-spread": "^7.12.1",
|
|
||||||
"@babel/plugin-proposal-optional-chaining": "^7.12.1",
|
|
||||||
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
|
|
||||||
"@babel/plugin-transform-modules-commonjs": "^7.18.2",
|
|
||||||
"@babel/plugin-transform-react-jsx": "^7.12.1",
|
|
||||||
"@babel/plugin-transform-runtime": "^7.12.1",
|
|
||||||
"@babel/preset-env": "^7.12.1",
|
|
||||||
"@babel/preset-react": "^7.12.1",
|
|
||||||
"@babel/preset-typescript": "^7.12.1",
|
|
||||||
"@babel/runtime": "^7.12.1",
|
|
||||||
"@testing-library/dom": "^8.14.0",
|
|
||||||
"@testing-library/jest-dom": "^5.16.4",
|
|
||||||
"@testing-library/react": "12.1.5",
|
|
||||||
"@testing-library/user-event": "^14.2.1",
|
|
||||||
"@types/codemirror": "^0.0.98",
|
|
||||||
"@types/debounce": "^1.2.0",
|
|
||||||
"@types/events": "^3.0.0",
|
|
||||||
"@types/jest": "^28.1.3",
|
|
||||||
"@types/node": "^14.11.10",
|
|
||||||
"@types/qrcode.react": "^1.0.1",
|
|
||||||
"@types/react": "^16.14.0",
|
|
||||||
"@types/react-copy-to-clipboard": "^4.3.0",
|
|
||||||
"@types/react-dom": "^16.9.16",
|
|
||||||
"@types/react-redux": "^7.1.1",
|
|
||||||
"@types/react-router": "^5.1.3",
|
|
||||||
"@types/react-router-dom": "^5.1.3",
|
|
||||||
"@types/react-transition-group": "^4.4.0",
|
|
||||||
"@types/styled-components": "^5.1.7",
|
|
||||||
"@types/uuid": "^3.4.5",
|
|
||||||
"@types/webpack-env": "^1.15.2",
|
|
||||||
"@types/yup": "^0.29.3",
|
|
||||||
"@typescript-eslint/eslint-plugin": "^5.29.0",
|
|
||||||
"@typescript-eslint/parser": "^5.29.0",
|
|
||||||
"autoprefixer": "^10.4.7",
|
|
||||||
"babel-jest": "^28.1.1",
|
|
||||||
"babel-loader": "^8.2.5",
|
|
||||||
"babel-plugin-styled-components": "^2.0.7",
|
|
||||||
"cross-env": "^7.0.2",
|
|
||||||
"css-loader": "^5.2.7",
|
|
||||||
"eslint": "^8.18.0",
|
|
||||||
"eslint-config-prettier": "^8.5.0",
|
|
||||||
"eslint-plugin-jest-dom": "^4.0.2",
|
|
||||||
"eslint-plugin-node": "^11.1.0",
|
|
||||||
"eslint-plugin-prettier": "^4.0.0",
|
|
||||||
"eslint-plugin-react": "^7.30.1",
|
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
|
||||||
"fork-ts-checker-webpack-plugin": "^6.2.10",
|
|
||||||
"identity-obj-proxy": "^3.0.0",
|
|
||||||
"jest": "^28.1.1",
|
|
||||||
"postcss": "^8.4.14",
|
|
||||||
"postcss-import": "^14.1.0",
|
|
||||||
"postcss-loader": "^4.0.0",
|
|
||||||
"postcss-nesting": "^10.1.8",
|
|
||||||
"postcss-preset-env": "^7.7.1",
|
|
||||||
"prettier": "^2.7.1",
|
|
||||||
"redux-devtools-extension": "^2.13.8",
|
|
||||||
"source-map-loader": "^1.1.3",
|
|
||||||
"style-loader": "^2.0.0",
|
|
||||||
"svg-url-loader": "^7.1.1",
|
|
||||||
"terser-webpack-plugin": "^4.2.3",
|
|
||||||
"ts-essentials": "^9.1.2",
|
|
||||||
"ts-jest": "^28.0.5",
|
|
||||||
"twin.macro": "^2.8.2",
|
|
||||||
"typescript": "^4.7.3",
|
|
||||||
"webpack": "^4.43.0",
|
|
||||||
"webpack-assets-manifest": "^3.1.1",
|
|
||||||
"webpack-bundle-analyzer": "^3.8.0",
|
|
||||||
"webpack-cli": "^3.3.12",
|
|
||||||
"webpack-dev-server": "^3.11.0",
|
|
||||||
"yarn-deduplicate": "^1.1.1"
|
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "cd public/assets && find . \\( -name \"*.js\" -o -name \"*.map\" \\) -type f -delete",
|
"build": "vite build",
|
||||||
"test": "jest",
|
"clean": "rimraf public/build",
|
||||||
|
"coverage": "vitest run --coverage",
|
||||||
|
"dev": "vite",
|
||||||
"lint": "eslint ./resources/scripts/**/*.{ts,tsx} --ext .ts,.tsx",
|
"lint": "eslint ./resources/scripts/**/*.{ts,tsx} --ext .ts,.tsx",
|
||||||
"watch": "cross-env NODE_ENV=development ./node_modules/.bin/webpack --watch --progress",
|
"test": "vitest run",
|
||||||
"build": "cross-env NODE_ENV=development ./node_modules/.bin/webpack --progress",
|
"test:ui": "vitest --ui"
|
||||||
"build:production": "yarn run clean && cross-env NODE_ENV=production ./node_modules/.bin/webpack --mode production",
|
},
|
||||||
"serve": "yarn run clean && cross-env WEBPACK_PUBLIC_PATH=/webpack@hmr/ NODE_ENV=development webpack-dev-server --host 0.0.0.0 --port 8080 --public https://pterodactyl.test --hot"
|
"dependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6.0.0",
|
||||||
|
"@codemirror/commands": "^6.0.0",
|
||||||
|
"@codemirror/lang-cpp": "^6.0.0",
|
||||||
|
"@codemirror/lang-css": "^6.0.0",
|
||||||
|
"@codemirror/lang-html": "^6.0.0",
|
||||||
|
"@codemirror/lang-java": "^6.0.0",
|
||||||
|
"@codemirror/lang-javascript": "^6.0.0",
|
||||||
|
"@codemirror/lang-json": "^6.0.0",
|
||||||
|
"@codemirror/lang-lezer": "^6.0.0",
|
||||||
|
"@codemirror/lang-markdown": "^6.0.0",
|
||||||
|
"@codemirror/lang-php": "^6.0.0",
|
||||||
|
"@codemirror/lang-python": "^6.0.0",
|
||||||
|
"@codemirror/lang-rust": "^6.0.0",
|
||||||
|
"@codemirror/lang-sql": "^6.0.0",
|
||||||
|
"@codemirror/lang-xml": "^6.0.0",
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@codemirror/language-data": "^6.0.0",
|
||||||
|
"@codemirror/legacy-modes": "^6.0.0",
|
||||||
|
"@codemirror/lint": "^6.0.0",
|
||||||
|
"@codemirror/search": "^6.0.0",
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.0.0",
|
||||||
|
"@floating-ui/react-dom-interactions": "0.10.2",
|
||||||
|
"@fortawesome/fontawesome-svg-core": "6.2.0",
|
||||||
|
"@fortawesome/free-solid-svg-icons": "6.2.0",
|
||||||
|
"@fortawesome/react-fontawesome": "0.2.0",
|
||||||
|
"@flyyer/use-fit-text": "3.0.1",
|
||||||
|
"@headlessui/react": "1.7.3",
|
||||||
|
"@heroicons/react": "1.0.6",
|
||||||
|
"@lezer/highlight": "1.1.2",
|
||||||
|
"@preact/signals-react": "1.1.1",
|
||||||
|
"@tailwindcss/forms": "0.5.3",
|
||||||
|
"@tailwindcss/line-clamp": "0.4.2",
|
||||||
|
"axios": "0.27.2",
|
||||||
|
"boring-avatars": "1.7.0",
|
||||||
|
"chart.js": "3.9.1",
|
||||||
|
"classnames": "2.3.2",
|
||||||
|
"codemirror": "5.57.0",
|
||||||
|
"copy-to-clipboard": "3.3.2",
|
||||||
|
"date-fns": "2.29.3",
|
||||||
|
"debounce": "1.2.1",
|
||||||
|
"deepmerge-ts": "4.2.2",
|
||||||
|
"easy-peasy": "5.1.0",
|
||||||
|
"events": "3.3.0",
|
||||||
|
"formik": "2.2.9",
|
||||||
|
"framer-motion": "7.6.2",
|
||||||
|
"i18next": "22.0.3",
|
||||||
|
"i18next-http-backend": "2.0.0",
|
||||||
|
"i18next-multiload-backend-adapter": "1.0.0",
|
||||||
|
"nanoid": "4.0.0",
|
||||||
|
"qrcode.react": "3.1.0",
|
||||||
|
"react": "18.2.0",
|
||||||
|
"react-chartjs-2": "4.2.0",
|
||||||
|
"react-dom": "18.2.0",
|
||||||
|
"react-fast-compare": "3.2.0",
|
||||||
|
"react-i18next": "12.0.0",
|
||||||
|
"react-router-dom": "6.4.2",
|
||||||
|
"reaptcha": "1.12.1",
|
||||||
|
"sockette": "2.0.6",
|
||||||
|
"styled-components": "5.3.6",
|
||||||
|
"styled-components-breakpoint": "3.0.0-preview.20",
|
||||||
|
"swr": "1.3.0",
|
||||||
|
"tailwindcss": "3.2.2",
|
||||||
|
"xterm": "5.0.0",
|
||||||
|
"xterm-addon-fit": "0.6.0",
|
||||||
|
"xterm-addon-search": "0.10.0",
|
||||||
|
"xterm-addon-search-bar": "0.2.0",
|
||||||
|
"xterm-addon-web-links": "0.7.0",
|
||||||
|
"yup": "0.32.11"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@testing-library/dom": "8.19.0",
|
||||||
|
"@testing-library/react": "13.4.0",
|
||||||
|
"@testing-library/user-event": "14.4.3",
|
||||||
|
"@types/codemirror": "0.0.109",
|
||||||
|
"@types/debounce": "1.2.1",
|
||||||
|
"@types/events": "3.0.0",
|
||||||
|
"@types/node": "18.11.9",
|
||||||
|
"@types/react": "18.0.24",
|
||||||
|
"@types/react-dom": "18.0.8",
|
||||||
|
"@types/styled-components": "5.1.26",
|
||||||
|
"@typescript-eslint/eslint-plugin": "5.41.0",
|
||||||
|
"@typescript-eslint/parser": "5.41.0",
|
||||||
|
"@vitejs/plugin-react": "2.2.0",
|
||||||
|
"autoprefixer": "10.4.12",
|
||||||
|
"babel-plugin-styled-components": "2.0.7",
|
||||||
|
"babel-plugin-twin": "1.0.2",
|
||||||
|
"cross-env": "7.0.3",
|
||||||
|
"eslint": "8.18.0",
|
||||||
|
"eslint-config-prettier": "8.5.0",
|
||||||
|
"eslint-plugin-node": "11.1.0",
|
||||||
|
"eslint-plugin-prettier": "4.2.1",
|
||||||
|
"eslint-plugin-react": "7.31.10",
|
||||||
|
"eslint-plugin-react-hooks": "4.6.0",
|
||||||
|
"happy-dom": "7.6.6",
|
||||||
|
"laravel-vite-plugin": "0.7.0",
|
||||||
|
"pathe": "0.3.9",
|
||||||
|
"postcss": "8.4.18",
|
||||||
|
"postcss-import": "15.0.0",
|
||||||
|
"postcss-nesting": "10.2.0",
|
||||||
|
"postcss-preset-env": "7.8.2",
|
||||||
|
"prettier": "2.7.1",
|
||||||
|
"rimraf": "3.0.2",
|
||||||
|
"ts-essentials": "9.3.0",
|
||||||
|
"twin.macro": "2.8.2",
|
||||||
|
"typescript": "4.8.4",
|
||||||
|
"vite": "3.2.2",
|
||||||
|
"vitest": "0.24.5"
|
||||||
},
|
},
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
"> 0.5%",
|
"> 0.5%",
|
||||||
|
@ -146,6 +132,7 @@
|
||||||
],
|
],
|
||||||
"babelMacros": {
|
"babelMacros": {
|
||||||
"twin": {
|
"twin": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
"preset": "styled-components"
|
"preset": "styled-components"
|
||||||
},
|
},
|
||||||
"styledComponents": {
|
"styledComponents": {
|
||||||
|
|
|
@ -1,30 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { Route } from 'react-router';
|
|
||||||
import { SwitchTransition } from 'react-transition-group';
|
|
||||||
import Fade from '@/components/elements/Fade';
|
|
||||||
import styled from 'styled-components/macro';
|
|
||||||
import tw from 'twin.macro';
|
|
||||||
|
|
||||||
const StyledSwitchTransition = styled(SwitchTransition)`
|
|
||||||
${tw`relative`};
|
|
||||||
|
|
||||||
& section {
|
|
||||||
${tw`absolute w-full top-0 left-0`};
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const TransitionRouter: React.FC = ({ children }) => {
|
|
||||||
return (
|
|
||||||
<Route
|
|
||||||
render={({ location }) => (
|
|
||||||
<StyledSwitchTransition>
|
|
||||||
<Fade timeout={150} key={location.pathname + location.search} in appear unmountOnExit>
|
|
||||||
<section>{children}</section>
|
|
||||||
</Fade>
|
|
||||||
</StyledSwitchTransition>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TransitionRouter;
|
|
|
@ -1 +0,0 @@
|
||||||
module.exports = 'test-file-stub';
|
|
|
@ -1,8 +1,10 @@
|
||||||
import useSWR, { ConfigInterface, responseInterface } from 'swr';
|
import type { AxiosError } from 'axios';
|
||||||
import { ActivityLog, Transformers } from '@definitions/user';
|
import type { SWRConfiguration } from 'swr';
|
||||||
import { AxiosError } from 'axios';
|
import useSWR from 'swr';
|
||||||
|
|
||||||
import http, { PaginatedResult, QueryBuilderParams, withQueryBuilderParams } from '@/api/http';
|
import http, { PaginatedResult, QueryBuilderParams, withQueryBuilderParams } from '@/api/http';
|
||||||
import { toPaginatedSet } from '@definitions/helpers';
|
import { toPaginatedSet } from '@definitions/helpers';
|
||||||
|
import { ActivityLog, Transformers } from '@definitions/user';
|
||||||
import useFilteredObject from '@/plugins/useFilteredObject';
|
import useFilteredObject from '@/plugins/useFilteredObject';
|
||||||
import { useUserSWRKey } from '@/plugins/useSWRKey';
|
import { useUserSWRKey } from '@/plugins/useSWRKey';
|
||||||
|
|
||||||
|
@ -10,8 +12,8 @@ export type ActivityLogFilters = QueryBuilderParams<'ip' | 'event', 'timestamp'>
|
||||||
|
|
||||||
const useActivityLogs = (
|
const useActivityLogs = (
|
||||||
filters?: ActivityLogFilters,
|
filters?: ActivityLogFilters,
|
||||||
config?: ConfigInterface<PaginatedResult<ActivityLog>, AxiosError>
|
config?: SWRConfiguration<PaginatedResult<ActivityLog>, AxiosError>,
|
||||||
): responseInterface<PaginatedResult<ActivityLog>, AxiosError> => {
|
) => {
|
||||||
const key = useUserSWRKey(['account', 'activity', JSON.stringify(useFilteredObject(filters || {}))]);
|
const key = useUserSWRKey(['account', 'activity', JSON.stringify(useFilteredObject(filters || {}))]);
|
||||||
|
|
||||||
return useSWR<PaginatedResult<ActivityLog>>(
|
return useSWR<PaginatedResult<ActivityLog>>(
|
||||||
|
@ -26,7 +28,7 @@ const useActivityLogs = (
|
||||||
|
|
||||||
return toPaginatedSet(data, Transformers.toActivityLog);
|
return toPaginatedSet(data, Transformers.toActivityLog);
|
||||||
},
|
},
|
||||||
{ revalidateOnMount: false, ...(config || {}) }
|
{ revalidateOnMount: false, ...(config || {}) },
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -12,7 +12,7 @@ export default (description: string, allowedIps: string): Promise<ApiKey & { sec
|
||||||
...rawDataToApiKey(data.attributes),
|
...rawDataToApiKey(data.attributes),
|
||||||
// eslint-disable-next-line camelcase
|
// eslint-disable-next-line camelcase
|
||||||
secretToken: data.meta?.secret_token ?? '',
|
secretToken: data.meta?.secret_token ?? '',
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.catch(reject);
|
.catch(reject);
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,10 +1,12 @@
|
||||||
import useSWR, { ConfigInterface } from 'swr';
|
import type { AxiosError } from 'axios';
|
||||||
|
import type { SWRConfiguration } from 'swr';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
import http, { FractalResponseList } from '@/api/http';
|
import http, { FractalResponseList } from '@/api/http';
|
||||||
import { SSHKey, Transformers } from '@definitions/user';
|
import { SSHKey, Transformers } from '@definitions/user';
|
||||||
import { AxiosError } from 'axios';
|
|
||||||
import { useUserSWRKey } from '@/plugins/useSWRKey';
|
import { useUserSWRKey } from '@/plugins/useSWRKey';
|
||||||
|
|
||||||
const useSSHKeys = (config?: ConfigInterface<SSHKey[], AxiosError>) => {
|
const useSSHKeys = (config?: SWRConfiguration<SSHKey[], AxiosError>) => {
|
||||||
const key = useUserSWRKey(['account', 'ssh-keys']);
|
const key = useUserSWRKey(['account', 'ssh-keys']);
|
||||||
|
|
||||||
return useSWR(
|
return useSWR(
|
||||||
|
@ -16,7 +18,7 @@ const useSSHKeys = (config?: ConfigInterface<SSHKey[], AxiosError>) => {
|
||||||
return Transformers.toSSHKey(datum.attributes);
|
return Transformers.toSSHKey(datum.attributes);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
{ revalidateOnMount: false, ...(config || {}) }
|
{ revalidateOnMount: false, ...(config || {}) },
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -20,9 +20,9 @@ export default ({ username, password, recaptchaData }: LoginData): Promise<Login
|
||||||
user: username,
|
user: username,
|
||||||
password,
|
password,
|
||||||
'g-recaptcha-response': recaptchaData,
|
'g-recaptcha-response': recaptchaData,
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.then((response) => {
|
.then(response => {
|
||||||
if (!(response.data instanceof Object)) {
|
if (!(response.data instanceof Object)) {
|
||||||
return reject(new Error('An error occurred while processing the login request.'));
|
return reject(new Error('An error occurred while processing the login request.'));
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,11 +8,11 @@ export default (token: string, code: string, recoveryToken?: string): Promise<Lo
|
||||||
authentication_code: code,
|
authentication_code: code,
|
||||||
recovery_token: recoveryToken && recoveryToken.length > 0 ? recoveryToken : undefined,
|
recovery_token: recoveryToken && recoveryToken.length > 0 ? recoveryToken : undefined,
|
||||||
})
|
})
|
||||||
.then((response) =>
|
.then(response =>
|
||||||
resolve({
|
resolve({
|
||||||
complete: response.data.data.complete,
|
complete: response.data.data.complete,
|
||||||
intended: response.data.data.intended || undefined,
|
intended: response.data.data.intended || undefined,
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.catch(reject);
|
.catch(reject);
|
||||||
});
|
});
|
||||||
|
|
|
@ -19,11 +19,11 @@ export default (email: string, data: Data): Promise<PasswordResetResponse> => {
|
||||||
password: data.password,
|
password: data.password,
|
||||||
password_confirmation: data.passwordConfirmation,
|
password_confirmation: data.passwordConfirmation,
|
||||||
})
|
})
|
||||||
.then((response) =>
|
.then(response =>
|
||||||
resolve({
|
resolve({
|
||||||
redirectTo: response.data.redirect_to,
|
redirectTo: response.data.redirect_to,
|
||||||
sendToLogin: response.data.send_to_login,
|
sendToLogin: response.data.send_to_login,
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.catch(reject);
|
.catch(reject);
|
||||||
});
|
});
|
||||||
|
|
|
@ -3,7 +3,7 @@ import http from '@/api/http';
|
||||||
export default (email: string, recaptchaData?: string): Promise<string> => {
|
export default (email: string, recaptchaData?: string): Promise<string> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
http.post('/auth/password', { email, 'g-recaptcha-response': recaptchaData })
|
http.post('/auth/password', { email, 'g-recaptcha-response': recaptchaData })
|
||||||
.then((response) => resolve(response.data.status || ''))
|
.then(response => resolve(response.data.status || ''))
|
||||||
.catch(reject);
|
.catch(reject);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
@ -15,17 +15,17 @@ function transform<T, M>(data: null | undefined, transformer: TransformerFunc<T>
|
||||||
function transform<T, M>(
|
function transform<T, M>(
|
||||||
data: FractalResponseData | null | undefined,
|
data: FractalResponseData | null | undefined,
|
||||||
transformer: TransformerFunc<T>,
|
transformer: TransformerFunc<T>,
|
||||||
missing?: M
|
missing?: M,
|
||||||
): T | M;
|
): T | M;
|
||||||
function transform<T, M>(
|
function transform<T, M>(
|
||||||
data: FractalResponseList | FractalPaginatedResponse | null | undefined,
|
data: FractalResponseList | FractalPaginatedResponse | null | undefined,
|
||||||
transformer: TransformerFunc<T>,
|
transformer: TransformerFunc<T>,
|
||||||
missing?: M
|
missing?: M,
|
||||||
): T[] | M;
|
): T[] | M;
|
||||||
function transform<T>(
|
function transform<T>(
|
||||||
data: FractalResponseData | FractalResponseList | FractalPaginatedResponse | null | undefined,
|
data: FractalResponseData | FractalResponseList | FractalPaginatedResponse | null | undefined,
|
||||||
transformer: TransformerFunc<T>,
|
transformer: TransformerFunc<T>,
|
||||||
missing = undefined
|
missing = undefined,
|
||||||
) {
|
) {
|
||||||
if (data === undefined || data === null) {
|
if (data === undefined || data === null) {
|
||||||
return missing;
|
return missing;
|
||||||
|
@ -44,7 +44,7 @@ function transform<T>(
|
||||||
|
|
||||||
function toPaginatedSet<T extends TransformerFunc<Model>>(
|
function toPaginatedSet<T extends TransformerFunc<Model>>(
|
||||||
response: FractalPaginatedResponse,
|
response: FractalPaginatedResponse,
|
||||||
transformer: T
|
transformer: T,
|
||||||
): PaginatedResult<ReturnType<T>> {
|
): PaginatedResult<ReturnType<T>> {
|
||||||
return {
|
return {
|
||||||
items: transform(response, transformer) as ReturnType<T>[],
|
items: transform(response, transformer) as ReturnType<T>[],
|
||||||
|
|
|
@ -19,7 +19,7 @@ export default ({ query, ...params }: QueryParams): Promise<PaginatedResult<Serv
|
||||||
resolve({
|
resolve({
|
||||||
items: (data.data || []).map((datum: any) => rawDataToServerObject(datum)),
|
items: (data.data || []).map((datum: any) => rawDataToServerObject(datum)),
|
||||||
pagination: getPaginationSet(data.meta.pagination),
|
pagination: getPaginationSet(data.meta.pagination),
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.catch(reject);
|
.catch(reject);
|
||||||
});
|
});
|
||||||
|
|
|
@ -11,7 +11,7 @@ const http: AxiosInstance = axios.create({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
http.interceptors.request.use((req) => {
|
http.interceptors.request.use(req => {
|
||||||
if (!req.url?.endsWith('/resources')) {
|
if (!req.url?.endsWith('/resources')) {
|
||||||
store.getActions().progress.startContinuous();
|
store.getActions().progress.startContinuous();
|
||||||
}
|
}
|
||||||
|
@ -20,18 +20,18 @@ http.interceptors.request.use((req) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
http.interceptors.response.use(
|
http.interceptors.response.use(
|
||||||
(resp) => {
|
resp => {
|
||||||
if (!resp.request?.url?.endsWith('/resources')) {
|
if (!resp.request?.url?.endsWith('/resources')) {
|
||||||
store.getActions().progress.setComplete();
|
store.getActions().progress.setComplete();
|
||||||
}
|
}
|
||||||
|
|
||||||
return resp;
|
return resp;
|
||||||
},
|
},
|
||||||
(error) => {
|
error => {
|
||||||
store.getActions().progress.setComplete();
|
store.getActions().progress.setComplete();
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export default http;
|
export default http;
|
||||||
|
|
|
@ -1,21 +1,22 @@
|
||||||
import http from '@/api/http';
|
import type { AxiosError } from 'axios';
|
||||||
import { AxiosError } from 'axios';
|
import type { NavigateFunction } from 'react-router-dom';
|
||||||
import { History } from 'history';
|
|
||||||
|
|
||||||
export const setupInterceptors = (history: History) => {
|
import http from '@/api/http';
|
||||||
|
|
||||||
|
export const setupInterceptors = (navigate: NavigateFunction) => {
|
||||||
http.interceptors.response.use(
|
http.interceptors.response.use(
|
||||||
(resp) => resp,
|
resp => resp,
|
||||||
(error: AxiosError) => {
|
(error: AxiosError) => {
|
||||||
if (error.response?.status === 400) {
|
if (error.response?.status === 400) {
|
||||||
if (
|
if (
|
||||||
(error.response?.data as Record<string, any>).errors?.[0].code === 'TwoFactorAuthRequiredException'
|
(error.response?.data as Record<string, any>).errors?.[0].code === 'TwoFactorAuthRequiredException'
|
||||||
) {
|
) {
|
||||||
if (!window.location.pathname.startsWith('/account')) {
|
if (!window.location.pathname.startsWith('/account')) {
|
||||||
history.replace('/account', { twoFactorRedirect: true });
|
navigate('/account', { state: { twoFactorRedirect: true } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,8 +1,12 @@
|
||||||
import useSWR, { ConfigInterface, responseInterface } from 'swr';
|
import type { AxiosError } from 'axios';
|
||||||
import { ActivityLog, Transformers } from '@definitions/user';
|
import type { SWRConfiguration } from 'swr';
|
||||||
import { AxiosError } from 'axios';
|
import useSWR from 'swr';
|
||||||
import http, { PaginatedResult, QueryBuilderParams, withQueryBuilderParams } from '@/api/http';
|
|
||||||
|
import type { PaginatedResult, QueryBuilderParams } from '@/api/http';
|
||||||
|
import http, { withQueryBuilderParams } from '@/api/http';
|
||||||
import { toPaginatedSet } from '@definitions/helpers';
|
import { toPaginatedSet } from '@definitions/helpers';
|
||||||
|
import type { ActivityLog } from '@definitions/user';
|
||||||
|
import { Transformers } from '@definitions/user';
|
||||||
import useFilteredObject from '@/plugins/useFilteredObject';
|
import useFilteredObject from '@/plugins/useFilteredObject';
|
||||||
import { useServerSWRKey } from '@/plugins/useSWRKey';
|
import { useServerSWRKey } from '@/plugins/useSWRKey';
|
||||||
import { ServerContext } from '@/state/server';
|
import { ServerContext } from '@/state/server';
|
||||||
|
@ -11,9 +15,9 @@ export type ActivityLogFilters = QueryBuilderParams<'ip' | 'event', 'timestamp'>
|
||||||
|
|
||||||
const useActivityLogs = (
|
const useActivityLogs = (
|
||||||
filters?: ActivityLogFilters,
|
filters?: ActivityLogFilters,
|
||||||
config?: ConfigInterface<PaginatedResult<ActivityLog>, AxiosError>
|
config?: SWRConfiguration<PaginatedResult<ActivityLog>, AxiosError>,
|
||||||
): responseInterface<PaginatedResult<ActivityLog>, AxiosError> => {
|
) => {
|
||||||
const uuid = ServerContext.useStoreState((state) => state.server.data?.uuid);
|
const uuid = ServerContext.useStoreState(state => state.server.data?.uuid);
|
||||||
const key = useServerSWRKey(['activity', useFilteredObject(filters || {})]);
|
const key = useServerSWRKey(['activity', useFilteredObject(filters || {})]);
|
||||||
|
|
||||||
return useSWR<PaginatedResult<ActivityLog>>(
|
return useSWR<PaginatedResult<ActivityLog>>(
|
||||||
|
@ -28,7 +32,7 @@ const useActivityLogs = (
|
||||||
|
|
||||||
return toPaginatedSet(data, Transformers.toActivityLog);
|
return toPaginatedSet(data, Transformers.toActivityLog);
|
||||||
},
|
},
|
||||||
{ revalidateOnMount: false, ...(config || {}) }
|
{ revalidateOnMount: false, ...(config || {}) },
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -11,9 +11,9 @@ export default (uuid: string, data: { connectionsFrom: string; databaseName: str
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
params: { include: 'password' },
|
params: { include: 'password' },
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
.then((response) => resolve(rawDataToServerDatabase(response.data.attributes)))
|
.then(response => resolve(rawDataToServerDatabase(response.data.attributes)))
|
||||||
.catch(reject);
|
.catch(reject);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
@ -23,8 +23,8 @@ export default (uuid: string, includePassword = true): Promise<ServerDatabase[]>
|
||||||
http.get(`/api/client/servers/${uuid}/databases`, {
|
http.get(`/api/client/servers/${uuid}/databases`, {
|
||||||
params: includePassword ? { include: 'password' } : undefined,
|
params: includePassword ? { include: 'password' } : undefined,
|
||||||
})
|
})
|
||||||
.then((response) =>
|
.then(response =>
|
||||||
resolve((response.data.data || []).map((item: any) => rawDataToServerDatabase(item.attributes)))
|
resolve((response.data.data || []).map((item: any) => rawDataToServerDatabase(item.attributes))),
|
||||||
)
|
)
|
||||||
.catch(reject);
|
.catch(reject);
|
||||||
});
|
});
|
||||||
|
|
|
@ -4,7 +4,7 @@ import http from '@/api/http';
|
||||||
export default (uuid: string, database: string): Promise<ServerDatabase> => {
|
export default (uuid: string, database: string): Promise<ServerDatabase> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
http.post(`/api/client/servers/${uuid}/databases/${database}/rotate-password`)
|
http.post(`/api/client/servers/${uuid}/databases/${database}/rotate-password`)
|
||||||
.then((response) => resolve(rawDataToServerDatabase(response.data.attributes)))
|
.then(response => resolve(rawDataToServerDatabase(response.data.attributes)))
|
||||||
.catch(reject);
|
.catch(reject);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
@ -10,7 +10,7 @@ export default async (uuid: string, directory: string, files: string[]): Promise
|
||||||
timeout: 60000,
|
timeout: 60000,
|
||||||
timeoutErrorMessage:
|
timeoutErrorMessage:
|
||||||
'It looks like this archive is taking a long time to generate. It will appear once completed.',
|
'It looks like this archive is taking a long time to generate. It will appear once completed.',
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return rawDataToFileObject(data);
|
return rawDataToFileObject(data);
|
||||||
|
|
|
@ -8,6 +8,6 @@ export default async (uuid: string, directory: string, file: string): Promise<vo
|
||||||
timeout: 300000,
|
timeout: 300000,
|
||||||
timeoutErrorMessage:
|
timeoutErrorMessage:
|
||||||
'It looks like this archive is taking a long time to be unarchived. Once completed the unarchived files will appear.',
|
'It looks like this archive is taking a long time to be unarchived. Once completed the unarchived files will appear.',
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -4,7 +4,7 @@ export default (server: string, file: string): Promise<string> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
http.get(`/api/client/servers/${server}/files/contents`, {
|
http.get(`/api/client/servers/${server}/files/contents`, {
|
||||||
params: { file },
|
params: { file },
|
||||||
transformResponse: (res) => res,
|
transformResponse: res => res,
|
||||||
responseType: 'text',
|
responseType: 'text',
|
||||||
})
|
})
|
||||||
.then(({ data }) => resolve(data))
|
.then(({ data }) => resolve(data))
|
||||||
|
|
|
@ -25,7 +25,7 @@ export interface Server {
|
||||||
};
|
};
|
||||||
invocation: string;
|
invocation: string;
|
||||||
dockerImage: string;
|
dockerImage: string;
|
||||||
description: string;
|
description: string | null;
|
||||||
limits: {
|
limits: {
|
||||||
memory: number;
|
memory: number;
|
||||||
swap: number;
|
swap: number;
|
||||||
|
@ -65,10 +65,10 @@ export const rawDataToServerObject = ({ attributes: data }: FractalResponseData)
|
||||||
featureLimits: { ...data.feature_limits },
|
featureLimits: { ...data.feature_limits },
|
||||||
isTransferring: data.is_transferring,
|
isTransferring: data.is_transferring,
|
||||||
variables: ((data.relationships?.variables as FractalResponseList | undefined)?.data || []).map(
|
variables: ((data.relationships?.variables as FractalResponseList | undefined)?.data || []).map(
|
||||||
rawDataToServerEggVariable
|
rawDataToServerEggVariable,
|
||||||
),
|
),
|
||||||
allocations: ((data.relationships?.allocations as FractalResponseList | undefined)?.data || []).map(
|
allocations: ((data.relationships?.allocations as FractalResponseList | undefined)?.data || []).map(
|
||||||
rawDataToServerAllocation
|
rawDataToServerAllocation,
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -80,7 +80,7 @@ export default (uuid: string): Promise<[Server, string[]]> => {
|
||||||
rawDataToServerObject(data),
|
rawDataToServerObject(data),
|
||||||
// eslint-disable-next-line camelcase
|
// eslint-disable-next-line camelcase
|
||||||
data.meta?.is_server_owner ? ['*'] : data.meta?.user_permissions || [],
|
data.meta?.is_server_owner ? ['*'] : data.meta?.user_permissions || [],
|
||||||
])
|
]),
|
||||||
)
|
)
|
||||||
.catch(reject);
|
.catch(reject);
|
||||||
});
|
});
|
||||||
|
|
|
@ -26,7 +26,7 @@ export default (server: string): Promise<ServerStats> => {
|
||||||
networkRxInBytes: attributes.resources.network_rx_bytes,
|
networkRxInBytes: attributes.resources.network_rx_bytes,
|
||||||
networkTxInBytes: attributes.resources.network_tx_bytes,
|
networkTxInBytes: attributes.resources.network_tx_bytes,
|
||||||
uptime: attributes.resources.uptime,
|
uptime: attributes.resources.uptime,
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.catch(reject);
|
.catch(reject);
|
||||||
});
|
});
|
||||||
|
|
|
@ -12,7 +12,7 @@ export default (server: string): Promise<Response> => {
|
||||||
resolve({
|
resolve({
|
||||||
token: data.data.token,
|
token: data.data.token,
|
||||||
socket: data.data.socket,
|
socket: data.data.socket,
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.catch(reject);
|
.catch(reject);
|
||||||
});
|
});
|
||||||
|
|
|
@ -16,7 +16,7 @@ export default async (uuid: string, schedule: number, task: number | undefined,
|
||||||
payload: data.payload,
|
payload: data.payload,
|
||||||
continue_on_failure: data.continueOnFailure,
|
continue_on_failure: data.continueOnFailure,
|
||||||
time_offset: data.timeOffset,
|
time_offset: data.timeOffset,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return rawDataToServerTask(response.attributes);
|
return rawDataToServerTask(response.attributes);
|
||||||
|
|
|
@ -12,7 +12,7 @@ export default (uuid: string, params: Params, subuser?: Subuser): Promise<Subuse
|
||||||
http.post(`/api/client/servers/${uuid}/users${subuser ? `/${subuser.uuid}` : ''}`, {
|
http.post(`/api/client/servers/${uuid}/users${subuser ? `/${subuser.uuid}` : ''}`, {
|
||||||
...params,
|
...params,
|
||||||
})
|
})
|
||||||
.then((data) => resolve(rawDataToServerSubuser(data.data)))
|
.then(data => resolve(rawDataToServerSubuser(data.data)))
|
||||||
.catch(reject);
|
.catch(reject);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
@ -9,7 +9,7 @@ export const rawDataToServerSubuser = (data: FractalResponseData): Subuser => ({
|
||||||
twoFactorEnabled: data.attributes['2fa_enabled'],
|
twoFactorEnabled: data.attributes['2fa_enabled'],
|
||||||
createdAt: new Date(data.attributes.created_at),
|
createdAt: new Date(data.attributes.created_at),
|
||||||
permissions: data.attributes.permissions || [],
|
permissions: data.attributes.permissions || [],
|
||||||
can: (permission) => (data.attributes.permissions || []).indexOf(permission) >= 0,
|
can: permission => (data.attributes.permissions || []).indexOf(permission) >= 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
export default (uuid: string): Promise<Subuser[]> => {
|
export default (uuid: string): Promise<Subuser[]> => {
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
import { ServerContext } from '@/state/server';
|
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
|
||||||
import http from '@/api/http';
|
import http from '@/api/http';
|
||||||
import { rawDataToServerAllocation } from '@/api/transformers';
|
|
||||||
import { Allocation } from '@/api/server/getServer';
|
import { Allocation } from '@/api/server/getServer';
|
||||||
|
import { rawDataToServerAllocation } from '@/api/transformers';
|
||||||
|
import { ServerContext } from '@/state/server';
|
||||||
|
|
||||||
export default () => {
|
export default () => {
|
||||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||||
|
|
||||||
return useSWR<Allocation[]>(
|
return useSWR<Allocation[]>(
|
||||||
['server:allocations', uuid],
|
['server:allocations', uuid],
|
||||||
|
@ -14,6 +15,6 @@ export default () => {
|
||||||
|
|
||||||
return (data.data || []).map(rawDataToServerAllocation);
|
return (data.data || []).map(rawDataToServerAllocation);
|
||||||
},
|
},
|
||||||
{ revalidateOnFocus: false, revalidateOnMount: false }
|
{ revalidateOnFocus: false, revalidateOnMount: false },
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
|
import { createContext, useContext } from 'react';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import http, { getPaginationSet, PaginatedResult } from '@/api/http';
|
|
||||||
import { ServerBackup } from '@/api/server/types';
|
import type { PaginatedResult } from '@/api/http';
|
||||||
|
import http, { getPaginationSet } from '@/api/http';
|
||||||
|
import type { ServerBackup } from '@/api/server/types';
|
||||||
import { rawDataToServerBackup } from '@/api/transformers';
|
import { rawDataToServerBackup } from '@/api/transformers';
|
||||||
import { ServerContext } from '@/state/server';
|
import { ServerContext } from '@/state/server';
|
||||||
import { createContext, useContext } from 'react';
|
|
||||||
|
|
||||||
interface ctx {
|
interface ctx {
|
||||||
page: number;
|
page: number;
|
||||||
|
@ -16,7 +18,7 @@ type BackupResponse = PaginatedResult<ServerBackup> & { backupCount: number };
|
||||||
|
|
||||||
export default () => {
|
export default () => {
|
||||||
const { page } = useContext(Context);
|
const { page } = useContext(Context);
|
||||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||||
|
|
||||||
return useSWR<BackupResponse>(['server:backups', uuid, page], async () => {
|
return useSWR<BackupResponse>(['server:backups', uuid, page], async () => {
|
||||||
const { data } = await http.get(`/api/client/servers/${uuid}/backups`, { params: { page } });
|
const { data } = await http.get(`/api/client/servers/${uuid}/backups`, { params: { page } });
|
||||||
|
|
|
@ -1,7 +1,10 @@
|
||||||
import useSWR, { ConfigInterface } from 'swr';
|
import type { AxiosError } from 'axios';
|
||||||
|
import type { SWRConfiguration } from 'swr';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
import http, { FractalResponseList } from '@/api/http';
|
import http, { FractalResponseList } from '@/api/http';
|
||||||
|
import type { ServerEggVariable } from '@/api/server/types';
|
||||||
import { rawDataToServerEggVariable } from '@/api/transformers';
|
import { rawDataToServerEggVariable } from '@/api/transformers';
|
||||||
import { ServerEggVariable } from '@/api/server/types';
|
|
||||||
|
|
||||||
interface Response {
|
interface Response {
|
||||||
invocation: string;
|
invocation: string;
|
||||||
|
@ -9,7 +12,7 @@ interface Response {
|
||||||
dockerImages: Record<string, string>;
|
dockerImages: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default (uuid: string, initialData?: Response | null, config?: ConfigInterface<Response>) =>
|
export default (uuid: string, fallbackData?: Response, config?: SWRConfiguration<Response, AxiosError>) =>
|
||||||
useSWR(
|
useSWR(
|
||||||
[uuid, '/startup'],
|
[uuid, '/startup'],
|
||||||
async (): Promise<Response> => {
|
async (): Promise<Response> => {
|
||||||
|
@ -23,5 +26,5 @@ export default (uuid: string, initialData?: Response | null, config?: ConfigInte
|
||||||
dockerImages: data.meta.docker_images || {},
|
dockerImages: data.meta.docker_images || {},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
{ initialData: initialData || undefined, errorRetryCount: 3, ...(config || {}) }
|
{ fallbackData, errorRetryCount: 3, ...(config ?? {}) },
|
||||||
);
|
);
|
||||||
|
|
|
@ -49,7 +49,7 @@ export const rawDataToFileObject = (data: FractalResponseData): FileObject => ({
|
||||||
|
|
||||||
const matches = ['application/jar', 'application/octet-stream', 'inode/directory', /^image\//];
|
const matches = ['application/jar', 'application/octet-stream', 'inode/directory', /^image\//];
|
||||||
|
|
||||||
return matches.every((m) => !this.mimetype.match(m));
|
return matches.every(m => !this.mimetype.match(m));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
import { createGlobalStyle } from 'styled-components/macro';
|
import { createGlobalStyle } from 'styled-components';
|
||||||
|
|
||||||
export default createGlobalStyle`
|
export default createGlobalStyle`
|
||||||
body {
|
body {
|
||||||
|
|
|
@ -1,23 +1,20 @@
|
||||||
import React, { lazy } from 'react';
|
|
||||||
import { hot } from 'react-hot-loader/root';
|
|
||||||
import { Route, Router, Switch } from 'react-router-dom';
|
|
||||||
import { StoreProvider } from 'easy-peasy';
|
import { StoreProvider } from 'easy-peasy';
|
||||||
import { store } from '@/state';
|
import { lazy } from 'react';
|
||||||
import { SiteSettings } from '@/state/settings';
|
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
||||||
|
|
||||||
|
import '@/assets/tailwind.css';
|
||||||
|
import GlobalStylesheet from '@/assets/css/GlobalStylesheet';
|
||||||
|
import AuthenticatedRoute from '@/components/elements/AuthenticatedRoute';
|
||||||
import ProgressBar from '@/components/elements/ProgressBar';
|
import ProgressBar from '@/components/elements/ProgressBar';
|
||||||
import { NotFound } from '@/components/elements/ScreenBlock';
|
import { NotFound } from '@/components/elements/ScreenBlock';
|
||||||
import tw from 'twin.macro';
|
|
||||||
import GlobalStylesheet from '@/assets/css/GlobalStylesheet';
|
|
||||||
import { history } from '@/components/history';
|
|
||||||
import { setupInterceptors } from '@/api/interceptors';
|
|
||||||
import AuthenticatedRoute from '@/components/elements/AuthenticatedRoute';
|
|
||||||
import { ServerContext } from '@/state/server';
|
|
||||||
import '@/assets/tailwind.css';
|
|
||||||
import Spinner from '@/components/elements/Spinner';
|
import Spinner from '@/components/elements/Spinner';
|
||||||
|
import { store } from '@/state';
|
||||||
|
import { ServerContext } from '@/state/server';
|
||||||
|
import { SiteSettings } from '@/state/settings';
|
||||||
|
|
||||||
const DashboardRouter = lazy(() => import(/* webpackChunkName: "dashboard" */ '@/routers/DashboardRouter'));
|
const DashboardRouter = lazy(() => import('@/routers/DashboardRouter'));
|
||||||
const ServerRouter = lazy(() => import(/* webpackChunkName: "server" */ '@/routers/ServerRouter'));
|
const ServerRouter = lazy(() => import('@/routers/ServerRouter'));
|
||||||
const AuthenticationRouter = lazy(() => import(/* webpackChunkName: "auth" */ '@/routers/AuthenticationRouter'));
|
const AuthenticationRouter = lazy(() => import('@/routers/AuthenticationRouter'));
|
||||||
|
|
||||||
interface ExtendedWindow extends Window {
|
interface ExtendedWindow extends Window {
|
||||||
SiteConfiguration?: SiteSettings;
|
SiteConfiguration?: SiteSettings;
|
||||||
|
@ -35,9 +32,9 @@ interface ExtendedWindow extends Window {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
setupInterceptors(history);
|
// setupInterceptors(history);
|
||||||
|
|
||||||
const App = () => {
|
function App() {
|
||||||
const { PterodactylUser, SiteConfiguration } = window as ExtendedWindow;
|
const { PterodactylUser, SiteConfiguration } = window as ExtendedWindow;
|
||||||
if (PterodactylUser && !store.getState().user.data) {
|
if (PterodactylUser && !store.getState().user.data) {
|
||||||
store.getActions().user.setUserData({
|
store.getActions().user.setUserData({
|
||||||
|
@ -58,38 +55,55 @@ const App = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{/* @ts-expect-error go away */}
|
||||||
<GlobalStylesheet />
|
<GlobalStylesheet />
|
||||||
|
|
||||||
<StoreProvider store={store}>
|
<StoreProvider store={store}>
|
||||||
<ProgressBar />
|
<ProgressBar />
|
||||||
<div css={tw`mx-auto w-auto`}>
|
|
||||||
<Router history={history}>
|
<div className="mx-auto w-auto">
|
||||||
<Switch>
|
<BrowserRouter>
|
||||||
<Route path={'/auth'}>
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/auth/*"
|
||||||
|
element={
|
||||||
<Spinner.Suspense>
|
<Spinner.Suspense>
|
||||||
<AuthenticationRouter />
|
<AuthenticationRouter />
|
||||||
</Spinner.Suspense>
|
</Spinner.Suspense>
|
||||||
</Route>
|
}
|
||||||
<AuthenticatedRoute path={'/server/:id'}>
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/server/:id/*"
|
||||||
|
element={
|
||||||
|
<AuthenticatedRoute>
|
||||||
<Spinner.Suspense>
|
<Spinner.Suspense>
|
||||||
<ServerContext.Provider>
|
<ServerContext.Provider>
|
||||||
<ServerRouter />
|
<ServerRouter />
|
||||||
</ServerContext.Provider>
|
</ServerContext.Provider>
|
||||||
</Spinner.Suspense>
|
</Spinner.Suspense>
|
||||||
</AuthenticatedRoute>
|
</AuthenticatedRoute>
|
||||||
<AuthenticatedRoute path={'/'}>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/*"
|
||||||
|
element={
|
||||||
|
<AuthenticatedRoute>
|
||||||
<Spinner.Suspense>
|
<Spinner.Suspense>
|
||||||
<DashboardRouter />
|
<DashboardRouter />
|
||||||
</Spinner.Suspense>
|
</Spinner.Suspense>
|
||||||
</AuthenticatedRoute>
|
</AuthenticatedRoute>
|
||||||
<Route path={'*'}>
|
}
|
||||||
<NotFound />
|
/>
|
||||||
</Route>
|
|
||||||
</Switch>
|
<Route path="*" element={<NotFound />} />
|
||||||
</Router>
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
</div>
|
</div>
|
||||||
</StoreProvider>
|
</StoreProvider>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default hot(App);
|
export { App };
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import React from 'react';
|
|
||||||
import BoringAvatar, { AvatarProps } from 'boring-avatars';
|
import BoringAvatar, { AvatarProps } from 'boring-avatars';
|
||||||
import { useStoreState } from '@/state/hooks';
|
import { useStoreState } from '@/state/hooks';
|
||||||
|
|
||||||
|
@ -11,7 +10,7 @@ const _Avatar = ({ variant = 'beam', ...props }: AvatarProps) => (
|
||||||
);
|
);
|
||||||
|
|
||||||
const _UserAvatar = ({ variant = 'beam', ...props }: Omit<Props, 'name'>) => {
|
const _UserAvatar = ({ variant = 'beam', ...props }: Omit<Props, 'name'>) => {
|
||||||
const uuid = useStoreState((state) => state.user.data?.uuid);
|
const uuid = useStoreState(state => state.user.data?.uuid);
|
||||||
|
|
||||||
return <BoringAvatar colors={palette} name={uuid || 'system'} variant={variant} {...props} />;
|
return <BoringAvatar colors={palette} name={uuid || 'system'} variant={variant} {...props} />;
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React from 'react';
|
import { Fragment } from 'react';
|
||||||
import MessageBox from '@/components/MessageBox';
|
import MessageBox from '@/components/MessageBox';
|
||||||
import { useStoreState } from 'easy-peasy';
|
import { useStoreState } from 'easy-peasy';
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
|
@ -9,19 +9,17 @@ type Props = Readonly<{
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
const FlashMessageRender = ({ byKey, className }: Props) => {
|
const FlashMessageRender = ({ byKey, className }: Props) => {
|
||||||
const flashes = useStoreState((state) =>
|
const flashes = useStoreState(state => state.flashes.items.filter(flash => (byKey ? flash.key === byKey : true)));
|
||||||
state.flashes.items.filter((flash) => (byKey ? flash.key === byKey : true))
|
|
||||||
);
|
|
||||||
|
|
||||||
return flashes.length ? (
|
return flashes.length ? (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
{flashes.map((flash, index) => (
|
{flashes.map((flash, index) => (
|
||||||
<React.Fragment key={flash.id || flash.type + flash.message}>
|
<Fragment key={flash.id || flash.type + flash.message}>
|
||||||
{index > 0 && <div css={tw`mt-2`}></div>}
|
{index > 0 && <div css={tw`mt-2`}></div>}
|
||||||
<MessageBox type={flash.type} title={flash.title}>
|
<MessageBox type={flash.type} title={flash.title}>
|
||||||
{flash.message}
|
{flash.message}
|
||||||
</MessageBox>
|
</MessageBox>
|
||||||
</React.Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import * as React from 'react';
|
|
||||||
import tw, { TwStyle } from 'twin.macro';
|
import tw, { TwStyle } from 'twin.macro';
|
||||||
import styled from 'styled-components/macro';
|
import styled from 'styled-components';
|
||||||
|
|
||||||
export type FlashMessageType = 'success' | 'info' | 'warning' | 'error';
|
export type FlashMessageType = 'success' | 'info' | 'warning' | 'error';
|
||||||
|
|
||||||
|
@ -42,7 +41,7 @@ const getBackground = (type?: FlashMessageType): TwStyle | string => {
|
||||||
|
|
||||||
const Container = styled.div<{ $type?: FlashMessageType }>`
|
const Container = styled.div<{ $type?: FlashMessageType }>`
|
||||||
${tw`p-2 border items-center leading-normal rounded flex w-full text-sm text-white`};
|
${tw`p-2 border items-center leading-normal rounded flex w-full text-sm text-white`};
|
||||||
${(props) => styling(props.$type)};
|
${props => styling(props.$type)};
|
||||||
`;
|
`;
|
||||||
Container.displayName = 'MessageBox.Container';
|
Container.displayName = 'MessageBox.Container';
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import * as React from 'react';
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link, NavLink } from 'react-router-dom';
|
import { Link, NavLink } from 'react-router-dom';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
@ -7,7 +6,7 @@ import { useStoreState } from 'easy-peasy';
|
||||||
import { ApplicationStore } from '@/state';
|
import { ApplicationStore } from '@/state';
|
||||||
import SearchContainer from '@/components/dashboard/search/SearchContainer';
|
import SearchContainer from '@/components/dashboard/search/SearchContainer';
|
||||||
import tw, { theme } from 'twin.macro';
|
import tw, { theme } from 'twin.macro';
|
||||||
import styled from 'styled-components/macro';
|
import styled from 'styled-components';
|
||||||
import http from '@/api/http';
|
import http from '@/api/http';
|
||||||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||||
import Tooltip from '@/components/elements/tooltip/Tooltip';
|
import Tooltip from '@/components/elements/tooltip/Tooltip';
|
||||||
|
@ -39,6 +38,7 @@ export default () => {
|
||||||
|
|
||||||
const onTriggerLogout = () => {
|
const onTriggerLogout = () => {
|
||||||
setIsLoggingOut(true);
|
setIsLoggingOut(true);
|
||||||
|
|
||||||
http.post('/auth/logout').finally(() => {
|
http.post('/auth/logout').finally(() => {
|
||||||
// @ts-expect-error this is valid
|
// @ts-expect-error this is valid
|
||||||
window.location = '/';
|
window.location = '/';
|
||||||
|
@ -46,41 +46,43 @@ export default () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'w-full bg-neutral-900 shadow-md overflow-x-auto'}>
|
<div className="w-full bg-neutral-900 shadow-md overflow-x-auto">
|
||||||
<SpinnerOverlay visible={isLoggingOut} />
|
<SpinnerOverlay visible={isLoggingOut} />
|
||||||
<div className={'mx-auto w-full flex items-center h-[3.5rem] max-w-[1200px]'}>
|
<div className="mx-auto w-full flex items-center h-[3.5rem] max-w-[1200px]">
|
||||||
<div id={'logo'} className={'flex-1'}>
|
<div id="logo" className="flex-1">
|
||||||
<Link
|
<Link
|
||||||
to={'/'}
|
to="/"
|
||||||
className={
|
className="text-2xl font-header px-4 no-underline text-neutral-200 hover:text-neutral-100 transition-colors duration-150"
|
||||||
'text-2xl font-header px-4 no-underline text-neutral-200 hover:text-neutral-100 transition-colors duration-150'
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<RightNavigation className={'flex h-full items-center justify-center'}>
|
<RightNavigation className="flex h-full items-center justify-center">
|
||||||
<SearchContainer />
|
<SearchContainer />
|
||||||
<Tooltip placement={'bottom'} content={'Dashboard'}>
|
|
||||||
<NavLink to={'/'} exact>
|
<Tooltip placement="bottom" content="Dashboard">
|
||||||
|
<NavLink to="/" end>
|
||||||
<FontAwesomeIcon icon={faLayerGroup} />
|
<FontAwesomeIcon icon={faLayerGroup} />
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
{rootAdmin && (
|
{rootAdmin && (
|
||||||
<Tooltip placement={'bottom'} content={'Admin'}>
|
<Tooltip placement="bottom" content="Admin">
|
||||||
<a href={'/admin'} rel={'noreferrer'}>
|
<a href="/admin" rel="noreferrer">
|
||||||
<FontAwesomeIcon icon={faCogs} />
|
<FontAwesomeIcon icon={faCogs} />
|
||||||
</a>
|
</a>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
<Tooltip placement={'bottom'} content={'Account Settings'}>
|
|
||||||
<NavLink to={'/account'}>
|
<Tooltip placement="bottom" content="Account Settings">
|
||||||
<span className={'flex items-center w-5 h-5'}>
|
<NavLink to="/account">
|
||||||
|
<span className="flex items-center w-5 h-5">
|
||||||
<Avatar.User />
|
<Avatar.User />
|
||||||
</span>
|
</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip placement={'bottom'} content={'Sign Out'}>
|
|
||||||
|
<Tooltip placement="bottom" content="Sign Out">
|
||||||
<button onClick={onTriggerLogout}>
|
<button onClick={onTriggerLogout}>
|
||||||
<FontAwesomeIcon icon={faSignOutAlt} />
|
<FontAwesomeIcon icon={faSignOutAlt} />
|
||||||
</button>
|
</button>
|
||||||
|
|
|
@ -1,28 +1,29 @@
|
||||||
import * as React from 'react';
|
import { useStoreState } from 'easy-peasy';
|
||||||
|
import type { FormikHelpers } from 'formik';
|
||||||
|
import { Formik } from 'formik';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
import Reaptcha from 'reaptcha';
|
||||||
|
import tw from 'twin.macro';
|
||||||
|
import { object, string } from 'yup';
|
||||||
|
|
||||||
import requestPasswordResetEmail from '@/api/auth/requestPasswordResetEmail';
|
import requestPasswordResetEmail from '@/api/auth/requestPasswordResetEmail';
|
||||||
import { httpErrorToHuman } from '@/api/http';
|
import { httpErrorToHuman } from '@/api/http';
|
||||||
import LoginFormContainer from '@/components/auth/LoginFormContainer';
|
import LoginFormContainer from '@/components/auth/LoginFormContainer';
|
||||||
import { useStoreState } from 'easy-peasy';
|
|
||||||
import Field from '@/components/elements/Field';
|
|
||||||
import { Formik, FormikHelpers } from 'formik';
|
|
||||||
import { object, string } from 'yup';
|
|
||||||
import tw from 'twin.macro';
|
|
||||||
import Button from '@/components/elements/Button';
|
import Button from '@/components/elements/Button';
|
||||||
import Reaptcha from 'reaptcha';
|
import Field from '@/components/elements/Field';
|
||||||
import useFlash from '@/plugins/useFlash';
|
import useFlash from '@/plugins/useFlash';
|
||||||
|
|
||||||
interface Values {
|
interface Values {
|
||||||
email: string;
|
email: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default () => {
|
function ForgotPasswordContainer() {
|
||||||
const ref = useRef<Reaptcha>(null);
|
const ref = useRef<Reaptcha>(null);
|
||||||
const [token, setToken] = useState('');
|
const [token, setToken] = useState('');
|
||||||
|
|
||||||
const { clearFlashes, addFlash } = useFlash();
|
const { clearFlashes, addFlash } = useFlash();
|
||||||
const { enabled: recaptchaEnabled, siteKey } = useStoreState((state) => state.settings.data!.recaptcha);
|
const { enabled: recaptchaEnabled, siteKey } = useStoreState(state => state.settings.data!.recaptcha);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
clearFlashes();
|
clearFlashes();
|
||||||
|
@ -34,7 +35,7 @@ export default () => {
|
||||||
// If there is no token in the state yet, request the token and then abort this submit request
|
// If there is no token in the state yet, request the token and then abort this submit request
|
||||||
// since it will be re-submitted when the recaptcha data is returned by the component.
|
// since it will be re-submitted when the recaptcha data is returned by the component.
|
||||||
if (recaptchaEnabled && !token) {
|
if (recaptchaEnabled && !token) {
|
||||||
ref.current!.execute().catch((error) => {
|
ref.current!.execute().catch(error => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
|
@ -45,17 +46,19 @@ export default () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
requestPasswordResetEmail(email, token)
|
requestPasswordResetEmail(email, token)
|
||||||
.then((response) => {
|
.then(response => {
|
||||||
resetForm();
|
resetForm();
|
||||||
addFlash({ type: 'success', title: 'Success', message: response });
|
addFlash({ type: 'success', title: 'Success', message: response });
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch(error => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
addFlash({ type: 'error', title: 'Error', message: httpErrorToHuman(error) });
|
addFlash({ type: 'error', title: 'Error', message: httpErrorToHuman(error) });
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setToken('');
|
setToken('');
|
||||||
if (ref.current) ref.current.reset();
|
if (ref.current !== null) {
|
||||||
|
void ref.current.reset();
|
||||||
|
}
|
||||||
|
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
});
|
});
|
||||||
|
@ -92,9 +95,9 @@ export default () => {
|
||||||
ref={ref}
|
ref={ref}
|
||||||
size={'invisible'}
|
size={'invisible'}
|
||||||
sitekey={siteKey || '_invalid_key'}
|
sitekey={siteKey || '_invalid_key'}
|
||||||
onVerify={(response) => {
|
onVerify={response => {
|
||||||
setToken(response);
|
setToken(response);
|
||||||
submitForm();
|
void submitForm();
|
||||||
}}
|
}}
|
||||||
onExpire={() => {
|
onExpire={() => {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
|
@ -114,4 +117,6 @@ export default () => {
|
||||||
)}
|
)}
|
||||||
</Formik>
|
</Formik>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
|
export default ForgotPasswordContainer;
|
||||||
|
|
|
@ -1,28 +1,29 @@
|
||||||
import React, { useState } from 'react';
|
import type { ActionCreator } from 'easy-peasy';
|
||||||
import { Link, RouteComponentProps } from 'react-router-dom';
|
import { useFormikContext, withFormik } from 'formik';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import type { Location, RouteProps } from 'react-router-dom';
|
||||||
|
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import tw from 'twin.macro';
|
||||||
|
|
||||||
import loginCheckpoint from '@/api/auth/loginCheckpoint';
|
import loginCheckpoint from '@/api/auth/loginCheckpoint';
|
||||||
import LoginFormContainer from '@/components/auth/LoginFormContainer';
|
import LoginFormContainer from '@/components/auth/LoginFormContainer';
|
||||||
import { ActionCreator } from 'easy-peasy';
|
|
||||||
import { StaticContext } from 'react-router';
|
|
||||||
import { useFormikContext, withFormik } from 'formik';
|
|
||||||
import useFlash from '@/plugins/useFlash';
|
|
||||||
import { FlashStore } from '@/state/flashes';
|
|
||||||
import Field from '@/components/elements/Field';
|
|
||||||
import tw from 'twin.macro';
|
|
||||||
import Button from '@/components/elements/Button';
|
import Button from '@/components/elements/Button';
|
||||||
|
import Field from '@/components/elements/Field';
|
||||||
|
import useFlash from '@/plugins/useFlash';
|
||||||
|
import type { FlashStore } from '@/state/flashes';
|
||||||
|
|
||||||
interface Values {
|
interface Values {
|
||||||
code: string;
|
code: string;
|
||||||
recoveryCode: '';
|
recoveryCode: '';
|
||||||
}
|
}
|
||||||
|
|
||||||
type OwnProps = RouteComponentProps<Record<string, string | undefined>, StaticContext, { token?: string }>;
|
type OwnProps = RouteProps;
|
||||||
|
|
||||||
type Props = OwnProps & {
|
type Props = OwnProps & {
|
||||||
clearAndAddHttpError: ActionCreator<FlashStore['clearAndAddHttpError']['payload']>;
|
clearAndAddHttpError: ActionCreator<FlashStore['clearAndAddHttpError']['payload']>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const LoginCheckpointContainer = () => {
|
function LoginCheckpointContainer() {
|
||||||
const { isSubmitting, setFieldValue } = useFormikContext<Values>();
|
const { isSubmitting, setFieldValue } = useFormikContext<Values>();
|
||||||
const [isMissingDevice, setIsMissingDevice] = useState(false);
|
const [isMissingDevice, setIsMissingDevice] = useState(false);
|
||||||
|
|
||||||
|
@ -53,7 +54,7 @@ const LoginCheckpointContainer = () => {
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setFieldValue('code', '');
|
setFieldValue('code', '');
|
||||||
setFieldValue('recoveryCode', '');
|
setFieldValue('recoveryCode', '');
|
||||||
setIsMissingDevice((s) => !s);
|
setIsMissingDevice(s => !s);
|
||||||
}}
|
}}
|
||||||
css={tw`cursor-pointer text-xs text-neutral-500 tracking-wide uppercase no-underline hover:text-neutral-700`}
|
css={tw`cursor-pointer text-xs text-neutral-500 tracking-wide uppercase no-underline hover:text-neutral-700`}
|
||||||
>
|
>
|
||||||
|
@ -70,12 +71,12 @@ const LoginCheckpointContainer = () => {
|
||||||
</div>
|
</div>
|
||||||
</LoginFormContainer>
|
</LoginFormContainer>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
const EnhancedForm = withFormik<Props, Values>({
|
const EnhancedForm = withFormik<Props & { location: Location }, Values>({
|
||||||
handleSubmit: ({ code, recoveryCode }, { setSubmitting, props: { clearAndAddHttpError, location } }) => {
|
handleSubmit: ({ code, recoveryCode }, { setSubmitting, props: { clearAndAddHttpError, location } }) => {
|
||||||
loginCheckpoint(location.state?.token || '', code, recoveryCode)
|
loginCheckpoint(location.state?.token || '', code, recoveryCode)
|
||||||
.then((response) => {
|
.then(response => {
|
||||||
if (response.complete) {
|
if (response.complete) {
|
||||||
// @ts-expect-error this is valid
|
// @ts-expect-error this is valid
|
||||||
window.location = response.intended || '/';
|
window.location = response.intended || '/';
|
||||||
|
@ -84,7 +85,7 @@ const EnhancedForm = withFormik<Props, Values>({
|
||||||
|
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch(error => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
clearAndAddHttpError({ error });
|
clearAndAddHttpError({ error });
|
||||||
|
@ -97,16 +98,17 @@ const EnhancedForm = withFormik<Props, Values>({
|
||||||
}),
|
}),
|
||||||
})(LoginCheckpointContainer);
|
})(LoginCheckpointContainer);
|
||||||
|
|
||||||
export default ({ history, location, ...props }: OwnProps) => {
|
export default ({ ...props }: OwnProps) => {
|
||||||
const { clearAndAddHttpError } = useFlash();
|
const { clearAndAddHttpError } = useFlash();
|
||||||
|
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
if (!location.state?.token) {
|
if (!location.state?.token) {
|
||||||
history.replace('/auth/login');
|
navigate('/auth/login');
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <EnhancedForm clearAndAddHttpError={clearAndAddHttpError} location={location} {...props} />;
|
||||||
<EnhancedForm clearAndAddHttpError={clearAndAddHttpError} history={history} location={location} {...props} />
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,14 +1,16 @@
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import { useStoreState } from 'easy-peasy';
|
||||||
import { Link, RouteComponentProps } from 'react-router-dom';
|
import type { FormikHelpers } from 'formik';
|
||||||
|
import { Formik } from 'formik';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import Reaptcha from 'reaptcha';
|
||||||
|
import tw from 'twin.macro';
|
||||||
|
import { object, string } from 'yup';
|
||||||
|
|
||||||
import login from '@/api/auth/login';
|
import login from '@/api/auth/login';
|
||||||
import LoginFormContainer from '@/components/auth/LoginFormContainer';
|
import LoginFormContainer from '@/components/auth/LoginFormContainer';
|
||||||
import { useStoreState } from 'easy-peasy';
|
|
||||||
import { Formik, FormikHelpers } from 'formik';
|
|
||||||
import { object, string } from 'yup';
|
|
||||||
import Field from '@/components/elements/Field';
|
import Field from '@/components/elements/Field';
|
||||||
import tw from 'twin.macro';
|
|
||||||
import Button from '@/components/elements/Button';
|
import Button from '@/components/elements/Button';
|
||||||
import Reaptcha from 'reaptcha';
|
|
||||||
import useFlash from '@/plugins/useFlash';
|
import useFlash from '@/plugins/useFlash';
|
||||||
|
|
||||||
interface Values {
|
interface Values {
|
||||||
|
@ -16,12 +18,14 @@ interface Values {
|
||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LoginContainer = ({ history }: RouteComponentProps) => {
|
function LoginContainer() {
|
||||||
const ref = useRef<Reaptcha>(null);
|
const ref = useRef<Reaptcha>(null);
|
||||||
const [token, setToken] = useState('');
|
const [token, setToken] = useState('');
|
||||||
|
|
||||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||||
const { enabled: recaptchaEnabled, siteKey } = useStoreState((state) => state.settings.data!.recaptcha);
|
const { enabled: recaptchaEnabled, siteKey } = useStoreState(state => state.settings.data!.recaptcha);
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
clearFlashes();
|
clearFlashes();
|
||||||
|
@ -33,7 +37,7 @@ const LoginContainer = ({ history }: RouteComponentProps) => {
|
||||||
// If there is no token in the state yet, request the token and then abort this submit request
|
// If there is no token in the state yet, request the token and then abort this submit request
|
||||||
// since it will be re-submitted when the recaptcha data is returned by the component.
|
// since it will be re-submitted when the recaptcha data is returned by the component.
|
||||||
if (recaptchaEnabled && !token) {
|
if (recaptchaEnabled && !token) {
|
||||||
ref.current!.execute().catch((error) => {
|
ref.current!.execute().catch(error => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
|
@ -44,16 +48,16 @@ const LoginContainer = ({ history }: RouteComponentProps) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
login({ ...values, recaptchaData: token })
|
login({ ...values, recaptchaData: token })
|
||||||
.then((response) => {
|
.then(response => {
|
||||||
if (response.complete) {
|
if (response.complete) {
|
||||||
// @ts-expect-error this is valid
|
// @ts-expect-error this is valid
|
||||||
window.location = response.intended || '/';
|
window.location = response.intended || '/';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
history.replace('/auth/login/checkpoint', { token: response.confirmationToken });
|
navigate('/auth/login/checkpoint', { state: { token: response.confirmationToken } });
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch(error => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|
||||||
setToken('');
|
setToken('');
|
||||||
|
@ -89,7 +93,7 @@ const LoginContainer = ({ history }: RouteComponentProps) => {
|
||||||
ref={ref}
|
ref={ref}
|
||||||
size={'invisible'}
|
size={'invisible'}
|
||||||
sitekey={siteKey || '_invalid_key'}
|
sitekey={siteKey || '_invalid_key'}
|
||||||
onVerify={(response) => {
|
onVerify={response => {
|
||||||
setToken(response);
|
setToken(response);
|
||||||
submitForm();
|
submitForm();
|
||||||
}}
|
}}
|
||||||
|
@ -111,6 +115,6 @@ const LoginContainer = ({ history }: RouteComponentProps) => {
|
||||||
)}
|
)}
|
||||||
</Formik>
|
</Formik>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default LoginContainer;
|
export default LoginContainer;
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { forwardRef } from 'react';
|
import { forwardRef } from 'react';
|
||||||
|
import * as React from 'react';
|
||||||
import { Form } from 'formik';
|
import { Form } from 'formik';
|
||||||
import styled from 'styled-components/macro';
|
import styled from 'styled-components';
|
||||||
import { breakpoint } from '@/theme';
|
import { breakpoint } from '@/theme';
|
||||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import React, { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { RouteComponentProps } from 'react-router';
|
import { Link, useParams } from 'react-router-dom';
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import performPasswordReset from '@/api/auth/performPasswordReset';
|
import performPasswordReset from '@/api/auth/performPasswordReset';
|
||||||
import { httpErrorToHuman } from '@/api/http';
|
import { httpErrorToHuman } from '@/api/http';
|
||||||
import LoginFormContainer from '@/components/auth/LoginFormContainer';
|
import LoginFormContainer from '@/components/auth/LoginFormContainer';
|
||||||
|
@ -18,7 +17,7 @@ interface Values {
|
||||||
passwordConfirmation: string;
|
passwordConfirmation: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ({ match, location }: RouteComponentProps<{ token: string }>) => {
|
function ResetPasswordContainer() {
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
|
|
||||||
const { clearFlashes, addFlash } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
|
const { clearFlashes, addFlash } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
|
||||||
|
@ -28,14 +27,16 @@ export default ({ match, location }: RouteComponentProps<{ token: string }>) =>
|
||||||
setEmail(parsed.get('email') || '');
|
setEmail(parsed.get('email') || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const params = useParams<'token'>();
|
||||||
|
|
||||||
const submit = ({ password, passwordConfirmation }: Values, { setSubmitting }: FormikHelpers<Values>) => {
|
const submit = ({ password, passwordConfirmation }: Values, { setSubmitting }: FormikHelpers<Values>) => {
|
||||||
clearFlashes();
|
clearFlashes();
|
||||||
performPasswordReset(email, { token: match.params.token, password, passwordConfirmation })
|
performPasswordReset(email, { token: params.token ?? '', password, passwordConfirmation })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
// @ts-expect-error this is valid
|
// @ts-expect-error this is valid
|
||||||
window.location = '/';
|
window.location = '/';
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch(error => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
|
@ -56,7 +57,6 @@ export default ({ match, location }: RouteComponentProps<{ token: string }>) =>
|
||||||
.min(8, 'Your new password should be at least 8 characters in length.'),
|
.min(8, 'Your new password should be at least 8 characters in length.'),
|
||||||
passwordConfirmation: string()
|
passwordConfirmation: string()
|
||||||
.required('Your new password does not match.')
|
.required('Your new password does not match.')
|
||||||
// @ts-expect-error this is valid
|
|
||||||
.oneOf([ref('password'), null], 'Your new password does not match.'),
|
.oneOf([ref('password'), null], 'Your new password does not match.'),
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
|
@ -95,4 +95,6 @@ export default ({ match, location }: RouteComponentProps<{ token: string }>) =>
|
||||||
)}
|
)}
|
||||||
</Formik>
|
</Formik>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
|
export default ResetPasswordContainer;
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import ContentBox from '@/components/elements/ContentBox';
|
import ContentBox from '@/components/elements/ContentBox';
|
||||||
import CreateApiKeyForm from '@/components/dashboard/forms/CreateApiKeyForm';
|
import CreateApiKeyForm from '@/components/dashboard/forms/CreateApiKeyForm';
|
||||||
import getApiKeys, { ApiKey } from '@/api/account/getApiKeys';
|
import getApiKeys, { ApiKey } from '@/api/account/getApiKeys';
|
||||||
|
@ -23,9 +23,9 @@ export default () => {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getApiKeys()
|
getApiKeys()
|
||||||
.then((keys) => setKeys(keys))
|
.then(keys => setKeys(keys))
|
||||||
.then(() => setLoading(false))
|
.then(() => setLoading(false))
|
||||||
.catch((error) => clearAndAddHttpError(error));
|
.catch(error => clearAndAddHttpError(error));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const doDeletion = (identifier: string) => {
|
const doDeletion = (identifier: string) => {
|
||||||
|
@ -33,8 +33,8 @@ export default () => {
|
||||||
|
|
||||||
clearAndAddHttpError();
|
clearAndAddHttpError();
|
||||||
deleteApiKey(identifier)
|
deleteApiKey(identifier)
|
||||||
.then(() => setKeys((s) => [...(s || []).filter((key) => key.identifier !== identifier)]))
|
.then(() => setKeys(s => [...(s || []).filter(key => key.identifier !== identifier)]))
|
||||||
.catch((error) => clearAndAddHttpError(error))
|
.catch(error => clearAndAddHttpError(error))
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setDeleteIdentifier('');
|
setDeleteIdentifier('');
|
||||||
|
@ -46,7 +46,7 @@ export default () => {
|
||||||
<FlashMessageRender byKey={'account'} />
|
<FlashMessageRender byKey={'account'} />
|
||||||
<div css={tw`md:flex flex-nowrap my-10`}>
|
<div css={tw`md:flex flex-nowrap my-10`}>
|
||||||
<ContentBox title={'Create API Key'} css={tw`flex-none w-full md:w-1/2`}>
|
<ContentBox title={'Create API Key'} css={tw`flex-none w-full md:w-1/2`}>
|
||||||
<CreateApiKeyForm onKeyCreated={(key) => setKeys((s) => [...s!, key])} />
|
<CreateApiKeyForm onKeyCreated={key => setKeys(s => [...s!, key])} />
|
||||||
</ContentBox>
|
</ContentBox>
|
||||||
<ContentBox title={'API Keys'} css={tw`flex-1 overflow-hidden mt-8 md:mt-0 md:ml-8`}>
|
<ContentBox title={'API Keys'} css={tw`flex-1 overflow-hidden mt-8 md:mt-0 md:ml-8`}>
|
||||||
<SpinnerOverlay visible={loading} />
|
<SpinnerOverlay visible={loading} />
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import * as React from 'react';
|
|
||||||
import ContentBox from '@/components/elements/ContentBox';
|
import ContentBox from '@/components/elements/ContentBox';
|
||||||
import UpdatePasswordForm from '@/components/dashboard/forms/UpdatePasswordForm';
|
import UpdatePasswordForm from '@/components/dashboard/forms/UpdatePasswordForm';
|
||||||
import UpdateEmailAddressForm from '@/components/dashboard/forms/UpdateEmailAddressForm';
|
import UpdateEmailAddressForm from '@/components/dashboard/forms/UpdateEmailAddressForm';
|
||||||
|
@ -6,7 +5,7 @@ import ConfigureTwoFactorForm from '@/components/dashboard/forms/ConfigureTwoFac
|
||||||
import PageContentBlock from '@/components/elements/PageContentBlock';
|
import PageContentBlock from '@/components/elements/PageContentBlock';
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
import { breakpoint } from '@/theme';
|
import { breakpoint } from '@/theme';
|
||||||
import styled from 'styled-components/macro';
|
import styled from 'styled-components';
|
||||||
import MessageBox from '@/components/MessageBox';
|
import MessageBox from '@/components/MessageBox';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
|
@ -27,24 +26,26 @@ const Container = styled.div`
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default () => {
|
export default () => {
|
||||||
const { state } = useLocation<undefined | { twoFactorRedirect?: boolean }>();
|
const { state } = useLocation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContentBlock title={'Account Overview'}>
|
<PageContentBlock title="Account Overview">
|
||||||
{state?.twoFactorRedirect && (
|
{state?.twoFactorRedirect && (
|
||||||
<MessageBox title={'2-Factor Required'} type={'error'}>
|
<MessageBox title="2-Factor Required" type="error">
|
||||||
Your account must have two-factor authentication enabled in order to continue.
|
Your account must have two-factor authentication enabled in order to continue.
|
||||||
</MessageBox>
|
</MessageBox>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Container css={[tw`lg:grid lg:grid-cols-3 mb-10`, state?.twoFactorRedirect ? tw`mt-4` : tw`mt-10`]}>
|
<Container css={[tw`lg:grid lg:grid-cols-3 mb-10`, state?.twoFactorRedirect ? tw`mt-4` : tw`mt-10`]}>
|
||||||
<ContentBox title={'Update Password'} showFlashes={'account:password'}>
|
<ContentBox title="Update Password" showFlashes="account:password">
|
||||||
<UpdatePasswordForm />
|
<UpdatePasswordForm />
|
||||||
</ContentBox>
|
</ContentBox>
|
||||||
<ContentBox css={tw`mt-8 sm:mt-0 sm:ml-8`} title={'Update Email Address'} showFlashes={'account:email'}>
|
|
||||||
|
<ContentBox css={tw`mt-8 sm:mt-0 sm:ml-8`} title="Update Email Address" showFlashes="account:email">
|
||||||
<UpdateEmailAddressForm />
|
<UpdateEmailAddressForm />
|
||||||
</ContentBox>
|
</ContentBox>
|
||||||
<ContentBox css={tw`md:ml-8 mt-8 md:mt-0`} title={'Two-Step Verification'}>
|
|
||||||
|
<ContentBox css={tw`md:ml-8 mt-8 md:mt-0`} title="Two-Step Verification">
|
||||||
<ConfigureTwoFactorForm />
|
<ConfigureTwoFactorForm />
|
||||||
</ContentBox>
|
</ContentBox>
|
||||||
</Container>
|
</Container>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useContext } from 'react';
|
import { useContext } from 'react';
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
import Button from '@/components/elements/Button';
|
import Button from '@/components/elements/Button';
|
||||||
import asModal from '@/hoc/asModal';
|
import asModal from '@/hoc/asModal';
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Server } from '@/api/server/getServer';
|
import { Server } from '@/api/server/getServer';
|
||||||
import getServers from '@/api/getServers';
|
import getServers from '@/api/getServers';
|
||||||
import ServerRow from '@/components/dashboard/ServerRow';
|
import ServerRow from '@/components/dashboard/ServerRow';
|
||||||
|
@ -20,13 +20,13 @@ export default () => {
|
||||||
|
|
||||||
const [page, setPage] = useState(!isNaN(defaultPage) && defaultPage > 0 ? defaultPage : 1);
|
const [page, setPage] = useState(!isNaN(defaultPage) && defaultPage > 0 ? defaultPage : 1);
|
||||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||||
const uuid = useStoreState((state) => state.user.data!.uuid);
|
const uuid = useStoreState(state => state.user.data!.uuid);
|
||||||
const rootAdmin = useStoreState((state) => state.user.data!.rootAdmin);
|
const rootAdmin = useStoreState(state => state.user.data!.rootAdmin);
|
||||||
const [showOnlyAdmin, setShowOnlyAdmin] = usePersistedState(`${uuid}:show_all_servers`, false);
|
const [showOnlyAdmin, setShowOnlyAdmin] = usePersistedState(`${uuid}:show_all_servers`, false);
|
||||||
|
|
||||||
const { data: servers, error } = useSWR<PaginatedResult<Server>>(
|
const { data: servers, error } = useSWR<PaginatedResult<Server>>(
|
||||||
['/api/client/servers', showOnlyAdmin && rootAdmin, page],
|
['/api/client/servers', showOnlyAdmin && rootAdmin, page],
|
||||||
() => getServers({ page, type: showOnlyAdmin && rootAdmin ? 'admin' : undefined })
|
() => getServers({ page, type: showOnlyAdmin && rootAdmin ? 'admin' : undefined }),
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -58,7 +58,7 @@ export default () => {
|
||||||
<Switch
|
<Switch
|
||||||
name={'show_all_servers'}
|
name={'show_all_servers'}
|
||||||
defaultChecked={showOnlyAdmin}
|
defaultChecked={showOnlyAdmin}
|
||||||
onChange={() => setShowOnlyAdmin((s) => !s)}
|
onChange={() => setShowOnlyAdmin(s => !s)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import React, { memo, useEffect, useRef, useState } from 'react';
|
import { memo, useEffect, useRef, useState } from 'react';
|
||||||
|
import * as React from 'react';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faEthernet, faHdd, faMemory, faMicrochip, faServer } from '@fortawesome/free-solid-svg-icons';
|
import { faEthernet, faHdd, faMemory, faMicrochip, faServer } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
@ -8,7 +9,7 @@ import { bytesToString, ip, mbToBytes } from '@/lib/formatters';
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
import GreyRowBox from '@/components/elements/GreyRowBox';
|
import GreyRowBox from '@/components/elements/GreyRowBox';
|
||||||
import Spinner from '@/components/elements/Spinner';
|
import Spinner from '@/components/elements/Spinner';
|
||||||
import styled from 'styled-components/macro';
|
import styled from 'styled-components';
|
||||||
import isEqual from 'react-fast-compare';
|
import isEqual from 'react-fast-compare';
|
||||||
|
|
||||||
// Determines if the current value is in an alarm threshold so we can show it in red rather
|
// Determines if the current value is in an alarm threshold so we can show it in red rather
|
||||||
|
@ -17,14 +18,14 @@ const isAlarmState = (current: number, limit: number): boolean => limit > 0 && c
|
||||||
|
|
||||||
const Icon = memo(
|
const Icon = memo(
|
||||||
styled(FontAwesomeIcon)<{ $alarm: boolean }>`
|
styled(FontAwesomeIcon)<{ $alarm: boolean }>`
|
||||||
${(props) => (props.$alarm ? tw`text-red-400` : tw`text-neutral-500`)};
|
${props => (props.$alarm ? tw`text-red-400` : tw`text-neutral-500`)};
|
||||||
`,
|
`,
|
||||||
isEqual
|
isEqual,
|
||||||
);
|
);
|
||||||
|
|
||||||
const IconDescription = styled.p<{ $alarm: boolean }>`
|
const IconDescription = styled.p<{ $alarm: boolean }>`
|
||||||
${tw`text-sm ml-2`};
|
${tw`text-sm ml-2`};
|
||||||
${(props) => (props.$alarm ? tw`text-white` : tw`text-neutral-400`)};
|
${props => (props.$alarm ? tw`text-white` : tw`text-neutral-400`)};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StatusIndicatorBox = styled(GreyRowBox)<{ $status: ServerPowerState | undefined }>`
|
const StatusIndicatorBox = styled(GreyRowBox)<{ $status: ServerPowerState | undefined }>`
|
||||||
|
@ -56,8 +57,8 @@ export default ({ server, className }: { server: Server; className?: string }) =
|
||||||
|
|
||||||
const getStats = () =>
|
const getStats = () =>
|
||||||
getServerResourceUsage(server.uuid)
|
getServerResourceUsage(server.uuid)
|
||||||
.then((data) => setStats(data))
|
.then(data => setStats(data))
|
||||||
.catch((error) => console.error(error));
|
.catch(error => console.error(error));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsSuspended(stats?.isSuspended || server.status === 'suspended');
|
setIsSuspended(stats?.isSuspended || server.status === 'suspended');
|
||||||
|
@ -106,8 +107,8 @@ export default ({ server, className }: { server: Server; className?: string }) =
|
||||||
<FontAwesomeIcon icon={faEthernet} css={tw`text-neutral-500`} />
|
<FontAwesomeIcon icon={faEthernet} css={tw`text-neutral-500`} />
|
||||||
<p css={tw`text-sm text-neutral-400 ml-2`}>
|
<p css={tw`text-sm text-neutral-400 ml-2`}>
|
||||||
{server.allocations
|
{server.allocations
|
||||||
.filter((alloc) => alloc.isDefault)
|
.filter(alloc => alloc.isDefault)
|
||||||
.map((allocation) => (
|
.map(allocation => (
|
||||||
<React.Fragment key={allocation.ip + allocation.port.toString()}>
|
<React.Fragment key={allocation.ip + allocation.port.toString()}>
|
||||||
{allocation.alias || ip(allocation.ip)}:{allocation.port}
|
{allocation.alias || ip(allocation.ip)}:{allocation.port}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { ActivityLogFilters, useActivityLogs } from '@/api/account/activity';
|
import { ActivityLogFilters, useActivityLogs } from '@/api/account/activity';
|
||||||
import { useFlashKey } from '@/plugins/useFlash';
|
import { useFlashKey } from '@/plugins/useFlash';
|
||||||
import PageContentBlock from '@/components/elements/PageContentBlock';
|
import PageContentBlock from '@/components/elements/PageContentBlock';
|
||||||
|
@ -23,7 +23,7 @@ export default () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setFilters((value) => ({ ...value, filters: { ip: hash.ip, event: hash.event } }));
|
setFilters(value => ({ ...value, filters: { ip: hash.ip, event: hash.event } }));
|
||||||
}, [hash]);
|
}, [hash]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -38,7 +38,7 @@ export default () => {
|
||||||
<Link
|
<Link
|
||||||
to={'#'}
|
to={'#'}
|
||||||
className={classNames(btnStyles.button, btnStyles.text, 'w-full sm:w-auto')}
|
className={classNames(btnStyles.button, btnStyles.text, 'w-full sm:w-auto')}
|
||||||
onClick={() => setFilters((value) => ({ ...value, filters: {} }))}
|
onClick={() => setFilters(value => ({ ...value, filters: {} }))}
|
||||||
>
|
>
|
||||||
Clear Filters <XCircleIcon className={'w-4 h-4 ml-2'} />
|
Clear Filters <XCircleIcon className={'w-4 h-4 ml-2'} />
|
||||||
</Link>
|
</Link>
|
||||||
|
@ -48,7 +48,7 @@ export default () => {
|
||||||
<Spinner centered />
|
<Spinner centered />
|
||||||
) : (
|
) : (
|
||||||
<div className={'bg-gray-700'}>
|
<div className={'bg-gray-700'}>
|
||||||
{data?.items.map((activity) => (
|
{data?.items.map(activity => (
|
||||||
<ActivityLogEntry key={activity.id} activity={activity}>
|
<ActivityLogEntry key={activity.id} activity={activity}>
|
||||||
{typeof activity.properties.useragent === 'string' && (
|
{typeof activity.properties.useragent === 'string' && (
|
||||||
<Tooltip content={activity.properties.useragent} placement={'top'}>
|
<Tooltip content={activity.properties.useragent} placement={'top'}>
|
||||||
|
@ -64,7 +64,7 @@ export default () => {
|
||||||
{data && (
|
{data && (
|
||||||
<PaginationFooter
|
<PaginationFooter
|
||||||
pagination={data.pagination}
|
pagination={data.pagination}
|
||||||
onPageSelect={(page) => setFilters((value) => ({ ...value, page }))}
|
onPageSelect={page => setFilters(value => ({ ...value, page }))}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</PageContentBlock>
|
</PageContentBlock>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useStoreState } from 'easy-peasy';
|
import { useStoreState } from 'easy-peasy';
|
||||||
import { ApplicationStore } from '@/state';
|
import { ApplicationStore } from '@/state';
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Field, Form, Formik, FormikHelpers } from 'formik';
|
import { Field, Form, Formik, FormikHelpers } from 'formik';
|
||||||
import { object, string } from 'yup';
|
import { object, string } from 'yup';
|
||||||
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
|
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
|
||||||
|
@ -11,7 +11,7 @@ import { ApiKey } from '@/api/account/getApiKeys';
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
import Button from '@/components/elements/Button';
|
import Button from '@/components/elements/Button';
|
||||||
import Input, { Textarea } from '@/components/elements/Input';
|
import Input, { Textarea } from '@/components/elements/Input';
|
||||||
import styled from 'styled-components/macro';
|
import styled from 'styled-components';
|
||||||
import ApiKeyModal from '@/components/dashboard/ApiKeyModal';
|
import ApiKeyModal from '@/components/dashboard/ApiKeyModal';
|
||||||
|
|
||||||
interface Values {
|
interface Values {
|
||||||
|
@ -36,7 +36,7 @@ export default ({ onKeyCreated }: { onKeyCreated: (key: ApiKey) => void }) => {
|
||||||
setApiKey(`${key.identifier}${secretToken}`);
|
setApiKey(`${key.identifier}${secretToken}`);
|
||||||
onKeyCreated(key);
|
onKeyCreated(key);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch(error => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|
||||||
addError({ key: 'account', message: httpErrorToHuman(error) });
|
addError({ key: 'account', message: httpErrorToHuman(error) });
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import React, { useContext, useEffect, useState } from 'react';
|
import { useContext, useEffect, useState } from 'react';
|
||||||
|
import * as React from 'react';
|
||||||
import asDialog from '@/hoc/asDialog';
|
import asDialog from '@/hoc/asDialog';
|
||||||
import { Dialog, DialogWrapperContext } from '@/components/elements/dialog';
|
import { Dialog, DialogWrapperContext } from '@/components/elements/dialog';
|
||||||
import { Button } from '@/components/elements/button/index';
|
import { Button } from '@/components/elements/button/index';
|
||||||
|
@ -14,10 +15,10 @@ const DisableTOTPDialog = () => {
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const { clearAndAddHttpError } = useFlashKey('account:two-step');
|
const { clearAndAddHttpError } = useFlashKey('account:two-step');
|
||||||
const { close, setProps } = useContext(DialogWrapperContext);
|
const { close, setProps } = useContext(DialogWrapperContext);
|
||||||
const updateUserData = useStoreActions((actions) => actions.user.updateUserData);
|
const updateUserData = useStoreActions(actions => actions.user.updateUserData);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setProps((state) => ({ ...state, preventExternalClose: submitting }));
|
setProps(state => ({ ...state, preventExternalClose: submitting }));
|
||||||
}, [submitting]);
|
}, [submitting]);
|
||||||
|
|
||||||
const submit = (e: React.FormEvent<HTMLFormElement>) => {
|
const submit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
@ -48,7 +49,7 @@ const DisableTOTPDialog = () => {
|
||||||
type={'password'}
|
type={'password'}
|
||||||
variant={Input.Text.Variants.Loose}
|
variant={Input.Text.Variants.Loose}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
onChange={e => setPassword(e.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
<Dialog.Footer>
|
<Dialog.Footer>
|
||||||
<Button.Text onClick={close}>Cancel</Button.Text>
|
<Button.Text onClick={close}>Cancel</Button.Text>
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import React from 'react';
|
|
||||||
import { Dialog, DialogProps } from '@/components/elements/dialog';
|
import { Dialog, DialogProps } from '@/components/elements/dialog';
|
||||||
import { Button } from '@/components/elements/button/index';
|
import { Button } from '@/components/elements/button/index';
|
||||||
import CopyOnClick from '@/components/elements/CopyOnClick';
|
import CopyOnClick from '@/components/elements/CopyOnClick';
|
||||||
|
@ -30,7 +29,7 @@ export default ({ tokens, open, onClose }: RecoveryTokenDialogProps) => {
|
||||||
<Dialog.Icon position={'container'} type={'success'} />
|
<Dialog.Icon position={'container'} type={'success'} />
|
||||||
<CopyOnClick text={tokens.join('\n')} showInNotification={false}>
|
<CopyOnClick text={tokens.join('\n')} showInNotification={false}>
|
||||||
<pre className={'bg-gray-800 rounded p-2 mt-6'}>
|
<pre className={'bg-gray-800 rounded p-2 mt-6'}>
|
||||||
{grouped.map((value) => (
|
{grouped.map(value => (
|
||||||
<span key={value.join('_')} className={'block'}>
|
<span key={value.join('_')} className={'block'}>
|
||||||
{value[0]}
|
{value[0]}
|
||||||
<span className={'mx-2 selection:bg-gray-800'}> </span>
|
<span className={'mx-2 selection:bg-gray-800'}> </span>
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import React, { useContext, useEffect, useState } from 'react';
|
import { useContext, useEffect, useState } from 'react';
|
||||||
|
import * as React from 'react';
|
||||||
import { Dialog, DialogWrapperContext } from '@/components/elements/dialog';
|
import { Dialog, DialogWrapperContext } from '@/components/elements/dialog';
|
||||||
import getTwoFactorTokenData, { TwoFactorTokenData } from '@/api/account/getTwoFactorTokenData';
|
import getTwoFactorTokenData, { TwoFactorTokenData } from '@/api/account/getTwoFactorTokenData';
|
||||||
import { useFlashKey } from '@/plugins/useFlash';
|
import { useFlashKey } from '@/plugins/useFlash';
|
||||||
|
@ -32,11 +33,11 @@ const ConfigureTwoFactorForm = ({ onTokens }: Props) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getTwoFactorTokenData()
|
getTwoFactorTokenData()
|
||||||
.then(setToken)
|
.then(setToken)
|
||||||
.catch((error) => clearAndAddHttpError(error));
|
.catch(error => clearAndAddHttpError(error));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setProps((state) => ({ ...state, preventExternalClose: submitting }));
|
setProps(state => ({ ...state, preventExternalClose: submitting }));
|
||||||
}, [submitting]);
|
}, [submitting]);
|
||||||
|
|
||||||
const submit = (e: React.FormEvent<HTMLFormElement>) => {
|
const submit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
@ -48,11 +49,11 @@ const ConfigureTwoFactorForm = ({ onTokens }: Props) => {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
clearAndAddHttpError();
|
clearAndAddHttpError();
|
||||||
enableAccountTwoFactor(value, password)
|
enableAccountTwoFactor(value, password)
|
||||||
.then((tokens) => {
|
.then(tokens => {
|
||||||
updateUserData({ useTotp: true });
|
updateUserData({ useTotp: true });
|
||||||
onTokens(tokens);
|
onTokens(tokens);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch(error => {
|
||||||
clearAndAddHttpError(error);
|
clearAndAddHttpError(error);
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
});
|
});
|
||||||
|
@ -81,7 +82,7 @@ const ConfigureTwoFactorForm = ({ onTokens }: Props) => {
|
||||||
aria-labelledby={'totp-code-description'}
|
aria-labelledby={'totp-code-description'}
|
||||||
variant={Input.Text.Variants.Loose}
|
variant={Input.Text.Variants.Loose}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => setValue(e.currentTarget.value)}
|
onChange={e => setValue(e.currentTarget.value)}
|
||||||
className={'mt-3'}
|
className={'mt-3'}
|
||||||
placeholder={'000000'}
|
placeholder={'000000'}
|
||||||
type={'text'}
|
type={'text'}
|
||||||
|
@ -97,7 +98,7 @@ const ConfigureTwoFactorForm = ({ onTokens }: Props) => {
|
||||||
className={'mt-1'}
|
className={'mt-1'}
|
||||||
type={'password'}
|
type={'password'}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
onChange={e => setPassword(e.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
<Dialog.Footer>
|
<Dialog.Footer>
|
||||||
<Button.Text onClick={close}>Cancel</Button.Text>
|
<Button.Text onClick={close}>Cancel</Button.Text>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React from 'react';
|
import { Fragment } from 'react';
|
||||||
import { Actions, State, useStoreActions, useStoreState } from 'easy-peasy';
|
import { Actions, State, useStoreActions, useStoreState } from 'easy-peasy';
|
||||||
import { Form, Formik, FormikHelpers } from 'formik';
|
import { Form, Formik, FormikHelpers } from 'formik';
|
||||||
import * as Yup from 'yup';
|
import * as Yup from 'yup';
|
||||||
|
@ -34,15 +34,15 @@ export default () => {
|
||||||
type: 'success',
|
type: 'success',
|
||||||
key: 'account:email',
|
key: 'account:email',
|
||||||
message: 'Your primary email has been updated.',
|
message: 'Your primary email has been updated.',
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.catch((error) =>
|
.catch(error =>
|
||||||
addFlash({
|
addFlash({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
key: 'account:email',
|
key: 'account:email',
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
message: httpErrorToHuman(error),
|
message: httpErrorToHuman(error),
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
resetForm();
|
resetForm();
|
||||||
|
@ -53,7 +53,7 @@ export default () => {
|
||||||
return (
|
return (
|
||||||
<Formik onSubmit={submit} validationSchema={schema} initialValues={{ email: user!.email, password: '' }}>
|
<Formik onSubmit={submit} validationSchema={schema} initialValues={{ email: user!.email, password: '' }}>
|
||||||
{({ isSubmitting, isValid }) => (
|
{({ isSubmitting, isValid }) => (
|
||||||
<React.Fragment>
|
<Fragment>
|
||||||
<SpinnerOverlay size={'large'} visible={isSubmitting} />
|
<SpinnerOverlay size={'large'} visible={isSubmitting} />
|
||||||
<Form css={tw`m-0`}>
|
<Form css={tw`m-0`}>
|
||||||
<Field id={'current_email'} type={'email'} name={'email'} label={'Email'} />
|
<Field id={'current_email'} type={'email'} name={'email'} label={'Email'} />
|
||||||
|
@ -69,7 +69,7 @@ export default () => {
|
||||||
<Button disabled={isSubmitting || !isValid}>Update Email</Button>
|
<Button disabled={isSubmitting || !isValid}>Update Email</Button>
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
</React.Fragment>
|
</Fragment>
|
||||||
)}
|
)}
|
||||||
</Formik>
|
</Formik>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React from 'react';
|
import { Fragment } from 'react';
|
||||||
import { Actions, State, useStoreActions, useStoreState } from 'easy-peasy';
|
import { Actions, State, useStoreActions, useStoreState } from 'easy-peasy';
|
||||||
import { Form, Formik, FormikHelpers } from 'formik';
|
import { Form, Formik, FormikHelpers } from 'formik';
|
||||||
import Field from '@/components/elements/Field';
|
import Field from '@/components/elements/Field';
|
||||||
|
@ -24,7 +24,7 @@ const schema = Yup.object().shape({
|
||||||
'Password confirmation does not match the password you entered.',
|
'Password confirmation does not match the password you entered.',
|
||||||
function (value) {
|
function (value) {
|
||||||
return value === this.parent.password;
|
return value === this.parent.password;
|
||||||
}
|
},
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -43,26 +43,26 @@ export default () => {
|
||||||
// @ts-expect-error this is valid
|
// @ts-expect-error this is valid
|
||||||
window.location = '/auth/login';
|
window.location = '/auth/login';
|
||||||
})
|
})
|
||||||
.catch((error) =>
|
.catch(error =>
|
||||||
addFlash({
|
addFlash({
|
||||||
key: 'account:password',
|
key: 'account:password',
|
||||||
type: 'error',
|
type: 'error',
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
message: httpErrorToHuman(error),
|
message: httpErrorToHuman(error),
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.then(() => setSubmitting(false));
|
.then(() => setSubmitting(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<Fragment>
|
||||||
<Formik
|
<Formik
|
||||||
onSubmit={submit}
|
onSubmit={submit}
|
||||||
validationSchema={schema}
|
validationSchema={schema}
|
||||||
initialValues={{ current: '', password: '', confirmPassword: '' }}
|
initialValues={{ current: '', password: '', confirmPassword: '' }}
|
||||||
>
|
>
|
||||||
{({ isSubmitting, isValid }) => (
|
{({ isSubmitting, isValid }) => (
|
||||||
<React.Fragment>
|
<Fragment>
|
||||||
<SpinnerOverlay size={'large'} visible={isSubmitting} />
|
<SpinnerOverlay size={'large'} visible={isSubmitting} />
|
||||||
<Form css={tw`m-0`}>
|
<Form css={tw`m-0`}>
|
||||||
<Field
|
<Field
|
||||||
|
@ -94,9 +94,9 @@ export default () => {
|
||||||
<Button disabled={isSubmitting || !isValid}>Update Password</Button>
|
<Button disabled={isSubmitting || !isValid}>Update Password</Button>
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
</React.Fragment>
|
</Fragment>
|
||||||
)}
|
)}
|
||||||
</Formik>
|
</Formik>
|
||||||
</React.Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faSearch } from '@fortawesome/free-solid-svg-icons';
|
import { faSearch } from '@fortawesome/free-solid-svg-icons';
|
||||||
import useEventListener from '@/plugins/useEventListener';
|
import useEventListener from '@/plugins/useEventListener';
|
||||||
|
@ -18,7 +18,8 @@ export default () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{visible && <SearchModal appear visible={visible} onDismissed={() => setVisible(false)} />}
|
<SearchModal appear visible={visible} onDismissed={() => setVisible(false)} />
|
||||||
|
|
||||||
<Tooltip placement={'bottom'} content={'Search'}>
|
<Tooltip placement={'bottom'} content={'Search'}>
|
||||||
<div className={'navigation-link'} onClick={() => setVisible(true)}>
|
<div className={'navigation-link'} onClick={() => setVisible(true)}>
|
||||||
<FontAwesomeIcon icon={faSearch} />
|
<FontAwesomeIcon icon={faSearch} />
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
|
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
|
||||||
import { Field, Form, Formik, FormikHelpers, useFormikContext } from 'formik';
|
import { Field, Form, Formik, FormikHelpers, useFormikContext } from 'formik';
|
||||||
import { Actions, useStoreActions, useStoreState } from 'easy-peasy';
|
import { Actions, useStoreActions, useStoreState } from 'easy-peasy';
|
||||||
|
@ -10,7 +10,7 @@ import getServers from '@/api/getServers';
|
||||||
import { Server } from '@/api/server/getServer';
|
import { Server } from '@/api/server/getServer';
|
||||||
import { ApplicationStore } from '@/state';
|
import { ApplicationStore } from '@/state';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import styled from 'styled-components/macro';
|
import styled from 'styled-components';
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
import Input from '@/components/elements/Input';
|
import Input from '@/components/elements/Input';
|
||||||
import { ip } from '@/lib/formatters';
|
import { ip } from '@/lib/formatters';
|
||||||
|
@ -47,10 +47,10 @@ const SearchWatcher = () => {
|
||||||
|
|
||||||
export default ({ ...props }: Props) => {
|
export default ({ ...props }: Props) => {
|
||||||
const ref = useRef<HTMLInputElement>(null);
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
const isAdmin = useStoreState((state) => state.user.data!.rootAdmin);
|
const isAdmin = useStoreState(state => state.user.data!.rootAdmin);
|
||||||
const [servers, setServers] = useState<Server[]>([]);
|
const [servers, setServers] = useState<Server[]>([]);
|
||||||
const { clearAndAddHttpError, clearFlashes } = useStoreActions(
|
const { clearAndAddHttpError, clearFlashes } = useStoreActions(
|
||||||
(actions: Actions<ApplicationStore>) => actions.flashes
|
(actions: Actions<ApplicationStore>) => actions.flashes,
|
||||||
);
|
);
|
||||||
|
|
||||||
const search = debounce(({ term }: Values, { setSubmitting }: FormikHelpers<Values>) => {
|
const search = debounce(({ term }: Values, { setSubmitting }: FormikHelpers<Values>) => {
|
||||||
|
@ -58,8 +58,8 @@ export default ({ ...props }: Props) => {
|
||||||
|
|
||||||
// if (ref.current) ref.current.focus();
|
// if (ref.current) ref.current.focus();
|
||||||
getServers({ query: term, type: isAdmin ? 'admin-all' : undefined })
|
getServers({ query: term, type: isAdmin ? 'admin-all' : undefined })
|
||||||
.then((servers) => setServers(servers.items.filter((_, index) => index < 5)))
|
.then(servers => setServers(servers.items.filter((_, index) => index < 5)))
|
||||||
.catch((error) => {
|
.catch(error => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
clearAndAddHttpError({ key: 'search', error });
|
clearAndAddHttpError({ key: 'search', error });
|
||||||
})
|
})
|
||||||
|
@ -100,7 +100,7 @@ export default ({ ...props }: Props) => {
|
||||||
</Form>
|
</Form>
|
||||||
{servers.length > 0 && (
|
{servers.length > 0 && (
|
||||||
<div css={tw`mt-6`}>
|
<div css={tw`mt-6`}>
|
||||||
{servers.map((server) => (
|
{servers.map(server => (
|
||||||
<ServerResult
|
<ServerResult
|
||||||
key={server.uuid}
|
key={server.uuid}
|
||||||
to={`/server/${server.id}`}
|
to={`/server/${server.id}`}
|
||||||
|
@ -110,8 +110,8 @@ export default ({ ...props }: Props) => {
|
||||||
<p css={tw`text-sm`}>{server.name}</p>
|
<p css={tw`text-sm`}>{server.name}</p>
|
||||||
<p css={tw`mt-1 text-xs text-neutral-400`}>
|
<p css={tw`mt-1 text-xs text-neutral-400`}>
|
||||||
{server.allocations
|
{server.allocations
|
||||||
.filter((alloc) => alloc.isDefault)
|
.filter(alloc => alloc.isDefault)
|
||||||
.map((allocation) => (
|
.map(allocation => (
|
||||||
<span key={allocation.ip + allocation.port.toString()}>
|
<span key={allocation.ip + allocation.port.toString()}>
|
||||||
{allocation.alias || ip(allocation.ip)}:{allocation.port}
|
{allocation.alias || ip(allocation.ip)}:{allocation.port}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import ContentBox from '@/components/elements/ContentBox';
|
import ContentBox from '@/components/elements/ContentBox';
|
||||||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import React from 'react';
|
|
||||||
import { Field, Form, Formik, FormikHelpers } from 'formik';
|
import { Field, Form, Formik, FormikHelpers } from 'formik';
|
||||||
import { object, string } from 'yup';
|
import { object, string } from 'yup';
|
||||||
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
|
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
|
||||||
|
@ -6,7 +5,7 @@ import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
import Button from '@/components/elements/Button';
|
import Button from '@/components/elements/Button';
|
||||||
import Input, { Textarea } from '@/components/elements/Input';
|
import Input, { Textarea } from '@/components/elements/Input';
|
||||||
import styled from 'styled-components/macro';
|
import styled from 'styled-components';
|
||||||
import { useFlashKey } from '@/plugins/useFlash';
|
import { useFlashKey } from '@/plugins/useFlash';
|
||||||
import { createSSHKey, useSSHKeys } from '@/api/account/ssh-keys';
|
import { createSSHKey, useSSHKeys } from '@/api/account/ssh-keys';
|
||||||
|
|
||||||
|
@ -27,11 +26,11 @@ export default () => {
|
||||||
clearAndAddHttpError();
|
clearAndAddHttpError();
|
||||||
|
|
||||||
createSSHKey(values.name, values.publicKey)
|
createSSHKey(values.name, values.publicKey)
|
||||||
.then((key) => {
|
.then(key => {
|
||||||
resetForm();
|
resetForm();
|
||||||
mutate((data) => (data || []).concat(key));
|
mutate(data => (data || []).concat(key));
|
||||||
})
|
})
|
||||||
.catch((error) => clearAndAddHttpError(error))
|
.catch(error => clearAndAddHttpError(error))
|
||||||
.then(() => setSubmitting(false));
|
.then(() => setSubmitting(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faTrashAlt } from '@fortawesome/free-solid-svg-icons';
|
import { faTrashAlt } from '@fortawesome/free-solid-svg-icons';
|
||||||
import React, { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useFlashKey } from '@/plugins/useFlash';
|
import { useFlashKey } from '@/plugins/useFlash';
|
||||||
import { deleteSSHKey, useSSHKeys } from '@/api/account/ssh-keys';
|
import { deleteSSHKey, useSSHKeys } from '@/api/account/ssh-keys';
|
||||||
import { Dialog } from '@/components/elements/dialog';
|
import { Dialog } from '@/components/elements/dialog';
|
||||||
|
@ -16,9 +16,9 @@ export default ({ name, fingerprint }: { name: string; fingerprint: string }) =>
|
||||||
clearAndAddHttpError();
|
clearAndAddHttpError();
|
||||||
|
|
||||||
Promise.all([
|
Promise.all([
|
||||||
mutate((data) => data?.filter((value) => value.fingerprint !== fingerprint), false),
|
mutate(data => data?.filter(value => value.fingerprint !== fingerprint), false),
|
||||||
deleteSSHKey(fingerprint),
|
deleteSSHKey(fingerprint),
|
||||||
]).catch((error) => {
|
]).catch(error => {
|
||||||
mutate(undefined, true).catch(console.error);
|
mutate(undefined, true).catch(console.error);
|
||||||
clearAndAddHttpError(error);
|
clearAndAddHttpError(error);
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,16 +1,18 @@
|
||||||
import React from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { Redirect, Route, RouteProps } from 'react-router';
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
import { useStoreState } from '@/state/hooks';
|
import { useStoreState } from '@/state/hooks';
|
||||||
|
|
||||||
export default ({ children, ...props }: Omit<RouteProps, 'render'>) => {
|
function AuthenticatedRoute({ children }: { children?: ReactNode }): JSX.Element {
|
||||||
const isAuthenticated = useStoreState((state) => !!state.user.data?.uuid);
|
const isAuthenticated = useStoreState(state => !!state.user.data?.uuid);
|
||||||
|
|
||||||
return (
|
const location = useLocation();
|
||||||
<Route
|
|
||||||
{...props}
|
if (isAuthenticated) {
|
||||||
render={({ location }) =>
|
return <>{children}</>;
|
||||||
isAuthenticated ? children : <Redirect to={{ pathname: '/auth/login', state: { from: location } }} />
|
|
||||||
}
|
}
|
||||||
/>
|
|
||||||
);
|
return <Navigate to="/auth/login" state={{ from: location.pathname }} />;
|
||||||
};
|
}
|
||||||
|
|
||||||
|
export default AuthenticatedRoute;
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import React from 'react';
|
import * as React from 'react';
|
||||||
import styled, { css } from 'styled-components/macro';
|
import styled, { css } from 'styled-components';
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
import Spinner from '@/components/elements/Spinner';
|
import Spinner from '@/components/elements/Spinner';
|
||||||
|
|
||||||
|
@ -13,17 +13,17 @@ interface Props {
|
||||||
const ButtonStyle = styled.button<Omit<Props, 'isLoading'>>`
|
const ButtonStyle = styled.button<Omit<Props, 'isLoading'>>`
|
||||||
${tw`relative inline-block rounded p-2 uppercase tracking-wide text-sm transition-all duration-150 border`};
|
${tw`relative inline-block rounded p-2 uppercase tracking-wide text-sm transition-all duration-150 border`};
|
||||||
|
|
||||||
${(props) =>
|
${props =>
|
||||||
((!props.isSecondary && !props.color) || props.color === 'primary') &&
|
((!props.isSecondary && !props.color) || props.color === 'primary') &&
|
||||||
css<Props>`
|
css<Props>`
|
||||||
${(props) => !props.isSecondary && tw`bg-primary-500 border-primary-600 border text-primary-50`};
|
${props => !props.isSecondary && tw`bg-primary-500 border-primary-600 border text-primary-50`};
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) {
|
||||||
${tw`bg-primary-600 border-primary-700`};
|
${tw`bg-primary-600 border-primary-700`};
|
||||||
}
|
}
|
||||||
`};
|
`};
|
||||||
|
|
||||||
${(props) =>
|
${props =>
|
||||||
props.color === 'grey' &&
|
props.color === 'grey' &&
|
||||||
css`
|
css`
|
||||||
${tw`border-neutral-600 bg-neutral-500 text-neutral-50`};
|
${tw`border-neutral-600 bg-neutral-500 text-neutral-50`};
|
||||||
|
@ -33,7 +33,7 @@ const ButtonStyle = styled.button<Omit<Props, 'isLoading'>>`
|
||||||
}
|
}
|
||||||
`};
|
`};
|
||||||
|
|
||||||
${(props) =>
|
${props =>
|
||||||
props.color === 'green' &&
|
props.color === 'green' &&
|
||||||
css<Props>`
|
css<Props>`
|
||||||
${tw`border-green-600 bg-green-500 text-green-50`};
|
${tw`border-green-600 bg-green-500 text-green-50`};
|
||||||
|
@ -42,7 +42,7 @@ const ButtonStyle = styled.button<Omit<Props, 'isLoading'>>`
|
||||||
${tw`bg-green-600 border-green-700`};
|
${tw`bg-green-600 border-green-700`};
|
||||||
}
|
}
|
||||||
|
|
||||||
${(props) =>
|
${props =>
|
||||||
props.isSecondary &&
|
props.isSecondary &&
|
||||||
css`
|
css`
|
||||||
&:active:not(:disabled) {
|
&:active:not(:disabled) {
|
||||||
|
@ -51,7 +51,7 @@ const ButtonStyle = styled.button<Omit<Props, 'isLoading'>>`
|
||||||
`};
|
`};
|
||||||
`};
|
`};
|
||||||
|
|
||||||
${(props) =>
|
${props =>
|
||||||
props.color === 'red' &&
|
props.color === 'red' &&
|
||||||
css<Props>`
|
css<Props>`
|
||||||
${tw`border-red-600 bg-red-500 text-red-50`};
|
${tw`border-red-600 bg-red-500 text-red-50`};
|
||||||
|
@ -60,7 +60,7 @@ const ButtonStyle = styled.button<Omit<Props, 'isLoading'>>`
|
||||||
${tw`bg-red-600 border-red-700`};
|
${tw`bg-red-600 border-red-700`};
|
||||||
}
|
}
|
||||||
|
|
||||||
${(props) =>
|
${props =>
|
||||||
props.isSecondary &&
|
props.isSecondary &&
|
||||||
css`
|
css`
|
||||||
&:active:not(:disabled) {
|
&:active:not(:disabled) {
|
||||||
|
@ -69,21 +69,21 @@ const ButtonStyle = styled.button<Omit<Props, 'isLoading'>>`
|
||||||
`};
|
`};
|
||||||
`};
|
`};
|
||||||
|
|
||||||
${(props) => props.size === 'xsmall' && tw`px-2 py-1 text-xs`};
|
${props => props.size === 'xsmall' && tw`px-2 py-1 text-xs`};
|
||||||
${(props) => (!props.size || props.size === 'small') && tw`px-4 py-2`};
|
${props => (!props.size || props.size === 'small') && tw`px-4 py-2`};
|
||||||
${(props) => props.size === 'large' && tw`p-4 text-sm`};
|
${props => props.size === 'large' && tw`p-4 text-sm`};
|
||||||
${(props) => props.size === 'xlarge' && tw`p-4 w-full`};
|
${props => props.size === 'xlarge' && tw`p-4 w-full`};
|
||||||
|
|
||||||
${(props) =>
|
${props =>
|
||||||
props.isSecondary &&
|
props.isSecondary &&
|
||||||
css<Props>`
|
css<Props>`
|
||||||
${tw`border-neutral-600 bg-transparent text-neutral-200`};
|
${tw`border-neutral-600 bg-transparent text-neutral-200`};
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) {
|
||||||
${tw`border-neutral-500 text-neutral-100`};
|
${tw`border-neutral-500 text-neutral-100`};
|
||||||
${(props) => props.color === 'red' && tw`bg-red-500 border-red-600 text-red-50`};
|
${props => props.color === 'red' && tw`bg-red-500 border-red-600 text-red-50`};
|
||||||
${(props) => props.color === 'primary' && tw`bg-primary-500 border-primary-600 text-primary-50`};
|
${props => props.color === 'primary' && tw`bg-primary-500 border-primary-600 text-primary-50`};
|
||||||
${(props) => props.color === 'green' && tw`bg-green-500 border-green-600 text-green-50`};
|
${props => props.color === 'green' && tw`bg-green-500 border-green-600 text-green-50`};
|
||||||
}
|
}
|
||||||
`};
|
`};
|
||||||
|
|
||||||
|
@ -108,7 +108,7 @@ const Button: React.FC<ComponentProps> = ({ children, isLoading, ...props }) =>
|
||||||
|
|
||||||
type LinkProps = Omit<JSX.IntrinsicElements['a'], 'ref' | keyof Props> & Props;
|
type LinkProps = Omit<JSX.IntrinsicElements['a'], 'ref' | keyof Props> & Props;
|
||||||
|
|
||||||
const LinkButton: React.FC<LinkProps> = (props) => <ButtonStyle as={'a'} {...props} />;
|
const LinkButton: React.FC<LinkProps> = props => <ButtonStyle as={'a'} {...props} />;
|
||||||
|
|
||||||
export { LinkButton, ButtonStyle };
|
export { LinkButton, ButtonStyle };
|
||||||
export default Button;
|
export default Button;
|
||||||
|
|
|
@ -1,24 +1,25 @@
|
||||||
import React, { memo } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { usePermissions } from '@/plugins/usePermissions';
|
import { memo } from 'react';
|
||||||
import isEqual from 'react-fast-compare';
|
import isEqual from 'react-fast-compare';
|
||||||
|
import { usePermissions } from '@/plugins/usePermissions';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
action: string | string[];
|
action: string | string[];
|
||||||
matchAny?: boolean;
|
matchAny?: boolean;
|
||||||
renderOnError?: React.ReactNode | null;
|
renderOnError?: ReactNode | null;
|
||||||
children: React.ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Can = ({ action, matchAny = false, renderOnError, children }: Props) => {
|
function Can({ action, matchAny = false, renderOnError, children }: Props) {
|
||||||
const can = usePermissions(action);
|
const can = usePermissions(action);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{(matchAny && can.filter((p) => p).length > 0) || (!matchAny && can.every((p) => p))
|
{(matchAny && can.filter(p => p).length > 0) || (!matchAny && can.every(p => p)) ? children : renderOnError}
|
||||||
? children
|
|
||||||
: renderOnError}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default memo(Can, isEqual);
|
const MemoizedCan = memo(Can, isEqual);
|
||||||
|
|
||||||
|
export default MemoizedCan;
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import React from 'react';
|
|
||||||
import { Field, FieldProps } from 'formik';
|
import { Field, FieldProps } from 'formik';
|
||||||
import Input from '@/components/elements/Input';
|
import Input from '@/components/elements/Input';
|
||||||
|
|
||||||
|
@ -29,7 +28,7 @@ const Checkbox = ({ name, value, className, ...props }: Props & InputProps) => (
|
||||||
type={'checkbox'}
|
type={'checkbox'}
|
||||||
checked={(field.value || []).includes(value)}
|
checked={(field.value || []).includes(value)}
|
||||||
onClick={() => form.setFieldTouched(field.name, true)}
|
onClick={() => form.setFieldTouched(field.name, true)}
|
||||||
onChange={(e) => {
|
onChange={e => {
|
||||||
const set = new Set(field.value);
|
const set = new Set(field.value);
|
||||||
set.has(value) ? set.delete(value) : set.add(value);
|
set.has(value) ? set.delete(value) : set.add(value);
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React from 'react';
|
import * as React from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
interface CodeProps {
|
interface CodeProps {
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
import React, { useCallback, useEffect, useState } from 'react';
|
|
||||||
import CodeMirror from 'codemirror';
|
import CodeMirror from 'codemirror';
|
||||||
import styled from 'styled-components/macro';
|
import type { CSSProperties } from 'react';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
|
|
||||||
import modes from '@/modes';
|
import modes from '@/modes';
|
||||||
|
|
||||||
require('codemirror/lib/codemirror.css');
|
require('codemirror/lib/codemirror.css');
|
||||||
|
@ -106,7 +108,7 @@ const EditorContainer = styled.div`
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
style?: React.CSSProperties;
|
style?: CSSProperties;
|
||||||
initialContent?: string;
|
initialContent?: string;
|
||||||
mode: string;
|
mode: string;
|
||||||
filename?: string;
|
filename?: string;
|
||||||
|
@ -119,7 +121,7 @@ const findModeByFilename = (filename: string) => {
|
||||||
for (let i = 0; i < modes.length; i++) {
|
for (let i = 0; i < modes.length; i++) {
|
||||||
const info = modes[i];
|
const info = modes[i];
|
||||||
|
|
||||||
if (info.file && info.file.test(filename)) {
|
if (info?.file !== undefined && info.file.test(filename)) {
|
||||||
return info;
|
return info;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -130,7 +132,7 @@ const findModeByFilename = (filename: string) => {
|
||||||
if (ext) {
|
if (ext) {
|
||||||
for (let i = 0; i < modes.length; i++) {
|
for (let i = 0; i < modes.length; i++) {
|
||||||
const info = modes[i];
|
const info = modes[i];
|
||||||
if (info.ext) {
|
if (info?.ext !== undefined) {
|
||||||
for (let j = 0; j < info.ext.length; j++) {
|
for (let j = 0; j < info.ext.length; j++) {
|
||||||
if (info.ext[j] === ext) {
|
if (info.ext[j] === ext) {
|
||||||
return info;
|
return info;
|
||||||
|
@ -146,10 +148,12 @@ const findModeByFilename = (filename: string) => {
|
||||||
export default ({ style, initialContent, filename, mode, fetchContent, onContentSaved, onModeChanged }: Props) => {
|
export default ({ style, initialContent, filename, mode, fetchContent, onContentSaved, onModeChanged }: Props) => {
|
||||||
const [editor, setEditor] = useState<CodeMirror.Editor>();
|
const [editor, setEditor] = useState<CodeMirror.Editor>();
|
||||||
|
|
||||||
const ref = useCallback((node) => {
|
const ref = useCallback<(_?: unknown) => void>(node => {
|
||||||
if (!node) return;
|
if (node === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const e = CodeMirror.fromTextArea(node, {
|
const e = CodeMirror.fromTextArea(node as HTMLTextAreaElement, {
|
||||||
mode: 'text/plain',
|
mode: 'text/plain',
|
||||||
theme: 'ayu-mirage',
|
theme: 'ayu-mirage',
|
||||||
indentUnit: 4,
|
indentUnit: 4,
|
||||||
|
@ -158,7 +162,6 @@ export default ({ style, initialContent, filename, mode, fetchContent, onContent
|
||||||
indentWithTabs: false,
|
indentWithTabs: false,
|
||||||
lineWrapping: true,
|
lineWrapping: true,
|
||||||
lineNumbers: true,
|
lineNumbers: true,
|
||||||
foldGutter: true,
|
|
||||||
fixedGutter: true,
|
fixedGutter: true,
|
||||||
scrollbarStyle: 'overlay',
|
scrollbarStyle: 'overlay',
|
||||||
coverGutterNextToScrollbar: false,
|
coverGutterNextToScrollbar: false,
|
||||||
|
|
|
@ -1,23 +1,28 @@
|
||||||
import React, { useContext } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
import { useContext } from 'react';
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
import Button from '@/components/elements/Button';
|
|
||||||
import asModal from '@/hoc/asModal';
|
|
||||||
import ModalContext from '@/context/ModalContext';
|
|
||||||
|
|
||||||
type Props = {
|
import Button from '@/components/elements/Button';
|
||||||
|
import ModalContext from '@/context/ModalContext';
|
||||||
|
import asModal from '@/hoc/asModal';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
|
||||||
title: string;
|
title: string;
|
||||||
buttonText: string;
|
buttonText: string;
|
||||||
onConfirmed: () => void;
|
onConfirmed: () => void;
|
||||||
showSpinnerOverlay?: boolean;
|
showSpinnerOverlay?: boolean;
|
||||||
};
|
}
|
||||||
|
|
||||||
const ConfirmationModal: React.FC<Props> = ({ title, children, buttonText, onConfirmed }) => {
|
function ConfirmationModal({ title, children, buttonText, onConfirmed }: Props) {
|
||||||
const { dismiss } = useContext(ModalContext);
|
const { dismiss } = useContext(ModalContext);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h2 css={tw`text-2xl mb-6`}>{title}</h2>
|
<h2 css={tw`text-2xl mb-6`}>{title}</h2>
|
||||||
<div css={tw`text-neutral-300`}>{children}</div>
|
<div css={tw`text-neutral-300`}>{children}</div>
|
||||||
|
|
||||||
<div css={tw`flex flex-wrap items-center justify-end mt-8`}>
|
<div css={tw`flex flex-wrap items-center justify-end mt-8`}>
|
||||||
<Button isSecondary onClick={() => dismiss()} css={tw`w-full sm:w-auto border-transparent`}>
|
<Button isSecondary onClick={() => dismiss()} css={tw`w-full sm:w-auto border-transparent`}>
|
||||||
Cancel
|
Cancel
|
||||||
|
@ -28,10 +33,8 @@ const ConfirmationModal: React.FC<Props> = ({ title, children, buttonText, onCon
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
ConfirmationModal.displayName = 'ConfirmationModal';
|
export default asModal<Props>(props => ({
|
||||||
|
|
||||||
export default asModal<Props>((props) => ({
|
|
||||||
showSpinnerOverlay: props.showSpinnerOverlay,
|
showSpinnerOverlay: props.showSpinnerOverlay,
|
||||||
}))(ConfirmationModal);
|
}))(ConfirmationModal);
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React from 'react';
|
import * as React from 'react';
|
||||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import styled from 'styled-components/macro';
|
import styled from 'styled-components';
|
||||||
import { breakpoint } from '@/theme';
|
import { breakpoint } from '@/theme';
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
|
|
||||||
|
|
|
@ -1,13 +1,15 @@
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import Fade from '@/components/elements/Fade';
|
|
||||||
import Portal from '@/components/elements/Portal';
|
|
||||||
import copy from 'copy-to-clipboard';
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
import copy from 'copy-to-clipboard';
|
||||||
|
import type { MouseEvent, ReactNode } from 'react';
|
||||||
|
import { Children, cloneElement, isValidElement, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import Portal from '@/components/elements/Portal';
|
||||||
|
import FadeTransition from '@/components/elements/transitions/FadeTransition';
|
||||||
|
|
||||||
interface CopyOnClickProps {
|
interface CopyOnClickProps {
|
||||||
text: string | number | null | undefined;
|
text: string | number | null | undefined;
|
||||||
showInNotification?: boolean;
|
showInNotification?: boolean;
|
||||||
children: React.ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CopyOnClick = ({ text, showInNotification = true, children }: CopyOnClickProps) => {
|
const CopyOnClick = ({ text, showInNotification = true, children }: CopyOnClickProps) => {
|
||||||
|
@ -25,15 +27,16 @@ const CopyOnClick = ({ text, showInNotification = true, children }: CopyOnClickP
|
||||||
};
|
};
|
||||||
}, [copied]);
|
}, [copied]);
|
||||||
|
|
||||||
if (!React.isValidElement(children)) {
|
if (!isValidElement(children)) {
|
||||||
throw new Error('Component passed to <CopyOnClick/> must be a valid React element.');
|
throw new Error('Component passed to <CopyOnClick/> must be a valid React element.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const child = !text
|
const child = !text
|
||||||
? React.Children.only(children)
|
? Children.only(children)
|
||||||
: React.cloneElement(React.Children.only(children), {
|
: cloneElement(Children.only(children), {
|
||||||
|
// @ts-expect-error I don't know
|
||||||
className: classNames(children.props.className || '', 'cursor-pointer'),
|
className: classNames(children.props.className || '', 'cursor-pointer'),
|
||||||
onClick: (e: React.MouseEvent<HTMLElement>) => {
|
onClick: (e: MouseEvent<HTMLElement>) => {
|
||||||
copy(String(text));
|
copy(String(text));
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
if (typeof children.props.onClick === 'function') {
|
if (typeof children.props.onClick === 'function') {
|
||||||
|
@ -46,9 +49,9 @@ const CopyOnClick = ({ text, showInNotification = true, children }: CopyOnClickP
|
||||||
<>
|
<>
|
||||||
{copied && (
|
{copied && (
|
||||||
<Portal>
|
<Portal>
|
||||||
<Fade in appear timeout={250} key={copied ? 'visible' : 'invisible'}>
|
<FadeTransition show duration="duration-250" key={copied ? 'visible' : 'invisible'}>
|
||||||
<div className={'fixed z-50 bottom-0 right-0 m-4'}>
|
<div className="fixed z-50 bottom-0 right-0 m-4">
|
||||||
<div className={'rounded-md py-3 px-4 text-gray-200 bg-neutral-600/95 shadow'}>
|
<div className="rounded-md py-3 px-4 text-gray-200 bg-neutral-600/95 shadow">
|
||||||
<p>
|
<p>
|
||||||
{showInNotification
|
{showInNotification
|
||||||
? `Copied "${String(text)}" to clipboard.`
|
? `Copied "${String(text)}" to clipboard.`
|
||||||
|
@ -56,7 +59,7 @@ const CopyOnClick = ({ text, showInNotification = true, children }: CopyOnClickP
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Fade>
|
</FadeTransition>
|
||||||
</Portal>
|
</Portal>
|
||||||
)}
|
)}
|
||||||
{child}
|
{child}
|
||||||
|
|
|
@ -1,11 +1,13 @@
|
||||||
import React, { createRef } from 'react';
|
import type { MouseEvent as ReactMouseEvent, ReactNode } from 'react';
|
||||||
import styled from 'styled-components/macro';
|
import { createRef, PureComponent } from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
import Fade from '@/components/elements/Fade';
|
|
||||||
|
import FadeTransition from '@/components/elements/transitions/FadeTransition';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: React.ReactNode;
|
children: ReactNode;
|
||||||
renderToggle: (onClick: (e: React.MouseEvent<any, MouseEvent>) => void) => React.ReactChild;
|
renderToggle: (onClick: (e: ReactMouseEvent<unknown>) => void) => any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DropdownButtonRow = styled.button<{ danger?: boolean }>`
|
export const DropdownButtonRow = styled.button<{ danger?: boolean }>`
|
||||||
|
@ -13,7 +15,7 @@ export const DropdownButtonRow = styled.button<{ danger?: boolean }>`
|
||||||
transition: 150ms all ease;
|
transition: 150ms all ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
${(props) => (props.danger ? tw`text-red-700 bg-red-100` : tw`text-neutral-700 bg-neutral-100`)};
|
${props => (props.danger ? tw`text-red-700 bg-red-100` : tw`text-neutral-700 bg-neutral-100`)};
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
@ -22,19 +24,19 @@ interface State {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
class DropdownMenu extends React.PureComponent<Props, State> {
|
class DropdownMenu extends PureComponent<Props, State> {
|
||||||
menu = createRef<HTMLDivElement>();
|
menu = createRef<HTMLDivElement>();
|
||||||
|
|
||||||
state: State = {
|
override state: State = {
|
||||||
posX: 0,
|
posX: 0,
|
||||||
visible: false,
|
visible: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
componentWillUnmount() {
|
override componentWillUnmount() {
|
||||||
this.removeListeners();
|
this.removeListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps: Readonly<Props>, prevState: Readonly<State>) {
|
override componentDidUpdate(_prevProps: Readonly<Props>, prevState: Readonly<State>) {
|
||||||
const menu = this.menu.current;
|
const menu = this.menu.current;
|
||||||
|
|
||||||
if (this.state.visible && !prevState.visible && menu) {
|
if (this.state.visible && !prevState.visible && menu) {
|
||||||
|
@ -48,19 +50,21 @@ class DropdownMenu extends React.PureComponent<Props, State> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
removeListeners = () => {
|
removeListeners() {
|
||||||
document.removeEventListener('click', this.windowListener);
|
document.removeEventListener('click', this.windowListener);
|
||||||
document.removeEventListener('contextmenu', this.contextMenuListener);
|
document.removeEventListener('contextmenu', this.contextMenuListener);
|
||||||
};
|
}
|
||||||
|
|
||||||
onClickHandler = (e: React.MouseEvent<any, MouseEvent>) => {
|
onClickHandler(e: ReactMouseEvent<unknown>) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.triggerMenu(e.clientX);
|
this.triggerMenu(e.clientX);
|
||||||
};
|
}
|
||||||
|
|
||||||
contextMenuListener = () => this.setState({ visible: false });
|
contextMenuListener() {
|
||||||
|
this.setState({ visible: false });
|
||||||
|
}
|
||||||
|
|
||||||
windowListener = (e: MouseEvent) => {
|
windowListener(e: MouseEvent): any {
|
||||||
const menu = this.menu.current;
|
const menu = this.menu.current;
|
||||||
|
|
||||||
if (e.button === 2 || !this.state.visible || !menu) {
|
if (e.button === 2 || !this.state.visible || !menu) {
|
||||||
|
@ -74,22 +78,24 @@ class DropdownMenu extends React.PureComponent<Props, State> {
|
||||||
if (e.target !== menu && !menu.contains(e.target as Node)) {
|
if (e.target !== menu && !menu.contains(e.target as Node)) {
|
||||||
this.setState({ visible: false });
|
this.setState({ visible: false });
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
triggerMenu = (posX: number) =>
|
triggerMenu(posX: number) {
|
||||||
this.setState((s) => ({
|
this.setState(s => ({
|
||||||
posX: !s.visible ? posX : s.posX,
|
posX: !s.visible ? posX : s.posX,
|
||||||
visible: !s.visible,
|
visible: !s.visible,
|
||||||
}));
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
override render() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{this.props.renderToggle(this.onClickHandler)}
|
{this.props.renderToggle(this.onClickHandler)}
|
||||||
<Fade timeout={150} in={this.state.visible} unmountOnExit>
|
|
||||||
|
<FadeTransition duration="duration-150" show={this.state.visible} appear unmount>
|
||||||
<div
|
<div
|
||||||
ref={this.menu}
|
ref={this.menu}
|
||||||
onClick={(e) => {
|
onClick={e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.setState({ visible: false });
|
this.setState({ visible: false });
|
||||||
}}
|
}}
|
||||||
|
@ -98,7 +104,7 @@ class DropdownMenu extends React.PureComponent<Props, State> {
|
||||||
>
|
>
|
||||||
{this.props.children}
|
{this.props.children}
|
||||||
</div>
|
</div>
|
||||||
</Fade>
|
</FadeTransition>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,15 +1,20 @@
|
||||||
import React from 'react';
|
|
||||||
import tw from 'twin.macro';
|
|
||||||
import Icon from '@/components/elements/Icon';
|
|
||||||
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
|
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Component } from 'react';
|
||||||
|
import tw from 'twin.macro';
|
||||||
|
|
||||||
|
import Icon from '@/components/elements/Icon';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
hasError: boolean;
|
hasError: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
class ErrorBoundary extends Component<Props, State> {
|
||||||
class ErrorBoundary extends React.Component<{}, State> {
|
override state: State = {
|
||||||
state: State = {
|
|
||||||
hasError: false,
|
hasError: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -17,15 +22,16 @@ class ErrorBoundary extends React.Component<{}, State> {
|
||||||
return { hasError: true };
|
return { hasError: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidCatch(error: Error) {
|
override componentDidCatch(error: Error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
override render() {
|
||||||
return this.state.hasError ? (
|
return this.state.hasError ? (
|
||||||
<div css={tw`flex items-center justify-center w-full my-4`}>
|
<div css={tw`flex items-center justify-center w-full my-4`}>
|
||||||
<div css={tw`flex items-center bg-neutral-900 rounded p-3 text-red-500`}>
|
<div css={tw`flex items-center bg-neutral-900 rounded p-3 text-red-500`}>
|
||||||
<Icon icon={faExclamationTriangle} css={tw`h-4 w-auto mr-2`} />
|
<Icon icon={faExclamationTriangle} css={tw`h-4 w-auto mr-2`} />
|
||||||
|
|
||||||
<p css={tw`text-sm text-neutral-100`}>
|
<p css={tw`text-sm text-neutral-100`}>
|
||||||
An error was encountered by the application while rendering this view. Try refreshing the page.
|
An error was encountered by the application while rendering this view. Try refreshing the page.
|
||||||
</p>
|
</p>
|
||||||
|
|
|
@ -1,47 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import tw from 'twin.macro';
|
|
||||||
import styled from 'styled-components/macro';
|
|
||||||
import CSSTransition, { CSSTransitionProps } from 'react-transition-group/CSSTransition';
|
|
||||||
|
|
||||||
interface Props extends Omit<CSSTransitionProps, 'timeout' | 'classNames'> {
|
|
||||||
timeout: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Container = styled.div<{ timeout: number }>`
|
|
||||||
.fade-enter,
|
|
||||||
.fade-exit,
|
|
||||||
.fade-appear {
|
|
||||||
will-change: opacity;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fade-enter,
|
|
||||||
.fade-appear {
|
|
||||||
${tw`opacity-0`};
|
|
||||||
|
|
||||||
&.fade-enter-active,
|
|
||||||
&.fade-appear-active {
|
|
||||||
${tw`opacity-100 transition-opacity ease-in`};
|
|
||||||
transition-duration: ${(props) => props.timeout}ms;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.fade-exit {
|
|
||||||
${tw`opacity-100`};
|
|
||||||
|
|
||||||
&.fade-exit-active {
|
|
||||||
${tw`opacity-0 transition-opacity ease-in`};
|
|
||||||
transition-duration: ${(props) => props.timeout}ms;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Fade: React.FC<Props> = ({ timeout, children, ...props }) => (
|
|
||||||
<Container timeout={timeout}>
|
|
||||||
<CSSTransition timeout={timeout} classNames={'fade'} {...props}>
|
|
||||||
{children}
|
|
||||||
</CSSTransition>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
Fade.displayName = 'Fade';
|
|
||||||
|
|
||||||
export default Fade;
|
|
|
@ -1,4 +1,5 @@
|
||||||
import React, { forwardRef } from 'react';
|
import { forwardRef } from 'react';
|
||||||
|
import * as React from 'react';
|
||||||
import { Field as FormikField, FieldProps } from 'formik';
|
import { Field as FormikField, FieldProps } from 'formik';
|
||||||
import Input from '@/components/elements/Input';
|
import Input from '@/components/elements/Input';
|
||||||
import Label from '@/components/elements/Label';
|
import Label from '@/components/elements/Label';
|
||||||
|
@ -41,7 +42,7 @@ const Field = forwardRef<HTMLInputElement, Props>(
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</FormikField>
|
</FormikField>
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
Field.displayName = 'Field';
|
Field.displayName = 'Field';
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React from 'react';
|
import * as React from 'react';
|
||||||
import { Field, FieldProps } from 'formik';
|
import { Field, FieldProps } from 'formik';
|
||||||
import InputError from '@/components/elements/InputError';
|
import InputError from '@/components/elements/InputError';
|
||||||
import Label from '@/components/elements/Label';
|
import Label from '@/components/elements/Label';
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import React from 'react';
|
|
||||||
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
|
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
|
||||||
import { Field, FieldProps } from 'formik';
|
import { Field, FieldProps } from 'formik';
|
||||||
import Switch, { SwitchProps } from '@/components/elements/Switch';
|
import Switch, { SwitchProps } from '@/components/elements/Switch';
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import styled from 'styled-components/macro';
|
import styled from 'styled-components';
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
|
|
||||||
export default styled.div<{ $hoverable?: boolean }>`
|
export default styled.div<{ $hoverable?: boolean }>`
|
||||||
${tw`flex rounded no-underline text-neutral-200 items-center bg-neutral-700 p-4 border border-transparent transition-colors duration-150 overflow-hidden`};
|
${tw`flex rounded no-underline text-neutral-200 items-center bg-neutral-700 p-4 border border-transparent transition-colors duration-150 overflow-hidden`};
|
||||||
|
|
||||||
${(props) => props.$hoverable !== false && tw`hover:border-neutral-500`};
|
${props => props.$hoverable !== false && tw`hover:border-neutral-500`};
|
||||||
|
|
||||||
& .icon {
|
& .icon {
|
||||||
${tw`rounded-full w-16 flex items-center justify-center bg-neutral-500 p-3`};
|
${tw`rounded-full w-16 flex items-center justify-center bg-neutral-500 p-3`};
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { CSSProperties } from 'react';
|
import { CSSProperties } from 'react';
|
||||||
import { IconDefinition } from '@fortawesome/fontawesome-svg-core';
|
import { IconDefinition } from '@fortawesome/fontawesome-svg-core';
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import styled, { css } from 'styled-components/macro';
|
import styled, { css } from 'styled-components';
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
|
@ -45,7 +45,7 @@ const inputStyle = css<Props>`
|
||||||
|
|
||||||
& + .input-help {
|
& + .input-help {
|
||||||
${tw`mt-1 text-xs`};
|
${tw`mt-1 text-xs`};
|
||||||
${(props) => (props.hasError ? tw`text-red-200` : tw`text-neutral-200`)};
|
${props => (props.hasError ? tw`text-red-200` : tw`text-neutral-200`)};
|
||||||
}
|
}
|
||||||
|
|
||||||
&:required,
|
&:required,
|
||||||
|
@ -55,15 +55,15 @@ const inputStyle = css<Props>`
|
||||||
|
|
||||||
&:not(:disabled):not(:read-only):focus {
|
&:not(:disabled):not(:read-only):focus {
|
||||||
${tw`shadow-md border-primary-300 ring-2 ring-primary-400 ring-opacity-50`};
|
${tw`shadow-md border-primary-300 ring-2 ring-primary-400 ring-opacity-50`};
|
||||||
${(props) => props.hasError && tw`border-red-300 ring-red-200`};
|
${props => props.hasError && tw`border-red-300 ring-red-200`};
|
||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
${tw`opacity-75`};
|
${tw`opacity-75`};
|
||||||
}
|
}
|
||||||
|
|
||||||
${(props) => props.isLight && light};
|
${props => props.isLight && light};
|
||||||
${(props) => props.hasError && tw`text-red-100 border-red-400 hover:border-red-300`};
|
${props => props.hasError && tw`text-red-100 border-red-400 hover:border-red-300`};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Input = styled.input<Props>`
|
const Input = styled.input<Props>`
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import React from 'react';
|
|
||||||
import { FormikErrors, FormikTouched } from 'formik';
|
import { FormikErrors, FormikTouched } from 'formik';
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
import { capitalize } from '@/lib/strings';
|
import { capitalize } from '@/lib/strings';
|
||||||
|
@ -15,7 +14,7 @@ const InputError = ({ errors, touched, name, children }: Props) =>
|
||||||
<p css={tw`text-xs text-red-400 pt-2`}>
|
<p css={tw`text-xs text-red-400 pt-2`}>
|
||||||
{typeof errors[name] === 'string'
|
{typeof errors[name] === 'string'
|
||||||
? capitalize(errors[name] as string)
|
? capitalize(errors[name] as string)
|
||||||
: capitalize((errors[name] as unknown as string[])[0])}
|
: capitalize((errors[name] as unknown as string[])[0] ?? '')}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<>{children ? <p css={tw`text-xs text-neutral-400 pt-2`}>{children}</p> : null}</>
|
<>{children ? <p css={tw`text-xs text-neutral-400 pt-2`}>{children}</p> : null}</>
|
||||||
|
|
|
@ -1,14 +1,15 @@
|
||||||
import React from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import Spinner from '@/components/elements/Spinner';
|
import styled, { css } from 'styled-components';
|
||||||
import Fade from '@/components/elements/Fade';
|
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
import styled, { css } from 'styled-components/macro';
|
|
||||||
import Select from '@/components/elements/Select';
|
import Select from '@/components/elements/Select';
|
||||||
|
import Spinner from '@/components/elements/Spinner';
|
||||||
|
import FadeTransition from '@/components/elements/transitions/FadeTransition';
|
||||||
|
|
||||||
const Container = styled.div<{ visible?: boolean }>`
|
const Container = styled.div<{ visible?: boolean }>`
|
||||||
${tw`relative`};
|
${tw`relative`};
|
||||||
|
|
||||||
${(props) =>
|
${props =>
|
||||||
props.visible &&
|
props.visible &&
|
||||||
css`
|
css`
|
||||||
& ${Select} {
|
& ${Select} {
|
||||||
|
@ -17,15 +18,18 @@ const Container = styled.div<{ visible?: boolean }>`
|
||||||
`};
|
`};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const InputSpinner = ({ visible, children }: { visible: boolean; children: React.ReactNode }) => (
|
function InputSpinner({ visible, children }: { visible: boolean; children: ReactNode }) {
|
||||||
|
return (
|
||||||
<Container visible={visible}>
|
<Container visible={visible}>
|
||||||
<Fade appear unmountOnExit in={visible} timeout={150}>
|
<FadeTransition show={visible} duration="duration-150" appear unmount>
|
||||||
<div css={tw`absolute right-0 h-full flex items-center justify-end pr-3`}>
|
<div css={tw`absolute right-0 h-full flex items-center justify-end pr-3`}>
|
||||||
<Spinner size={'small'} />
|
<Spinner size="small" />
|
||||||
</div>
|
</div>
|
||||||
</Fade>
|
</FadeTransition>
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default InputSpinner;
|
export default InputSpinner;
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import styled from 'styled-components/macro';
|
import styled from 'styled-components';
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
|
|
||||||
const Label = styled.label<{ isLight?: boolean }>`
|
const Label = styled.label<{ isLight?: boolean }>`
|
||||||
${tw`block text-xs uppercase text-neutral-200 mb-1 sm:mb-2`};
|
${tw`block text-xs uppercase text-neutral-200 mb-1 sm:mb-2`};
|
||||||
${(props) => props.isLight && tw`text-neutral-700`};
|
${props => props.isLight && tw`text-neutral-700`};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default Label;
|
export default Label;
|
||||||
|
|
|
@ -1,12 +1,16 @@
|
||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import Spinner from '@/components/elements/Spinner';
|
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import tw from 'twin.macro';
|
|
||||||
import styled, { css } from 'styled-components/macro';
|
|
||||||
import { breakpoint } from '@/theme';
|
|
||||||
import Fade from '@/components/elements/Fade';
|
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
import styled, { css } from 'styled-components';
|
||||||
|
import tw from 'twin.macro';
|
||||||
|
|
||||||
|
import Spinner from '@/components/elements/Spinner';
|
||||||
|
import { breakpoint } from '@/theme';
|
||||||
|
import FadeTransition from '@/components/elements/transitions/FadeTransition';
|
||||||
|
|
||||||
export interface RequiredModalProps {
|
export interface RequiredModalProps {
|
||||||
|
children?: ReactNode;
|
||||||
|
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
onDismissed: () => void;
|
onDismissed: () => void;
|
||||||
appear?: boolean;
|
appear?: boolean;
|
||||||
|
@ -32,7 +36,7 @@ const ModalContainer = styled.div<{ alignTop?: boolean }>`
|
||||||
${breakpoint('lg')`max-width: 50%`};
|
${breakpoint('lg')`max-width: 50%`};
|
||||||
|
|
||||||
${tw`relative flex flex-col w-full m-auto`};
|
${tw`relative flex flex-col w-full m-auto`};
|
||||||
${(props) =>
|
${props =>
|
||||||
props.alignTop &&
|
props.alignTop &&
|
||||||
css`
|
css`
|
||||||
margin-top: 20%;
|
margin-top: 20%;
|
||||||
|
@ -55,7 +59,7 @@ const ModalContainer = styled.div<{ alignTop?: boolean }>`
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Modal: React.FC<ModalProps> = ({
|
function Modal({
|
||||||
visible,
|
visible,
|
||||||
appear,
|
appear,
|
||||||
dismissable,
|
dismissable,
|
||||||
|
@ -65,7 +69,7 @@ const Modal: React.FC<ModalProps> = ({
|
||||||
closeOnEscape = true,
|
closeOnEscape = true,
|
||||||
onDismissed,
|
onDismissed,
|
||||||
children,
|
children,
|
||||||
}) => {
|
}: ModalProps) {
|
||||||
const [render, setRender] = useState(visible);
|
const [render, setRender] = useState(visible);
|
||||||
|
|
||||||
const isDismissable = useMemo(() => {
|
const isDismissable = useMemo(() => {
|
||||||
|
@ -85,14 +89,20 @@ const Modal: React.FC<ModalProps> = ({
|
||||||
};
|
};
|
||||||
}, [isDismissable, closeOnEscape, render]);
|
}, [isDismissable, closeOnEscape, render]);
|
||||||
|
|
||||||
useEffect(() => setRender(visible), [visible]);
|
useEffect(() => {
|
||||||
|
setRender(visible);
|
||||||
|
|
||||||
|
if (!visible) {
|
||||||
|
onDismissed();
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fade in={render} timeout={150} appear={appear || true} unmountOnExit onExited={() => onDismissed()}>
|
<FadeTransition as={Fragment} show={render} duration="duration-150" appear={appear ?? true} unmount>
|
||||||
<ModalMask
|
<ModalMask
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
onContextMenu={(e) => e.stopPropagation()}
|
onContextMenu={e => e.stopPropagation()}
|
||||||
onMouseDown={(e) => {
|
onMouseDown={e => {
|
||||||
if (isDismissable && closeOnBackground) {
|
if (isDismissable && closeOnBackground) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (e.target === e.currentTarget) {
|
if (e.target === e.currentTarget) {
|
||||||
|
@ -119,16 +129,16 @@ const Modal: React.FC<ModalProps> = ({
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showSpinnerOverlay && (
|
|
||||||
<Fade timeout={150} appear in>
|
<FadeTransition duration="duration-150" show={showSpinnerOverlay ?? false} appear>
|
||||||
<div
|
<div
|
||||||
css={tw`absolute w-full h-full rounded flex items-center justify-center`}
|
css={tw`absolute w-full h-full rounded flex items-center justify-center`}
|
||||||
style={{ background: 'hsla(211, 10%, 53%, 0.35)', zIndex: 9999 }}
|
style={{ background: 'hsla(211, 10%, 53%, 0.35)', zIndex: 9999 }}
|
||||||
>
|
>
|
||||||
<Spinner />
|
<Spinner />
|
||||||
</div>
|
</div>
|
||||||
</Fade>
|
</FadeTransition>
|
||||||
)}
|
|
||||||
<div
|
<div
|
||||||
css={tw`bg-neutral-800 p-3 sm:p-4 md:p-6 rounded shadow-md overflow-y-scroll transition-all duration-150`}
|
css={tw`bg-neutral-800 p-3 sm:p-4 md:p-6 rounded shadow-md overflow-y-scroll transition-all duration-150`}
|
||||||
>
|
>
|
||||||
|
@ -136,14 +146,14 @@ const Modal: React.FC<ModalProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</ModalContainer>
|
</ModalContainer>
|
||||||
</ModalMask>
|
</ModalMask>
|
||||||
</Fade>
|
</FadeTransition>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
const PortaledModal: React.FC<ModalProps> = ({ children, ...props }) => {
|
function PortaledModal({ children, ...props }: ModalProps): JSX.Element {
|
||||||
const element = useRef(document.getElementById('modal-portal'));
|
const element = useRef(document.getElementById('modal-portal'));
|
||||||
|
|
||||||
return createPortal(<Modal {...props}>{children}</Modal>, element.current!);
|
return createPortal(<Modal {...props}>{children}</Modal>, element.current!);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default PortaledModal;
|
export default PortaledModal;
|
||||||
|
|
|
@ -1,16 +1,19 @@
|
||||||
import React, { useEffect } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import ContentContainer from '@/components/elements/ContentContainer';
|
import { useEffect } from 'react';
|
||||||
import { CSSTransition } from 'react-transition-group';
|
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
|
|
||||||
|
import ContentContainer from '@/components/elements/ContentContainer';
|
||||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||||
|
|
||||||
export interface PageContentBlockProps {
|
export interface PageContentBlockProps {
|
||||||
|
children?: ReactNode;
|
||||||
|
|
||||||
title?: string;
|
title?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
showFlashKey?: string;
|
showFlashKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PageContentBlock: React.FC<PageContentBlockProps> = ({ title, showFlashKey, className, children }) => {
|
function PageContentBlock({ title, showFlashKey, className, children }: PageContentBlockProps) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (title) {
|
if (title) {
|
||||||
document.title = title;
|
document.title = title;
|
||||||
|
@ -18,12 +21,12 @@ const PageContentBlock: React.FC<PageContentBlockProps> = ({ title, showFlashKey
|
||||||
}, [title]);
|
}, [title]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CSSTransition timeout={150} classNames={'fade'} appear in>
|
|
||||||
<>
|
<>
|
||||||
<ContentContainer css={tw`my-4 sm:my-10`} className={className}>
|
<ContentContainer css={tw`my-4 sm:my-10`} className={className}>
|
||||||
{showFlashKey && <FlashMessageRender byKey={showFlashKey} css={tw`mb-4`} />}
|
{showFlashKey && <FlashMessageRender byKey={showFlashKey} css={tw`mb-4`} />}
|
||||||
{children}
|
{children}
|
||||||
</ContentContainer>
|
</ContentContainer>
|
||||||
|
|
||||||
<ContentContainer css={tw`mb-4`}>
|
<ContentContainer css={tw`mb-4`}>
|
||||||
<p css={tw`text-center text-neutral-500 text-xs`}>
|
<p css={tw`text-center text-neutral-500 text-xs`}>
|
||||||
<a
|
<a
|
||||||
|
@ -38,8 +41,7 @@ const PageContentBlock: React.FC<PageContentBlockProps> = ({ title, showFlashKey
|
||||||
</p>
|
</p>
|
||||||
</ContentContainer>
|
</ContentContainer>
|
||||||
</>
|
</>
|
||||||
</CSSTransition>
|
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default PageContentBlock;
|
export default PageContentBlock;
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import React from 'react';
|
import * as React from 'react';
|
||||||
import { PaginatedResult } from '@/api/http';
|
import { PaginatedResult } from '@/api/http';
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
import styled from 'styled-components/macro';
|
import styled from 'styled-components';
|
||||||
import Button from '@/components/elements/Button';
|
import Button from '@/components/elements/Button';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faAngleDoubleLeft, faAngleDoubleRight } from '@fortawesome/free-solid-svg-icons';
|
import { faAngleDoubleLeft, faAngleDoubleRight } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
@ -48,12 +48,12 @@ function Pagination<T>({ data: { items, pagination }, onPageSelect, children }:
|
||||||
{children({ items, isFirstPage, isLastPage })}
|
{children({ items, isFirstPage, isLastPage })}
|
||||||
{pages.length > 1 && (
|
{pages.length > 1 && (
|
||||||
<div css={tw`mt-4 flex justify-center`}>
|
<div css={tw`mt-4 flex justify-center`}>
|
||||||
{pages[0] > 1 && !isFirstPage && (
|
{(pages?.[0] ?? 0) > 1 && !isFirstPage && (
|
||||||
<Block isSecondary color={'primary'} onClick={() => onPageSelect(1)}>
|
<Block isSecondary color={'primary'} onClick={() => onPageSelect(1)}>
|
||||||
<FontAwesomeIcon icon={faAngleDoubleLeft} />
|
<FontAwesomeIcon icon={faAngleDoubleLeft} />
|
||||||
</Block>
|
</Block>
|
||||||
)}
|
)}
|
||||||
{pages.map((i) => (
|
{pages.map(i => (
|
||||||
<Block
|
<Block
|
||||||
isSecondary={pagination.currentPage !== i}
|
isSecondary={pagination.currentPage !== i}
|
||||||
color={'primary'}
|
color={'primary'}
|
||||||
|
@ -63,7 +63,7 @@ function Pagination<T>({ data: { items, pagination }, onPageSelect, children }:
|
||||||
{i}
|
{i}
|
||||||
</Block>
|
</Block>
|
||||||
))}
|
))}
|
||||||
{pages[4] < pagination.totalPages && !isLastPage && (
|
{(pages?.[4] ?? 0) < pagination.totalPages && !isLastPage && (
|
||||||
<Block isSecondary color={'primary'} onClick={() => onPageSelect(pagination.totalPages)}>
|
<Block isSecondary color={'primary'} onClick={() => onPageSelect(pagination.totalPages)}>
|
||||||
<FontAwesomeIcon icon={faAngleDoubleRight} />
|
<FontAwesomeIcon icon={faAngleDoubleRight} />
|
||||||
</Block>
|
</Block>
|
||||||
|
|
|
@ -1,28 +1,26 @@
|
||||||
import React from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { Route } from 'react-router-dom';
|
|
||||||
import { RouteProps } from 'react-router';
|
|
||||||
import Can from '@/components/elements/Can';
|
|
||||||
import { ServerError } from '@/components/elements/ScreenBlock';
|
|
||||||
|
|
||||||
interface Props extends Omit<RouteProps, 'path'> {
|
import { ServerError } from '@/components/elements/ScreenBlock';
|
||||||
path: string;
|
import { usePermissions } from '@/plugins/usePermissions';
|
||||||
permission: string | string[] | null;
|
|
||||||
|
interface Props {
|
||||||
|
children?: ReactNode;
|
||||||
|
|
||||||
|
permission?: string | string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ({ permission, children, ...props }: Props) => (
|
function PermissionRoute({ children, permission }: Props): JSX.Element {
|
||||||
<Route {...props}>
|
if (permission === undefined) {
|
||||||
{!permission ? (
|
return <>{children}</>;
|
||||||
children
|
|
||||||
) : (
|
|
||||||
<Can
|
|
||||||
matchAny
|
|
||||||
action={permission}
|
|
||||||
renderOnError={
|
|
||||||
<ServerError title={'Access Denied'} message={'You do not have permission to access this page.'} />
|
|
||||||
}
|
}
|
||||||
>
|
|
||||||
{children}
|
const can = usePermissions(permission);
|
||||||
</Can>
|
|
||||||
)}
|
if (can.filter(p => p).length > 0) {
|
||||||
</Route>
|
return <>{children}</>;
|
||||||
);
|
}
|
||||||
|
|
||||||
|
return <ServerError title="Access Denied" message="You do not have permission to access this page." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PermissionRoute;
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import React, { useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
|
import * as React from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
export default ({ children }: { children: React.ReactNode }) => {
|
export default ({ children }: { children: React.ReactNode }) => {
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue