mirror of
https://github.com/pyrohost/pyrodactyl.git
synced 2026-04-06 04:01:58 +02:00
design: this is a big one.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
<?php
|
||||
t<?php
|
||||
|
||||
namespace Pterodactyl\Http\Controllers\Admin;
|
||||
|
||||
@@ -147,7 +147,7 @@ class NodesController extends Controller
|
||||
{
|
||||
$this->allocationRepository->update($request->input('allocation_id'), [
|
||||
'ip_alias' => (empty($request->input('alias'))) ? null : $request->input('alias'),
|
||||
]);
|
||||
], false); // Skip validation
|
||||
|
||||
return response('', 204);
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ class NetworkAllocationController extends ClientApiController
|
||||
{
|
||||
$original = $allocation->notes;
|
||||
|
||||
$allocation->forceFill(['notes' => $request->input('notes')])->save();
|
||||
$allocation->forceFill(['notes' => $request->input('notes')])->skipValidation()->save();
|
||||
|
||||
if ($original !== $allocation->notes) {
|
||||
Activity::event('server:allocation.notes')
|
||||
|
||||
@@ -52,6 +52,12 @@ class AssignmentService
|
||||
// an array of records, which is not ideal for this use case, we need a SINGLE
|
||||
// IP to use, not multiple.
|
||||
$underlying = gethostbyname($data['allocation_ip']);
|
||||
|
||||
// Validate that gethostbyname returned a valid IP
|
||||
if (!filter_var($underlying, FILTER_VALIDATE_IP)) {
|
||||
throw new DisplayException("gethostbyname returned invalid IP address: {$underlying} for input: {$data['allocation_ip']}");
|
||||
}
|
||||
|
||||
$parsed = Network::parse($underlying);
|
||||
} catch (\Exception $exception) {
|
||||
/* @noinspection PhpUndefinedVariableInspection */
|
||||
@@ -78,9 +84,16 @@ class AssignmentService
|
||||
}
|
||||
|
||||
foreach ($block as $unit) {
|
||||
$ipString = $ip->__toString();
|
||||
|
||||
// Validate the IP string before insertion
|
||||
if (!filter_var($ipString, FILTER_VALIDATE_IP)) {
|
||||
throw new DisplayException("Invalid IP address generated: {$ipString}");
|
||||
}
|
||||
|
||||
$insertData[] = [
|
||||
'node_id' => $node->id,
|
||||
'ip' => $ip->__toString(),
|
||||
'ip' => $ipString,
|
||||
'port' => (int) $unit,
|
||||
'ip_alias' => array_get($data, 'allocation_alias'),
|
||||
'server_id' => null,
|
||||
@@ -91,9 +104,16 @@ class AssignmentService
|
||||
throw new PortOutOfRangeException();
|
||||
}
|
||||
|
||||
$ipString = $ip->__toString();
|
||||
|
||||
// Validate the IP string before insertion
|
||||
if (!filter_var($ipString, FILTER_VALIDATE_IP)) {
|
||||
throw new DisplayException("Invalid IP address generated: {$ipString}");
|
||||
}
|
||||
|
||||
$insertData[] = [
|
||||
'node_id' => $node->id,
|
||||
'ip' => $ip->__toString(),
|
||||
'ip' => $ipString,
|
||||
'port' => (int) $port,
|
||||
'ip_alias' => array_get($data, 'allocation_alias'),
|
||||
'server_id' => null,
|
||||
|
||||
@@ -34,19 +34,41 @@ class FindAssignableAllocationService
|
||||
throw new AutoAllocationNotEnabledException();
|
||||
}
|
||||
|
||||
// Validate that the server has a valid primary allocation IP
|
||||
if (!$server->allocation) {
|
||||
throw new \Pterodactyl\Exceptions\DisplayException("Server has no primary allocation");
|
||||
}
|
||||
|
||||
$allocationIp = $server->allocation->ip;
|
||||
|
||||
// If it's not a valid IP, try to resolve it as a hostname
|
||||
if (!filter_var($allocationIp, FILTER_VALIDATE_IP)) {
|
||||
$resolvedIp = gethostbyname($allocationIp);
|
||||
|
||||
// If gethostbyname fails, it returns the original hostname
|
||||
if ($resolvedIp === $allocationIp || !filter_var($resolvedIp, FILTER_VALIDATE_IP)) {
|
||||
throw new \Pterodactyl\Exceptions\DisplayException(
|
||||
"Cannot resolve allocation IP/hostname '{$allocationIp}' to a valid IP address"
|
||||
);
|
||||
}
|
||||
|
||||
// Use the resolved IP for allocation operations
|
||||
$allocationIp = $resolvedIp;
|
||||
}
|
||||
|
||||
// Attempt to find a given available allocation for a server. If one cannot be found
|
||||
// we will fall back to attempting to create a new allocation that can be used for the
|
||||
// server.
|
||||
/** @var Allocation|null $allocation */
|
||||
$allocation = $server->node->allocations()
|
||||
->where('ip', $server->allocation->ip)
|
||||
->where('ip', $allocationIp)
|
||||
->whereNull('server_id')
|
||||
->inRandomOrder()
|
||||
->first();
|
||||
|
||||
$allocation = $allocation ?? $this->createNewAllocation($server);
|
||||
$allocation = $allocation ?? $this->createNewAllocation($server, $allocationIp);
|
||||
|
||||
$allocation->update(['server_id' => $server->id]);
|
||||
$allocation->skipValidation()->update(['server_id' => $server->id]);
|
||||
|
||||
return $allocation->refresh();
|
||||
}
|
||||
@@ -62,7 +84,7 @@ class FindAssignableAllocationService
|
||||
* @throws \Pterodactyl\Exceptions\Service\Allocation\PortOutOfRangeException
|
||||
* @throws \Pterodactyl\Exceptions\Service\Allocation\TooManyPortsInRangeException
|
||||
*/
|
||||
protected function createNewAllocation(Server $server): Allocation
|
||||
protected function createNewAllocation(Server $server, string $resolvedIp): Allocation
|
||||
{
|
||||
$start = config('pterodactyl.client_features.allocations.range_start', null);
|
||||
$end = config('pterodactyl.client_features.allocations.range_end', null);
|
||||
@@ -77,7 +99,7 @@ class FindAssignableAllocationService
|
||||
// Get all of the currently allocated ports for the node so that we can figure out
|
||||
// which port might be available.
|
||||
$ports = $server->node->allocations()
|
||||
->where('ip', $server->allocation->ip)
|
||||
->where('ip', $resolvedIp)
|
||||
->whereBetween('port', [$start, $end])
|
||||
->pluck('port');
|
||||
|
||||
@@ -96,13 +118,13 @@ class FindAssignableAllocationService
|
||||
$port = $available[array_rand($available)];
|
||||
|
||||
$this->service->handle($server->node, [
|
||||
'allocation_ip' => $server->allocation->ip,
|
||||
'allocation_ip' => $resolvedIp,
|
||||
'allocation_ports' => [$port],
|
||||
]);
|
||||
|
||||
/** @var Allocation $allocation */
|
||||
$allocation = $server->node->allocations()
|
||||
->where('ip', $server->allocation->ip)
|
||||
->where('ip', $resolvedIp)
|
||||
->where('port', $port)
|
||||
->firstOrFail();
|
||||
|
||||
|
||||
@@ -1,27 +1,45 @@
|
||||
import { faTrashAlt } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faPlus, faTrashAlt, faKey, faEye, faEyeSlash } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { Actions, useStoreActions } from 'easy-peasy';
|
||||
import { Field, Form, Formik, FormikHelpers } from 'formik';
|
||||
import { format } from 'date-fns';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { object, string } from 'yup';
|
||||
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import CreateApiKeyForm from '@/components/dashboard/forms/CreateApiKeyForm';
|
||||
import Button from '@/components/elements/Button';
|
||||
import ApiKeyModal from '@/components/dashboard/ApiKeyModal';
|
||||
import Code from '@/components/elements/Code';
|
||||
import ContentBox from '@/components/elements/ContentBox';
|
||||
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
|
||||
import Input from '@/components/elements/Input';
|
||||
import { MainPageHeader } from '@/components/elements/MainPageHeader';
|
||||
import PageContentBlock from '@/components/elements/PageContentBlock';
|
||||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import { Dialog } from '@/components/elements/dialog';
|
||||
|
||||
import createApiKey from '@/api/account/createApiKey';
|
||||
import deleteApiKey from '@/api/account/deleteApiKey';
|
||||
import getApiKeys, { ApiKey } from '@/api/account/getApiKeys';
|
||||
import { httpErrorToHuman } from '@/api/http';
|
||||
|
||||
import { ApplicationStore } from '@/state';
|
||||
import { useFlashKey } from '@/plugins/useFlash';
|
||||
|
||||
interface CreateValues {
|
||||
description: string;
|
||||
allowedIps: string;
|
||||
}
|
||||
|
||||
const AccountApiContainer = () => {
|
||||
const [deleteIdentifier, setDeleteIdentifier] = useState('');
|
||||
const [keys, setKeys] = useState<ApiKey[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [showKeys, setShowKeys] = useState<Record<string, boolean>>({});
|
||||
|
||||
const { clearAndAddHttpError } = useFlashKey('account');
|
||||
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
|
||||
|
||||
useEffect(() => {
|
||||
getApiKeys()
|
||||
@@ -32,7 +50,6 @@ const AccountApiContainer = () => {
|
||||
|
||||
const doDeletion = (identifier: string) => {
|
||||
setLoading(true);
|
||||
|
||||
clearAndAddHttpError();
|
||||
deleteApiKey(identifier)
|
||||
.then(() => setKeys((s) => [...(s || []).filter((key) => key.identifier !== identifier)]))
|
||||
@@ -43,58 +60,195 @@ const AccountApiContainer = () => {
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContentBlock title={'Account API'}>
|
||||
{/* Flash messages will now appear at the top of the page */}
|
||||
<FlashMessageRender byKey='account' />
|
||||
<div className='md:flex flex-nowrap my-10 space-x-8'>
|
||||
<ContentBox title={'Create API Key'} className='flex-none w-full md:w-1/1'>
|
||||
<CreateApiKeyForm onKeyCreated={(key) => setKeys((s) => [...s!, key])} />
|
||||
</ContentBox>
|
||||
</div>
|
||||
<ContentBox title={'API Keys'}>
|
||||
<SpinnerOverlay visible={loading} />
|
||||
<Dialog.Confirm
|
||||
title={'Delete API Key'}
|
||||
confirm={'Delete Key'}
|
||||
open={!!deleteIdentifier}
|
||||
onClose={() => setDeleteIdentifier('')}
|
||||
onConfirmed={() => doDeletion(deleteIdentifier)}
|
||||
>
|
||||
All requests using the <Code>{deleteIdentifier}</Code> key will be invalidated.
|
||||
</Dialog.Confirm>
|
||||
const submitCreate = (values: CreateValues, { setSubmitting, resetForm }: FormikHelpers<CreateValues>) => {
|
||||
clearFlashes('account');
|
||||
createApiKey(values.description, values.allowedIps)
|
||||
.then(({ secretToken, ...key }) => {
|
||||
resetForm();
|
||||
setSubmitting(false);
|
||||
setApiKey(`${key.identifier}${secretToken}`);
|
||||
setKeys((s) => [...s!, key]);
|
||||
setShowCreateModal(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
addError({ key: 'account', message: httpErrorToHuman(error) });
|
||||
setSubmitting(false);
|
||||
});
|
||||
};
|
||||
|
||||
{keys.length === 0 ? (
|
||||
<p className='text-center text-sm text-gray-500'>
|
||||
{loading ? 'Loading...' : 'No API keys exist for this account.'}
|
||||
</p>
|
||||
) : (
|
||||
keys.map((key) => (
|
||||
<div key={key.identifier} className='flex flex-col mb-6 space-y-4'>
|
||||
<div className='flex items-center justify-between space-x-4 border border-gray-300 rounded-lg p-4 transition duration-200'>
|
||||
<div className='flex-1'>
|
||||
<p className='text-sm font-medium'>{key.description}</p>
|
||||
<p className='text-xs text-gray-500 uppercase'>
|
||||
Last used:{' '}
|
||||
{key.lastUsedAt ? format(key.lastUsedAt, 'MMM d, yyyy HH:mm') : 'Never'}
|
||||
</p>
|
||||
</div>
|
||||
<p className='text-sm text-gray-600 hidden md:block'>
|
||||
<code className='font-mono py-1 px-2 bg-gray-800 rounded-sm text-white'>
|
||||
{key.identifier}
|
||||
</code>
|
||||
</p>
|
||||
<Button
|
||||
className='p-2 text-red-500 hover:text-red-700'
|
||||
onClick={() => setDeleteIdentifier(key.identifier)}
|
||||
const toggleKeyVisibility = (identifier: string) => {
|
||||
setShowKeys(prev => ({
|
||||
...prev,
|
||||
[identifier]: !prev[identifier]
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContentBlock title={'API Keys'}>
|
||||
<FlashMessageRender byKey='account' />
|
||||
<ApiKeyModal visible={apiKey.length > 0} onModalDismissed={() => setApiKey('')} apiKey={apiKey} />
|
||||
|
||||
{/* Create API Key Modal */}
|
||||
{showCreateModal && (
|
||||
<Dialog.Confirm
|
||||
open={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
title='Create API Key'
|
||||
confirm='Create Key'
|
||||
onConfirmed={() => {
|
||||
const form = document.getElementById('create-api-form') as HTMLFormElement;
|
||||
if (form) {
|
||||
const submitButton = form.querySelector('button[type="submit"]') as HTMLButtonElement;
|
||||
if (submitButton) submitButton.click();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Formik
|
||||
onSubmit={submitCreate}
|
||||
initialValues={{ description: '', allowedIps: '' }}
|
||||
validationSchema={object().shape({
|
||||
allowedIps: string(),
|
||||
description: string().required().min(4),
|
||||
})}
|
||||
>
|
||||
{({ isSubmitting }) => (
|
||||
<Form id='create-api-form' className='space-y-4'>
|
||||
<SpinnerOverlay visible={isSubmitting} />
|
||||
|
||||
<FormikFieldWrapper
|
||||
label='Description'
|
||||
name='description'
|
||||
description='A description of this API key.'
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrashAlt} size='lg' />
|
||||
</Button>
|
||||
<Field name='description' as={Input} className='w-full' />
|
||||
</FormikFieldWrapper>
|
||||
|
||||
<FormikFieldWrapper
|
||||
label='Allowed IPs'
|
||||
name='allowedIps'
|
||||
description='Leave blank to allow any IP address to use this API key, otherwise provide each IP address on a new line.'
|
||||
>
|
||||
<Field name='allowedIps' as={Input} className='w-full' />
|
||||
</FormikFieldWrapper>
|
||||
|
||||
<button type='submit' className='hidden' />
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</Dialog.Confirm>
|
||||
)}
|
||||
|
||||
<div className='w-full h-full min-h-full flex-1 flex flex-col px-2 sm:px-0'>
|
||||
<div
|
||||
className='transform-gpu skeleton-anim-2 mb-3 sm:mb-4'
|
||||
style={{
|
||||
animationDelay: '50ms',
|
||||
animationTimingFunction:
|
||||
'linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)',
|
||||
}}
|
||||
>
|
||||
<MainPageHeader title='API Keys'>
|
||||
<ActionButton
|
||||
variant="primary"
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className='flex items-center gap-2'
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} className='w-4 h-4' />
|
||||
Create API Key
|
||||
</ActionButton>
|
||||
</MainPageHeader>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className='transform-gpu skeleton-anim-2'
|
||||
style={{
|
||||
animationDelay: '75ms',
|
||||
animationTimingFunction:
|
||||
'linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)',
|
||||
}}
|
||||
>
|
||||
<div className='bg-gradient-to-b from-[#ffffff08] to-[#ffffff05] border-[1px] border-[#ffffff12] rounded-xl p-4 sm:p-6 shadow-sm'>
|
||||
<SpinnerOverlay visible={loading} />
|
||||
<Dialog.Confirm
|
||||
title={'Delete API Key'}
|
||||
confirm={'Delete Key'}
|
||||
open={!!deleteIdentifier}
|
||||
onClose={() => setDeleteIdentifier('')}
|
||||
onConfirmed={() => doDeletion(deleteIdentifier)}
|
||||
>
|
||||
All requests using the <Code>{deleteIdentifier}</Code> key will be invalidated.
|
||||
</Dialog.Confirm>
|
||||
|
||||
|
||||
{keys.length === 0 ? (
|
||||
<div className='text-center py-12'>
|
||||
<div className='w-16 h-16 mx-auto mb-4 rounded-full bg-[#ffffff11] flex items-center justify-center'>
|
||||
<FontAwesomeIcon icon={faKey} className='w-8 h-8 text-zinc-400' />
|
||||
</div>
|
||||
<h3 className='text-lg font-medium text-zinc-200 mb-2'>No API Keys</h3>
|
||||
<p className='text-sm text-zinc-400 max-w-sm mx-auto'>
|
||||
{loading ? 'Loading your API keys...' : 'You haven\'t created any API keys yet. Create one to get started with the API.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</ContentBox>
|
||||
) : (
|
||||
<div className='space-y-3'>
|
||||
{keys.map((key, index) => (
|
||||
<div
|
||||
key={key.identifier}
|
||||
className='transform-gpu skeleton-anim-2'
|
||||
style={{
|
||||
animationDelay: `${index * 25 + 100}ms`,
|
||||
animationTimingFunction:
|
||||
'linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)',
|
||||
}}
|
||||
>
|
||||
<div className='bg-[#ffffff05] border-[1px] border-[#ffffff08] rounded-lg p-4 hover:border-[#ffffff15] transition-all duration-150'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex-1 min-w-0'>
|
||||
<div className='flex items-center gap-3 mb-2'>
|
||||
<h4 className='text-sm font-medium text-zinc-100 truncate'>{key.description}</h4>
|
||||
</div>
|
||||
<div className='flex items-center gap-4 text-xs text-zinc-400'>
|
||||
<span>
|
||||
Last used: {key.lastUsedAt ? format(key.lastUsedAt, 'MMM d, yyyy HH:mm') : 'Never'}
|
||||
</span>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span>Key:</span>
|
||||
<code className='font-mono px-2 py-1 bg-[#ffffff08] border border-[#ffffff08] rounded text-zinc-300'>
|
||||
{showKeys[key.identifier] ? key.identifier : '••••••••••••••••'}
|
||||
</code>
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => toggleKeyVisibility(key.identifier)}
|
||||
className='p-1 text-zinc-400 hover:text-zinc-300'
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={showKeys[key.identifier] ? faEyeSlash : faEye}
|
||||
className='w-3 h-3'
|
||||
/>
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ActionButton
|
||||
variant="danger"
|
||||
size="sm"
|
||||
className='ml-4'
|
||||
onClick={() => setDeleteIdentifier(key.identifier)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrashAlt} className='w-4 h-4' />
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageContentBlock>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,37 +14,75 @@ const AccountOverviewContainer = () => {
|
||||
|
||||
return (
|
||||
<PageContentBlock title={'Your Settings'}>
|
||||
<h1 className='text-[52px] font-extrabold leading-[98%] tracking-[-0.14rem] mb-8'>Your Settings</h1>
|
||||
{state?.twoFactorRedirect && (
|
||||
<MessageBox title={'2-Factor Required'} type={'error'}>
|
||||
Your account must have two-factor authentication enabled in order to continue.
|
||||
</MessageBox>
|
||||
)}
|
||||
|
||||
<div className='flex flex-col w-full h-full gap-4'>
|
||||
<h2 className='mt-8 font-extrabold text-2xl'>Account Information</h2>
|
||||
<ContentBox title={'Email Address'} showFlashes={'account:email'}>
|
||||
<UpdateEmailAddressForm />
|
||||
</ContentBox>
|
||||
<h2 className='mt-8 font-extrabold text-2xl'>Password and Authentication</h2>
|
||||
<ContentBox title={'Account Password'} showFlashes={'account:password'}>
|
||||
<UpdatePasswordForm />
|
||||
</ContentBox>
|
||||
<ContentBox title={'Multi-Factor Authentication'}>
|
||||
<ConfigureTwoFactorForm />
|
||||
</ContentBox>
|
||||
<h2 className='mt-8 font-extrabold text-2xl'>App</h2>
|
||||
<ContentBox title={'Panel Version'}>
|
||||
<p className='text-sm mb-4'>
|
||||
This is useful to provide Pyro staff if you run into an unexpected issue.
|
||||
</p>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<Code>
|
||||
Version: {import.meta.env.VITE_PYRODACTYL_VERSION} - {import.meta.env.VITE_BRANCH_NAME}
|
||||
</Code>
|
||||
<Code>Commit : {import.meta.env.VITE_COMMIT_HASH.slice(0, 7)}</Code>
|
||||
<div className='w-full h-full min-h-full flex-1 flex flex-col px-2 sm:px-0'>
|
||||
{state?.twoFactorRedirect && (
|
||||
<div
|
||||
className='transform-gpu skeleton-anim-2 mb-3 sm:mb-4'
|
||||
style={{
|
||||
animationDelay: '25ms',
|
||||
animationTimingFunction:
|
||||
'linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)',
|
||||
}}
|
||||
>
|
||||
<MessageBox title={'2-Factor Required'} type={'error'}>
|
||||
Your account must have two-factor authentication enabled in order to continue.
|
||||
</MessageBox>
|
||||
</div>
|
||||
</ContentBox>
|
||||
)}
|
||||
|
||||
<div className='flex flex-col w-full h-full gap-4'>
|
||||
<div
|
||||
className='transform-gpu skeleton-anim-2'
|
||||
style={{
|
||||
animationDelay: '50ms',
|
||||
animationTimingFunction:
|
||||
'linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)',
|
||||
}}
|
||||
>
|
||||
<ContentBox title={'Email Address'} showFlashes={'account:email'}>
|
||||
<UpdateEmailAddressForm />
|
||||
</ContentBox>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className='transform-gpu skeleton-anim-2'
|
||||
style={{
|
||||
animationDelay: '75ms',
|
||||
animationTimingFunction:
|
||||
'linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)',
|
||||
}}
|
||||
>
|
||||
<div className='space-y-4'>
|
||||
<ContentBox title={'Account Password'} showFlashes={'account:password'}>
|
||||
<UpdatePasswordForm />
|
||||
</ContentBox>
|
||||
<ContentBox title={'Multi-Factor Authentication'}>
|
||||
<ConfigureTwoFactorForm />
|
||||
</ContentBox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className='transform-gpu skeleton-anim-2'
|
||||
style={{
|
||||
animationDelay: '100ms',
|
||||
animationTimingFunction:
|
||||
'linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)',
|
||||
}}
|
||||
>
|
||||
<ContentBox title={'Panel Version'}>
|
||||
<p className='text-sm mb-4 text-zinc-300'>
|
||||
This is useful to provide Pyro staff if you run into an unexpected issue.
|
||||
</p>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<Code>
|
||||
Version: {import.meta.env.VITE_PYRODACTYL_VERSION} - {import.meta.env.VITE_BRANCH_NAME}
|
||||
</Code>
|
||||
<Code>Commit : {import.meta.env.VITE_COMMIT_HASH.slice(0, 7)}</Code>
|
||||
</div>
|
||||
</ContentBox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageContentBlock>
|
||||
);
|
||||
|
||||
@@ -65,23 +65,28 @@ const DashboardContainer = () => {
|
||||
|
||||
return (
|
||||
<PageContentBlock title={'Dashboard'} showFlashKey={'dashboard'}>
|
||||
<Tabs
|
||||
defaultValue={dashboardDisplayOption}
|
||||
onValueChange={(value) => {
|
||||
setDashboardDisplayOption(value);
|
||||
}}
|
||||
className='w-full'
|
||||
>
|
||||
<MainPageHeader title={showOnlyAdmin ? 'Other Servers' : 'Your Servers'}>
|
||||
<div className='w-full h-full min-h-full flex-1 flex flex-col px-2 sm:px-0'>
|
||||
<Tabs
|
||||
defaultValue={dashboardDisplayOption}
|
||||
onValueChange={(value) => {
|
||||
setDashboardDisplayOption(value);
|
||||
}}
|
||||
className='w-full'
|
||||
>
|
||||
<div
|
||||
className='transform-gpu skeleton-anim-2 mb-3 sm:mb-4'
|
||||
style={{
|
||||
animationDelay: '50ms',
|
||||
animationTimingFunction:
|
||||
'linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)',
|
||||
}}
|
||||
>
|
||||
<MainPageHeader title={showOnlyAdmin ? 'Other Servers' : 'Your Servers'}>
|
||||
<div className='flex gap-4'>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(124.75% 124.75% at 50.01% -10.55%, rgb(36, 36, 36) 0%, rgb(20, 20, 20) 100%)',
|
||||
}}
|
||||
className='flex items-center gap-2 font-bold text-sm px-4 py-3 rounded-full border-[1px] border-[#ffffff12] hover:bg-[#ffffff11] transition-colors duration-150 cursor-pointer'
|
||||
className='inline-flex h-9 cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md bg-[#ffffff11] px-3 py-1.5 text-sm font-medium text-[#ffffff88] transition-all hover:bg-[#ffffff23] hover:text-[#ffffff] focus-visible:outline-hidden'
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
@@ -157,7 +162,8 @@ const DashboardContainer = () => {
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
</MainPageHeader>
|
||||
</MainPageHeader>
|
||||
</div>
|
||||
{!servers ? (
|
||||
<div className='flex items-center justify-center py-12'>
|
||||
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-brand'></div>
|
||||
@@ -194,7 +200,7 @@ const DashboardContainer = () => {
|
||||
>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
d='M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zm0 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V8zm0 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1v-2z'
|
||||
d='M9.293 2.293a1 1 0 011.414 0l7 7A1 1 0 0117 11h-1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-3a1 1 0 00-1-1H9a1 1 0 00-1 1v3a1 1 0 01-1 1H5a1 1 0 01-1-1v-6H3a1 1 0 01-.707-1.707l7-7z'
|
||||
clipRule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
@@ -243,11 +249,12 @@ const DashboardContainer = () => {
|
||||
<svg
|
||||
className='w-8 h-8 text-zinc-400'
|
||||
fill='currentColor'
|
||||
viewBox='0 0 16 17'
|
||||
viewBox='0 0 20 20'
|
||||
>
|
||||
<path
|
||||
d='M1 3.1C1 2.54 1 2.26 1.109 2.046C1.20487 1.85785 1.35785 1.70487 1.546 1.609C1.76 1.5 2.04 1.5 2.6 1.5H5.4C5.96 1.5 6.24 1.5 6.454 1.609C6.64215 1.70487 6.79513 1.85785 6.891 2.046C7 2.26 7 2.54 7 3.1V3.9C7 4.46 7 4.74 6.891 4.954C6.79513 5.14215 6.64215 5.29513 6.454 5.391C6.24 5.5 5.96 5.5 5.4 5.5H2.6C2.04 5.5 1.76 5.5 1.546 5.391C1.35785 5.29513 1.20487 5.14215 1.109 4.954C1 4.74 1 4.46 1 3.9V3.1ZM9 3.1C9 2.54 9 2.26 9.109 2.046C9.20487 1.85785 9.35785 1.70487 9.546 1.609C9.76 1.5 10.04 1.5 10.6 1.5H13.4C13.96 1.5 14.24 1.5 14.454 1.609C14.6422 1.70487 14.7951 1.85785 14.891 2.046C15 2.26 15 2.54 15 3.1V3.9C15 4.46 15 4.74 14.891 4.954C14.7951 5.14215 14.6422 5.29513 14.454 5.391C14.24 5.5 13.96 5.5 13.4 5.5H10.6C10.04 5.5 9.76 5.5 9.546 5.391C9.35785 5.29513 9.20487 5.14215 9.109 4.954C9 4.74 9 4.46 9 3.9V3.1ZM1 8.1C1 7.54 1 7.26 1.109 7.046C1.20487 6.85785 1.35785 6.70487 1.546 6.609C1.76 6.5 2.04 6.5 2.6 6.5H5.4C5.96 6.5 6.24 6.5 6.454 6.609C6.64215 6.70487 6.79513 6.85785 6.891 7.046C7 7.26 7 7.54 7 8.1V8.9C7 9.46 7 9.74 6.891 9.954C6.79513 10.1422 6.64215 10.2951 6.454 10.391C6.24 10.5 5.96 10.5 5.4 10.5H2.6C2.04 10.5 1.76 10.5 1.546 10.391C1.35785 10.2951 1.20487 10.1422 1.109 9.954C1 9.74 1 9.46 1 8.9V8.1ZM9 8.1C9 7.54 9 7.26 9.109 7.046C9.20487 6.85785 9.35785 6.70487 9.546 6.609C9.76 6.5 10.04 6.5 10.6 6.5H13.4C13.96 6.5 14.24 6.5 14.454 6.609C14.6422 6.70487 14.7951 6.85785 14.891 7.046C15 7.26 15 7.54 15 8.1V8.9C15 9.46 15 9.74 14.891 9.954C14.7951 10.1422 14.6422 10.2951 14.454 10.391C14.24 10.5 13.96 10.5 13.4 10.5H10.6C10.04 10.5 9.76 10.5 9.546 10.391C9.35785 10.2951 9.20487 10.1422 9.109 9.954C9 9.74 9 9.46 9 8.9V8.1ZM1 13.1C1 12.54 1 12.26 1.109 12.046C1.20487 11.8578 1.35785 11.7049 1.546 11.609C1.76 11.5 2.04 11.5 2.6 11.5H5.4C5.96 11.5 6.24 11.5 6.454 11.609C6.64215 11.7049 6.79513 11.8578 6.891 12.046C7 12.26 7 12.54 7 13.1V13.9C7 14.46 7 14.74 6.891 14.954C6.79513 15.1422 6.64215 15.2951 6.454 15.391C6.24 15.5 5.96 15.5 5.4 15.5H2.6C2.04 15.5 1.76 15.5 1.546 15.391C1.35785 15.2951 1.20487 15.1422 1.109 14.954C1 14.74 1 14.46 1 13.9V13.1ZM9 13.1C9 12.54 9 12.26 9.109 12.046C9.20487 11.8578 9.35785 11.7049 9.546 11.609C9.76 11.5 10.04 11.5 10.6 11.5H13.4C13.96 11.5 14.24 11.5 14.454 11.609C14.6422 11.7049 14.7951 11.8578 14.891 12.046C15 12.26 15 12.54 15 13.1V13.9C15 14.46 15 14.74 14.891 14.954C14.7951 15.1422 14.6422 15.2951 14.454 15.391C14.24 15.5 13.96 15.5 13.4 15.5H10.6C10.04 15.5 9.76 15.5 9.546 15.391C9.35785 15.2951 9.20487 15.1422 9.109 14.954C9 14.74 9 14.46 9 13.9V13.1Z'
|
||||
fill='currentColor'
|
||||
fillRule='evenodd'
|
||||
d='M9.293 2.293a1 1 0 011.414 0l7 7A1 1 0 0117 11h-1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-3a1 1 0 00-1-1H9a1 1 0 00-1 1v3a1 1 0 01-1 1H5a1 1 0 01-1-1v-6H3a1 1 0 01-.707-1.707l7-7z'
|
||||
clipRule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
@@ -267,7 +274,8 @@ const DashboardContainer = () => {
|
||||
</TabsContent>
|
||||
</>
|
||||
)}
|
||||
</Tabs>
|
||||
</Tabs>
|
||||
</div>
|
||||
</PageContentBlock>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ import PageContentBlock from '@/components/elements/PageContentBlock';
|
||||
import { MainPageHeader } from '@/components/elements/MainPageHeader';
|
||||
import Spinner from '@/components/elements/Spinner';
|
||||
import ActivityLogEntry from '@/components/elements/activity/ActivityLogEntry';
|
||||
import { Button } from '@/components/elements/button/index';
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import PaginationFooter from '@/components/elements/table/PaginationFooter';
|
||||
import { Input } from '@/components/elements/inputs';
|
||||
import Select from '@/components/elements/Select';
|
||||
@@ -165,8 +165,8 @@ const ActivityLogContainer = () => {
|
||||
>
|
||||
<MainPageHeader title={'Activity Log'}>
|
||||
<div className='flex gap-2 items-center flex-wrap'>
|
||||
<Button.Text
|
||||
variant={Button.Variants.Secondary}
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className='flex items-center gap-2'
|
||||
title='Toggle Filters (Ctrl+F)'
|
||||
@@ -174,18 +174,18 @@ const ActivityLogContainer = () => {
|
||||
<FontAwesomeIcon icon={faFilter} className='w-4 h-4' />
|
||||
Filters
|
||||
{hasActiveFilters && <span className='w-2 h-2 bg-blue-500 rounded-full'></span>}
|
||||
</Button.Text>
|
||||
<Button.Text
|
||||
variant={autoRefresh ? Button.Variants.Primary : Button.Variants.Secondary}
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
variant={autoRefresh ? "primary" : "secondary"}
|
||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||
className='flex items-center gap-2'
|
||||
title='Auto Refresh (Ctrl+R)'
|
||||
>
|
||||
<FontAwesomeIcon icon={autoRefresh ? faTimes : faSearch} className='w-4 h-4' />
|
||||
{autoRefresh ? 'Live' : 'Refresh'}
|
||||
</Button.Text>
|
||||
<Button.Text
|
||||
variant={Button.Variants.Secondary}
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
onClick={exportLogs}
|
||||
disabled={!filteredData?.items?.length}
|
||||
className='flex items-center gap-2'
|
||||
@@ -193,7 +193,7 @@ const ActivityLogContainer = () => {
|
||||
>
|
||||
<FontAwesomeIcon icon={faDownload} className='w-4 h-4' />
|
||||
Export
|
||||
</Button.Text>
|
||||
</ActionButton>
|
||||
</div>
|
||||
</MainPageHeader>
|
||||
</div>
|
||||
@@ -264,14 +264,14 @@ const ActivityLogContainer = () => {
|
||||
|
||||
<div className='flex items-end'>
|
||||
{hasActiveFilters && (
|
||||
<Button.Text
|
||||
variant={Button.Variants.Secondary}
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
onClick={clearAllFilters}
|
||||
className='flex items-center gap-2 w-full'
|
||||
>
|
||||
<FontAwesomeIcon icon={faTimes} className='w-4 h-4' />
|
||||
Clear All Filters
|
||||
</Button.Text>
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -316,18 +316,18 @@ const ActivityLogContainer = () => {
|
||||
</p>
|
||||
{hasActiveFilters && (
|
||||
<div className='flex gap-2 justify-center'>
|
||||
<Button.Text
|
||||
variant={Button.Variants.Secondary}
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
onClick={clearAllFilters}
|
||||
>
|
||||
Clear All Filters
|
||||
</Button.Text>
|
||||
<Button.Text
|
||||
variant={Button.Variants.Secondary}
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
onClick={() => setShowFilters(true)}
|
||||
>
|
||||
Adjust Filters
|
||||
</Button.Text>
|
||||
</ActionButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect, useState } from 'react';
|
||||
import DisableTOTPDialog from '@/components/dashboard/forms/DisableTOTPDialog';
|
||||
import RecoveryTokensDialog from '@/components/dashboard/forms/RecoveryTokensDialog';
|
||||
import SetupTOTPDialog from '@/components/dashboard/forms/SetupTOTPDialog';
|
||||
import { Button } from '@/components/elements/button/index';
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
|
||||
import { ApplicationStore } from '@/state';
|
||||
|
||||
@@ -39,9 +39,9 @@ const ConfigureTwoFactorForm = () => {
|
||||
</p>
|
||||
<div className={`mt-6`}>
|
||||
{isEnabled ? (
|
||||
<Button.Danger onClick={() => setVisible('disable')}>Remove Authenticator App</Button.Danger>
|
||||
<ActionButton variant="danger" onClick={() => setVisible('disable')}>Remove Authenticator App</ActionButton>
|
||||
) : (
|
||||
<Button onClick={() => setVisible('enable')}>Enable Authenticator App</Button>
|
||||
<ActionButton variant="primary" onClick={() => setVisible('enable')}>Enable Authenticator App</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
|
||||
import Input from '@/components/elements/Input';
|
||||
import PageContentBlock from '@/components/elements/PageContentBlock';
|
||||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||
import { Button } from '@/components/elements/button/index';
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
|
||||
import createApiKey from '@/api/account/createApiKey';
|
||||
import { ApiKey } from '@/api/account/getApiKeys';
|
||||
@@ -88,9 +88,9 @@ const CreateApiKeyForm = ({ onKeyCreated }: { onKeyCreated: (key: ApiKey) => voi
|
||||
|
||||
{/* Submit Button below form fields */}
|
||||
<div className='flex justify-end mt-6'>
|
||||
<Button type='submit' disabled={isSubmitting}>
|
||||
<ActionButton type='submit' disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Creating...' : 'Create API Key'}
|
||||
</Button>
|
||||
</ActionButton>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import { Button } from '@/components/elements/button/index';
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import { Dialog, DialogWrapperContext } from '@/components/elements/dialog';
|
||||
import { Input } from '@/components/elements/inputs';
|
||||
|
||||
@@ -57,15 +57,15 @@ const DisableTOTPDialog = () => {
|
||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||
/>
|
||||
<Dialog.Footer>
|
||||
<Button.Text onClick={close}>Cancel</Button.Text>
|
||||
<ActionButton variant="secondary" onClick={close}>Cancel</ActionButton>
|
||||
{/* <Tooltip
|
||||
delay={100}
|
||||
disabled={password.length > 0}
|
||||
content={'You must enter your account password to continue.'}
|
||||
> */}
|
||||
<Button.Danger type={'submit'} form={'disable-totp-form'} disabled={submitting || !password.length}>
|
||||
<ActionButton variant="danger" type={'submit'} form={'disable-totp-form'} disabled={submitting || !password.length}>
|
||||
Disable
|
||||
</Button.Danger>
|
||||
</ActionButton>
|
||||
{/* </Tooltip> */}
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import CopyOnClick from '@/components/elements/CopyOnClick';
|
||||
import { Alert } from '@/components/elements/alert';
|
||||
import { Button } from '@/components/elements/button/index';
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import { Dialog, DialogProps } from '@/components/elements/dialog';
|
||||
|
||||
interface RecoveryTokenDialogProps extends DialogProps {
|
||||
@@ -43,7 +43,7 @@ const RecoveryTokensDialog = ({ tokens, open, onClose }: RecoveryTokenDialogProp
|
||||
These codes will not be shown again.
|
||||
</Alert>
|
||||
<Dialog.Footer>
|
||||
<Button.Text onClick={onClose}>Done</Button.Text>
|
||||
<ActionButton variant="primary" onClick={onClose}>Done</ActionButton>
|
||||
</Dialog.Footer>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useContext, useEffect, useState } from 'react';
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import CopyOnClick from '@/components/elements/CopyOnClick';
|
||||
import Spinner from '@/components/elements/Spinner';
|
||||
import { Button } from '@/components/elements/button/index';
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import { Dialog, DialogWrapperContext } from '@/components/elements/dialog';
|
||||
import { Input } from '@/components/elements/inputs';
|
||||
|
||||
@@ -105,7 +105,7 @@ const ConfigureTwoFactorForm = ({ onTokens }: Props) => {
|
||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||
/>
|
||||
<Dialog.Footer>
|
||||
<Button.Text onClick={close}>Cancel</Button.Text>
|
||||
<ActionButton variant="secondary" onClick={close}>Cancel</ActionButton>
|
||||
{/* <Tooltip
|
||||
disabled={password.length > 0 && value.length === 6}
|
||||
content={
|
||||
@@ -115,13 +115,14 @@ const ConfigureTwoFactorForm = ({ onTokens }: Props) => {
|
||||
}
|
||||
delay={100}
|
||||
> */}
|
||||
<Button
|
||||
<ActionButton
|
||||
variant="primary"
|
||||
disabled={!token || value.length !== 6 || !password.length}
|
||||
type={'submit'}
|
||||
form={'enable-totp-form'}
|
||||
>
|
||||
Enable
|
||||
</Button>
|
||||
</ActionButton>
|
||||
{/* </Tooltip> */}
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
|
||||
@@ -5,7 +5,7 @@ import * as Yup from 'yup';
|
||||
|
||||
import Field from '@/components/elements/Field';
|
||||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||
import { Button } from '@/components/elements/button/index';
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
|
||||
import { httpErrorToHuman } from '@/api/http';
|
||||
|
||||
@@ -68,7 +68,7 @@ const UpdateEmailAddressForm = () => {
|
||||
/>
|
||||
</div>
|
||||
<div className={`mt-6`}>
|
||||
<Button disabled={isSubmitting || !isValid}>Update Email</Button>
|
||||
<ActionButton variant="primary" disabled={isSubmitting || !isValid}>Update Email</ActionButton>
|
||||
</div>
|
||||
</Form>
|
||||
</Fragment>
|
||||
|
||||
@@ -5,7 +5,7 @@ import * as Yup from 'yup';
|
||||
|
||||
import Field from '@/components/elements/Field';
|
||||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||
import { Button } from '@/components/elements/button/index';
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
|
||||
import updateAccountPassword from '@/api/account/updateAccountPassword';
|
||||
import { httpErrorToHuman } from '@/api/http';
|
||||
@@ -93,7 +93,7 @@ const UpdatePasswordForm = () => {
|
||||
/>
|
||||
</div>
|
||||
<div className={`mt-6`}>
|
||||
<Button disabled={isSubmitting || !isValid}>Update Password</Button>
|
||||
<ActionButton variant="primary" disabled={isSubmitting || !isValid}>Update Password</ActionButton>
|
||||
</div>
|
||||
</Form>
|
||||
</Fragment>
|
||||
|
||||
@@ -1,23 +1,40 @@
|
||||
import { faPlus, faTrashAlt, faKey, faEye, faEyeSlash } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { Actions, useStoreActions } from 'easy-peasy';
|
||||
import { Field, Form, Formik, FormikHelpers } from 'formik';
|
||||
import { format } from 'date-fns';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { object, string } from 'yup';
|
||||
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import CreateSSHKeyForm from '@/components/dashboard/ssh/CreateSSHKeyForm';
|
||||
import DeleteSSHKeyButton from '@/components/dashboard/ssh/DeleteSSHKeyButton';
|
||||
import Code from '@/components/elements/Code';
|
||||
import ContentBox from '@/components/elements/ContentBox';
|
||||
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
|
||||
import Input from '@/components/elements/Input';
|
||||
import { MainPageHeader } from '@/components/elements/MainPageHeader';
|
||||
import PageContentBlock from '@/components/elements/PageContentBlock';
|
||||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import { Dialog } from '@/components/elements/dialog';
|
||||
|
||||
import { useSSHKeys } from '@/api/account/ssh-keys';
|
||||
import { createSSHKey, deleteSSHKey, useSSHKeys } from '@/api/account/ssh-keys';
|
||||
import { httpErrorToHuman } from '@/api/http';
|
||||
|
||||
import { ApplicationStore } from '@/state';
|
||||
import { useFlashKey } from '@/plugins/useFlash';
|
||||
|
||||
interface CreateValues {
|
||||
name: string;
|
||||
publicKey: string;
|
||||
}
|
||||
|
||||
const AccountSSHContainer = () => {
|
||||
const [deleteIdentifier, setDeleteIdentifier] = useState('');
|
||||
const [deleteKey, setDeleteKey] = useState<{ name: string; fingerprint: string } | null>(null);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showKeys, setShowKeys] = useState<Record<string, boolean>>({});
|
||||
|
||||
const { clearAndAddHttpError } = useFlashKey('account');
|
||||
const { data, isValidating, error } = useSSHKeys({
|
||||
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
|
||||
const { data, isValidating, error, mutate } = useSSHKeys({
|
||||
revalidateOnMount: true,
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
@@ -26,54 +43,208 @@ const AccountSSHContainer = () => {
|
||||
clearAndAddHttpError(error);
|
||||
}, [error]);
|
||||
|
||||
const doDeletion = (fingerprint: string) => {};
|
||||
const doDeletion = () => {
|
||||
if (!deleteKey) return;
|
||||
|
||||
clearAndAddHttpError();
|
||||
Promise.all([
|
||||
mutate((data) => data?.filter((value) => value.fingerprint !== deleteKey.fingerprint), false),
|
||||
deleteSSHKey(deleteKey.fingerprint),
|
||||
]).catch((error) => {
|
||||
mutate(undefined, true).catch(console.error);
|
||||
clearAndAddHttpError(error);
|
||||
}).finally(() => {
|
||||
setDeleteKey(null);
|
||||
});
|
||||
};
|
||||
|
||||
const submitCreate = (values: CreateValues, { setSubmitting, resetForm }: FormikHelpers<CreateValues>) => {
|
||||
clearFlashes('account');
|
||||
createSSHKey(values.name, values.publicKey)
|
||||
.then((key) => {
|
||||
resetForm();
|
||||
setSubmitting(false);
|
||||
mutate((data) => (data || []).concat(key));
|
||||
setShowCreateModal(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
addError({ key: 'account', message: httpErrorToHuman(error) });
|
||||
setSubmitting(false);
|
||||
});
|
||||
};
|
||||
|
||||
const toggleKeyVisibility = (fingerprint: string) => {
|
||||
setShowKeys(prev => ({
|
||||
...prev,
|
||||
[fingerprint]: !prev[fingerprint]
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContentBlock title={'SSH Keys'}>
|
||||
<FlashMessageRender byKey={'account'} />
|
||||
<div className='md:flex flex-nowrap my-10 space-x-8'>
|
||||
{/* Create SSH Key Section */}
|
||||
<ContentBox title={'Add SSH Key'} className='flex-none w-full md:w-1/1'>
|
||||
<CreateSSHKeyForm />
|
||||
</ContentBox>
|
||||
</div>
|
||||
{/* SSH Keys List Section */}
|
||||
<ContentBox title={'SSH Keys'}>
|
||||
<SpinnerOverlay visible={!data && isValidating} />
|
||||
|
||||
{/* Create SSH Key Modal */}
|
||||
{showCreateModal && (
|
||||
<Dialog.Confirm
|
||||
title={'Delete SSH Key'}
|
||||
confirm={'Delete Key'}
|
||||
open={!!deleteIdentifier}
|
||||
onClose={() => setDeleteIdentifier('')}
|
||||
onConfirmed={() => doDeletion(deleteIdentifier)}
|
||||
open={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
title='Add SSH Key'
|
||||
confirm='Add Key'
|
||||
onConfirmed={() => {
|
||||
const form = document.getElementById('create-ssh-form') as HTMLFormElement;
|
||||
if (form) {
|
||||
const submitButton = form.querySelector('button[type="submit"]') as HTMLButtonElement;
|
||||
if (submitButton) submitButton.click();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Deleting this key will revoke access for any system using it.
|
||||
<Formik
|
||||
onSubmit={submitCreate}
|
||||
initialValues={{ name: '', publicKey: '' }}
|
||||
validationSchema={object().shape({
|
||||
name: string().required('SSH Key Name is required'),
|
||||
publicKey: string().required('Public Key is required'),
|
||||
})}
|
||||
>
|
||||
{({ isSubmitting }) => (
|
||||
<Form id='create-ssh-form' className='space-y-4'>
|
||||
<SpinnerOverlay visible={isSubmitting} />
|
||||
|
||||
<FormikFieldWrapper
|
||||
label='SSH Key Name'
|
||||
name='name'
|
||||
description='A name to identify this SSH key.'
|
||||
>
|
||||
<Field name='name' as={Input} className='w-full' />
|
||||
</FormikFieldWrapper>
|
||||
|
||||
<FormikFieldWrapper
|
||||
label='Public Key'
|
||||
name='publicKey'
|
||||
description='Enter your public SSH key.'
|
||||
>
|
||||
<Field name='publicKey' as={Input} className='w-full' />
|
||||
</FormikFieldWrapper>
|
||||
|
||||
<button type='submit' className='hidden' />
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</Dialog.Confirm>
|
||||
{!data || data.length === 0 ? (
|
||||
<p className='text-center text-sm text-gray-500'>
|
||||
{!data ? 'Loading...' : 'No SSH keys exist for this account.'}
|
||||
</p>
|
||||
) : (
|
||||
data.map((key) => (
|
||||
<div key={key.fingerprint} className='flex flex-col mb-6 space-y-4'>
|
||||
<div className='flex items-center justify-between space-x-4 border border-gray-300 rounded-lg p-4 transition duration-200'>
|
||||
<div className='flex-1'>
|
||||
<p className='text-sm font-medium'>{key.name}</p>
|
||||
<p className='text-xs text-gray-500 uppercase'>
|
||||
Added on: {format(key.createdAt, 'MMM d, yyyy HH:mm')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className='w-full h-full min-h-full flex-1 flex flex-col px-2 sm:px-0'>
|
||||
<div
|
||||
className='transform-gpu skeleton-anim-2 mb-3 sm:mb-4'
|
||||
style={{
|
||||
animationDelay: '50ms',
|
||||
animationTimingFunction:
|
||||
'linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)',
|
||||
}}
|
||||
>
|
||||
<MainPageHeader title='SSH Keys'>
|
||||
<ActionButton
|
||||
variant="primary"
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className='flex items-center gap-2'
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} className='w-4 h-4' />
|
||||
Add SSH Key
|
||||
</ActionButton>
|
||||
</MainPageHeader>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className='transform-gpu skeleton-anim-2'
|
||||
style={{
|
||||
animationDelay: '75ms',
|
||||
animationTimingFunction:
|
||||
'linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)',
|
||||
}}
|
||||
>
|
||||
<div className='bg-gradient-to-b from-[#ffffff08] to-[#ffffff05] border-[1px] border-[#ffffff12] rounded-xl p-4 sm:p-6 shadow-sm'>
|
||||
<SpinnerOverlay visible={!data && isValidating} />
|
||||
<Dialog.Confirm
|
||||
title={'Delete SSH Key'}
|
||||
confirm={'Delete Key'}
|
||||
open={!!deleteKey}
|
||||
onClose={() => setDeleteKey(null)}
|
||||
onConfirmed={doDeletion}
|
||||
>
|
||||
Removing the <Code>{deleteKey?.name}</Code> SSH key will invalidate its usage across the Panel.
|
||||
</Dialog.Confirm>
|
||||
|
||||
|
||||
{!data || data.length === 0 ? (
|
||||
<div className='text-center py-12'>
|
||||
<div className='w-16 h-16 mx-auto mb-4 rounded-full bg-[#ffffff11] flex items-center justify-center'>
|
||||
<FontAwesomeIcon icon={faKey} className='w-8 h-8 text-zinc-400' />
|
||||
</div>
|
||||
<p className='text-sm text-gray-600 hidden md:block'>
|
||||
<code className='font-mono py-1 px-2 bg-gray-800 rounded-sm text-white'>
|
||||
SHA256: {key.fingerprint}
|
||||
</code>
|
||||
<h3 className='text-lg font-medium text-zinc-200 mb-2'>No SSH Keys</h3>
|
||||
<p className='text-sm text-zinc-400 max-w-sm mx-auto'>
|
||||
{!data ? 'Loading your SSH keys...' : 'You haven\'t added any SSH keys yet. Add one to securely access your servers.'}
|
||||
</p>
|
||||
<DeleteSSHKeyButton name={key.name} fingerprint={key.fingerprint} />
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</ContentBox>
|
||||
) : (
|
||||
<div className='space-y-3'>
|
||||
{data.map((key, index) => (
|
||||
<div
|
||||
key={key.fingerprint}
|
||||
className='transform-gpu skeleton-anim-2'
|
||||
style={{
|
||||
animationDelay: `${index * 25 + 100}ms`,
|
||||
animationTimingFunction:
|
||||
'linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)',
|
||||
}}
|
||||
>
|
||||
<div className='bg-[#ffffff05] border-[1px] border-[#ffffff08] rounded-lg p-4 hover:border-[#ffffff15] transition-all duration-150'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex-1 min-w-0'>
|
||||
<div className='flex items-center gap-3 mb-2'>
|
||||
<h4 className='text-sm font-medium text-zinc-100 truncate'>{key.name}</h4>
|
||||
</div>
|
||||
<div className='flex items-center gap-4 text-xs text-zinc-400'>
|
||||
<span>
|
||||
Added: {format(key.createdAt, 'MMM d, yyyy HH:mm')}
|
||||
</span>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span>Fingerprint:</span>
|
||||
<code className='font-mono px-2 py-1 bg-[#ffffff08] border border-[#ffffff08] rounded text-zinc-300'>
|
||||
{showKeys[key.fingerprint] ? `SHA256:${key.fingerprint}` : 'SHA256:••••••••••••••••'}
|
||||
</code>
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => toggleKeyVisibility(key.fingerprint)}
|
||||
className='p-1 text-zinc-400 hover:text-zinc-300'
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={showKeys[key.fingerprint] ? faEyeSlash : faEye}
|
||||
className='w-3 h-3'
|
||||
/>
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ActionButton
|
||||
variant="danger"
|
||||
size="sm"
|
||||
className='ml-4'
|
||||
onClick={() => setDeleteKey({ name: key.name, fingerprint: key.fingerprint })}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrashAlt} className='w-4 h-4' />
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageContentBlock>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ import ContentBox from '@/components/elements/ContentBox';
|
||||
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
|
||||
import Input from '@/components/elements/Input';
|
||||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||
import { Button } from '@/components/elements/button/index';
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
|
||||
import { createSSHKey } from '@/api/account/ssh-keys';
|
||||
import { useSSHKeys } from '@/api/account/ssh-keys';
|
||||
@@ -85,9 +85,9 @@ const CreateSSHKeyForm = () => {
|
||||
|
||||
{/* Submit Button below form fields */}
|
||||
<div className='flex justify-end mt-6'>
|
||||
<Button type='submit' disabled={isSubmitting}>
|
||||
<ActionButton type='submit' disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Creating...' : 'Create SSH Key'}
|
||||
</Button>
|
||||
</ActionButton>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
@@ -37,8 +37,11 @@ const DeleteSSHKeyButton = ({ name, fingerprint }: { name: string; fingerprint:
|
||||
>
|
||||
Removing the <Code>{name}</Code> SSH key will invalidate its usage across the Panel.
|
||||
</Dialog.Confirm>
|
||||
<button className={`p-2 text-red-500 hover:text-red-700`} onClick={() => setVisible(true)}>
|
||||
<FontAwesomeIcon icon={faTrashAlt} size='lg' />{' '}
|
||||
<button
|
||||
className='p-2 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded-lg transition-all duration-150'
|
||||
onClick={() => setVisible(true)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrashAlt} size='lg' />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
|
||||
39
resources/scripts/components/elements/ActionButton.tsx
Normal file
39
resources/scripts/components/elements/ActionButton.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
interface ActionButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'danger';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const ActionButton = forwardRef<HTMLButtonElement, ActionButtonProps>(
|
||||
({ variant = 'primary', size = 'md', className = '', children, ...props }, ref) => {
|
||||
const baseClasses = 'inline-flex cursor-pointer items-center justify-center whitespace-nowrap rounded-lg font-medium transition-all focus-visible:outline-hidden disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
|
||||
const variantClasses = {
|
||||
primary: 'bg-brand text-white hover:bg-brand/80 active:bg-brand/90 border border-brand/20',
|
||||
secondary: 'bg-[#ffffff11] text-[#ffffff88] hover:bg-[#ffffff23] hover:text-[#ffffff] border border-[#ffffff12]',
|
||||
danger: 'bg-brand/20 text-red-400 hover:bg-brand/30 hover:text-red-300 border border-brand/40 hover:border-brand/60'
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'h-8 px-3 py-1.5 text-xs',
|
||||
md: 'h-10 px-4 py-2 text-sm',
|
||||
lg: 'h-12 px-6 py-3 text-base'
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ActionButton.displayName = 'ActionButton';
|
||||
|
||||
export default ActionButton;
|
||||
@@ -1,7 +1,7 @@
|
||||
import ModalContext from '@/context/ModalContext';
|
||||
import { useContext } from 'react';
|
||||
|
||||
import { Button } from '@/components/elements/button/index';
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
|
||||
import asModal from '@/hoc/asModal';
|
||||
|
||||
@@ -21,8 +21,8 @@ const ConfirmationModal: React.FC<Props> = ({ children, buttonText, onConfirmed
|
||||
<div className='flex flex-col w-full'>
|
||||
<div className={`text-zinc-300`}>{children}</div>
|
||||
<div className={`flex gap-4 items-center justify-end my-6`}>
|
||||
<Button.Text onClick={() => dismiss()}>Cancel</Button.Text>
|
||||
<Button onClick={() => onConfirmed()}>{buttonText}</Button>
|
||||
<ActionButton variant="secondary" onClick={() => dismiss()}>Cancel</ActionButton>
|
||||
<ActionButton onClick={() => onConfirmed()}>{buttonText}</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -6,6 +6,12 @@ export interface Props {
|
||||
}
|
||||
|
||||
const checkboxStyle = css<Props>`
|
||||
appearance: none;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 0.25rem;
|
||||
background-color: rgba(255, 255, 255, 0.09);
|
||||
color-adjust: exact;
|
||||
background-origin: border-box;
|
||||
transition:
|
||||
@@ -13,13 +19,27 @@ const checkboxStyle = css<Props>`
|
||||
box-shadow 25ms linear;
|
||||
|
||||
&:checked {
|
||||
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='black' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M5.707 7.293a1 1 0 0 0-1.414 1.414l2 2a1 1 0 0 0 1.414 0l4-4a1 1 0 0 0-1.414-1.414L7 8.586 5.707 7.293z'/%3e%3c/svg%3e");
|
||||
background-color: white;
|
||||
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M5.707 7.293a1 1 0 0 0-1.414 1.414l2 2a1 1 0 0 0 1.414 0l4-4a1 1 0 0 0-1.414-1.414L7 8.586 5.707 7.293z'/%3e%3c/svg%3e");
|
||||
background-color: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
background-size: 100% 100%;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 1px rgba(9, 103, 210, 0.25);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.25);
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useMemo, useRef, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import Spinner from '@/components/elements/Spinner';
|
||||
import { Button } from '@/components/elements/button/index';
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import { DialogContext, IconPosition, styles } from '@/components/elements/dialog';
|
||||
|
||||
import HugeIconsX from './hugeicons/X';
|
||||
@@ -164,9 +164,9 @@ const Modal: React.FC<ModalProps> = ({
|
||||
</div>
|
||||
{closeButton && (
|
||||
<div className={`my-6 sm:flex items-center justify-end`}>
|
||||
<Button onClick={onDismissed} className={`min-w-full`}>
|
||||
<ActionButton onClick={onDismissed} className={`min-w-full`}>
|
||||
<div>Close</div>
|
||||
</Button>
|
||||
</ActionButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -45,7 +45,7 @@ const ActivityLogEntry = ({ activity, children }: Props) => {
|
||||
<span className='text-zinc-500'>•</span>
|
||||
<Link
|
||||
to={`#${pathTo({ event: activity.event })}`}
|
||||
className='font-mono text-xs bg-zinc-800/50 text-zinc-300 px-2 py-1 rounded hover:bg-zinc-700/50 hover:text-blue-400 transition-colors duration-150 truncate'
|
||||
className='font-mono text-xs bg-zinc-800/50 text-zinc-300 px-2 py-1 rounded hover:bg-zinc-700/50 hover:text-brand transition-colors duration-150 truncate'
|
||||
>
|
||||
{activity.event}
|
||||
</Link>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
||||
import { faCode, faCopy } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
|
||||
import { Button } from '@/components/elements/button/index';
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import { Dialog } from '@/components/elements/dialog';
|
||||
|
||||
import { formatObjectToIdentString } from '@/lib/objects';
|
||||
@@ -30,14 +30,14 @@ const ActivityLogMetaButton = ({ meta }: { meta: Record<string, unknown> }) => {
|
||||
<div className='space-y-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<h4 className='text-sm font-medium text-zinc-300'>Formatted View</h4>
|
||||
<Button.Text
|
||||
variant={Button.Variants.Secondary}
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
onClick={copyToClipboard}
|
||||
className='flex items-center gap-2 text-xs'
|
||||
>
|
||||
<FontAwesomeIcon icon={faCopy} className='w-3 h-3' />
|
||||
{copied ? 'Copied!' : 'Copy JSON'}
|
||||
</Button.Text>
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
<div className='bg-zinc-900 rounded-lg p-4 border border-zinc-800 max-h-96 overflow-auto'>
|
||||
@@ -57,7 +57,7 @@ const ActivityLogMetaButton = ({ meta }: { meta: Record<string, unknown> }) => {
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button.Text onClick={() => setOpen(false)}>Close</Button.Text>
|
||||
<ActionButton variant="secondary" onClick={() => setOpen(false)}>Close</ActionButton>
|
||||
</Dialog.Footer>
|
||||
</Dialog>
|
||||
|
||||
|
||||
@@ -1,79 +1,2 @@
|
||||
/* @import "../../../../scripts/assets/tailwind.css'; */
|
||||
@import '../../../assets/tailwind.css';
|
||||
|
||||
.button {
|
||||
@apply inline-flex cursor-pointer items-center justify-center px-4 py-2;
|
||||
@apply rounded-xl text-sm font-bold transition-all duration-100;
|
||||
/* @apply focus:ring-3 focus:ring-zinc-700/50 focus:ring-offset-2 focus:ring-offset-zinc-700; */
|
||||
|
||||
/* Sizing Controls */
|
||||
&.small {
|
||||
@apply h-8 px-4 py-0 text-sm font-normal focus:ring-2;
|
||||
}
|
||||
|
||||
&.large {
|
||||
@apply px-5 py-3;
|
||||
}
|
||||
|
||||
&.secondary {
|
||||
@apply bg-white/10;
|
||||
|
||||
&:disabled {
|
||||
background: transparent !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
@apply cursor-not-allowed;
|
||||
}
|
||||
|
||||
&.square {
|
||||
@apply h-12 w-12 p-0;
|
||||
|
||||
&.small {
|
||||
@apply h-8 w-8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.primary {
|
||||
@apply bg-brand text-blue-50;
|
||||
@apply hover:bg-brand/70 active:bg-brand;
|
||||
/* @apply hover:bg-brand/70 focus:ring-brand/50 focus:ring-opacity-75 active:bg-brand; */
|
||||
|
||||
&.secondary {
|
||||
@apply hover:bg-brand/10 active:bg-brand;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
@apply bg-brand/25;
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
@apply bg-zinc-700 text-zinc-50;
|
||||
@apply hover:bg-zinc-600 active:bg-zinc-600;
|
||||
/* @apply focus:ring-opacity-50 hover:bg-zinc-600 focus:ring-zinc-300 active:bg-zinc-600; */
|
||||
|
||||
&.secondary {
|
||||
@apply hover:bg-zinc-500 active:bg-zinc-500;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
@apply bg-zinc-500/75 text-zinc-200/75;
|
||||
}
|
||||
}
|
||||
|
||||
.danger {
|
||||
@apply bg-red-600 text-zinc-50;
|
||||
@apply hover:bg-red-500 active:bg-red-500;
|
||||
/* @apply focus:ring-opacity-75 hover:bg-red-500 focus:ring-red-400 active:bg-red-500; */
|
||||
|
||||
&.secondary {
|
||||
@apply hover:bg-red-600 active:bg-red-600;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
@apply bg-red-600/75 text-red-50/75;
|
||||
}
|
||||
}
|
||||
@import '../../../assets/tailwind.css'
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button } from '@/components/elements/button/index';
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
|
||||
import { Dialog, RenderDialogProps } from './';
|
||||
|
||||
@@ -13,8 +13,8 @@ const ConfirmationDialog = ({ confirm = 'Okay', children, onConfirmed, ...props
|
||||
<Dialog {...props} description={typeof children === 'string' ? children : undefined}>
|
||||
{typeof children !== 'string' && children}
|
||||
<Dialog.Footer>
|
||||
<Button.Text onClick={props.onClose}>Cancel</Button.Text>
|
||||
<Button.Danger onClick={onConfirmed}>{confirm}</Button.Danger>
|
||||
<ActionButton variant="secondary" onClick={props.onClose}>Cancel</ActionButton>
|
||||
<ActionButton variant="danger" onClick={onConfirmed}>{confirm}</ActionButton>
|
||||
</Dialog.Footer>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import clsx from 'clsx';
|
||||
import { faChevronLeft, faChevronRight } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
|
||||
import { Button } from '@/components/elements/button/index';
|
||||
import ArrowLeft from '@/components/elements/hugeicons/ArrowLeft';
|
||||
import ArrowRight from '@/components/elements/hugeicons/ArrowRight';
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
|
||||
import { PaginationDataSet } from '@/api/http';
|
||||
|
||||
// FIXME: add icons back
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
pagination: PaginationDataSet;
|
||||
@@ -34,13 +32,6 @@ const PaginationFooter = ({ pagination, className, onPageSelect }: Props) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const buttonProps = (page: number) => ({
|
||||
size: Button.Sizes.Small,
|
||||
shape: Button.Shapes.IconSquare,
|
||||
variant: Button.Variants.Secondary,
|
||||
onClick: () => onPageSelect(page),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={clsx('flex items-center justify-between my-2', className)}>
|
||||
<p className={'text-sm text-zinc-500'}>
|
||||
@@ -52,25 +43,54 @@ const PaginationFooter = ({ pagination, className, onPageSelect }: Props) => {
|
||||
</p>
|
||||
{pagination.totalPages > 1 && (
|
||||
<div className={'flex space-x-1'}>
|
||||
<Button.Text {...buttonProps(1)} disabled={pages.previous.length !== 2}>
|
||||
<ArrowLeft fill='currentColor' className={'w-3 h-3'} />
|
||||
</Button.Text>
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onPageSelect(current - 1)}
|
||||
disabled={current <= 1}
|
||||
className="w-8 h-8 p-0 flex items-center justify-center"
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronLeft} className='w-3 h-3' />
|
||||
</ActionButton>
|
||||
{pages.previous.reverse().map((value) => (
|
||||
<Button.Text key={`previous-${value}`} {...buttonProps(value)}>
|
||||
<ActionButton
|
||||
key={`previous-${value}`}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onPageSelect(value)}
|
||||
className="w-8 h-8 p-0 flex items-center justify-center"
|
||||
>
|
||||
{value}
|
||||
</Button.Text>
|
||||
</ActionButton>
|
||||
))}
|
||||
<Button size={Button.Sizes.Small} shape={Button.Shapes.IconSquare}>
|
||||
<ActionButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="w-8 h-8 p-0 flex items-center justify-center"
|
||||
disabled
|
||||
>
|
||||
{current}
|
||||
</Button>
|
||||
</ActionButton>
|
||||
{pages.next.map((value) => (
|
||||
<Button.Text key={`next-${value}`} {...buttonProps(value)}>
|
||||
<ActionButton
|
||||
key={`next-${value}`}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onPageSelect(value)}
|
||||
className="w-8 h-8 p-0 flex items-center justify-center"
|
||||
>
|
||||
{value}
|
||||
</Button.Text>
|
||||
</ActionButton>
|
||||
))}
|
||||
<Button.Text {...buttonProps(total)} disabled={pages.next.length !== 2}>
|
||||
<ArrowRight fill='currentColor' className={'w-3 h-3'} />
|
||||
</Button.Text>
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onPageSelect(current + 1)}
|
||||
disabled={current >= total}
|
||||
className="w-8 h-8 p-0 flex items-center justify-center"
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronRight} className='w-3 h-3' />
|
||||
</ActionButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,11 +3,11 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import ServerContentBlock from '@/components/elements/ServerContentBlock';
|
||||
import { MainPageHeader } from '@/components/elements/MainPageHeader';
|
||||
import Spinner from '@/components/elements/Spinner';
|
||||
import ActivityLogEntry from '@/components/elements/activity/ActivityLogEntry';
|
||||
import { Button } from '@/components/elements/button/index';
|
||||
import PaginationFooter from '@/components/elements/table/PaginationFooter';
|
||||
import { Input } from '@/components/elements/inputs';
|
||||
import Select from '@/components/elements/Select';
|
||||
@@ -25,13 +25,11 @@ const ServerActivityLogContainer = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedEventType, setSelectedEventType] = useState('');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||
const [dateRange, setDateRange] = useState('all');
|
||||
|
||||
const { data, isValidating, error } = useActivityLogs(filters, {
|
||||
revalidateOnMount: true,
|
||||
revalidateOnFocus: false,
|
||||
refreshInterval: autoRefresh ? 30000 : 0, // Auto-refresh every 30 seconds
|
||||
});
|
||||
|
||||
// Extract unique event types for filter dropdown
|
||||
@@ -127,10 +125,6 @@ const ServerActivityLogContainer = () => {
|
||||
e.preventDefault();
|
||||
setShowFilters(!showFilters);
|
||||
break;
|
||||
case 'r':
|
||||
e.preventDefault();
|
||||
setAutoRefresh(!autoRefresh);
|
||||
break;
|
||||
case 'e':
|
||||
e.preventDefault();
|
||||
exportLogs();
|
||||
@@ -141,7 +135,7 @@ const ServerActivityLogContainer = () => {
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [showFilters, autoRefresh]);
|
||||
}, [showFilters]);
|
||||
|
||||
useEffect(() => {
|
||||
setFilters((value) => ({ ...value, filters: { ip: hash.ip, event: hash.event } }));
|
||||
@@ -166,27 +160,18 @@ const ServerActivityLogContainer = () => {
|
||||
>
|
||||
<MainPageHeader title={'Activity Log'}>
|
||||
<div className='flex gap-2 items-center flex-wrap'>
|
||||
<Button.Text
|
||||
variant={Button.Variants.Secondary}
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className='flex items-center gap-2'
|
||||
title='Toggle Filters (Ctrl+F)'
|
||||
>
|
||||
<FontAwesomeIcon icon={faFilter} className='w-4 h-4' />
|
||||
Filters
|
||||
{hasActiveFilters && <span className='w-2 h-2 bg-blue-500 rounded-full'></span>}
|
||||
</Button.Text>
|
||||
<Button.Text
|
||||
variant={autoRefresh ? Button.Variants.Primary : Button.Variants.Secondary}
|
||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||
className='flex items-center gap-2'
|
||||
title='Auto Refresh (Ctrl+R)'
|
||||
>
|
||||
<FontAwesomeIcon icon={autoRefresh ? faTimes : faSearch} className='w-4 h-4' />
|
||||
{autoRefresh ? 'Live' : 'Refresh'}
|
||||
</Button.Text>
|
||||
<Button.Text
|
||||
variant={Button.Variants.Secondary}
|
||||
{hasActiveFilters && <span className='w-2 h-2 bg-brand rounded-full'></span>}
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
onClick={exportLogs}
|
||||
disabled={!filteredData?.items?.length}
|
||||
className='flex items-center gap-2'
|
||||
@@ -194,7 +179,7 @@ const ServerActivityLogContainer = () => {
|
||||
>
|
||||
<FontAwesomeIcon icon={faDownload} className='w-4 h-4' />
|
||||
Export
|
||||
</Button.Text>
|
||||
</ActionButton>
|
||||
</div>
|
||||
</MainPageHeader>
|
||||
</div>
|
||||
@@ -239,7 +224,7 @@ const ServerActivityLogContainer = () => {
|
||||
<Select
|
||||
value={selectedEventType}
|
||||
onChange={(e) => setSelectedEventType(e.target.value)}
|
||||
className='w-full px-3 py-2 bg-zinc-800 border border-zinc-600 rounded-lg text-zinc-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 hover:border-zinc-500 transition-colors duration-150'
|
||||
className='w-full px-3 py-2 bg-zinc-800 border border-zinc-600 rounded-lg text-zinc-100 focus:border-brand focus:ring-1 focus:ring-brand hover:border-zinc-500 transition-colors duration-150'
|
||||
>
|
||||
<option value='' style={{ backgroundColor: '#27272a', color: '#f4f4f5' }}>All Events</option>
|
||||
{eventTypes.map(type => (
|
||||
@@ -253,7 +238,7 @@ const ServerActivityLogContainer = () => {
|
||||
<Select
|
||||
value={dateRange}
|
||||
onChange={(e) => setDateRange(e.target.value)}
|
||||
className='w-full px-3 py-2 bg-zinc-800 border border-zinc-600 rounded-lg text-zinc-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 hover:border-zinc-500 transition-colors duration-150'
|
||||
className='w-full px-3 py-2 bg-zinc-800 border border-zinc-600 rounded-lg text-zinc-100 focus:border-brand focus:ring-1 focus:ring-brand hover:border-zinc-500 transition-colors duration-150'
|
||||
>
|
||||
<option value='all' style={{ backgroundColor: '#27272a', color: '#f4f4f5' }}>All Time</option>
|
||||
<option value='1h' style={{ backgroundColor: '#27272a', color: '#f4f4f5' }}>Last Hour</option>
|
||||
@@ -265,14 +250,14 @@ const ServerActivityLogContainer = () => {
|
||||
|
||||
<div className='flex items-end'>
|
||||
{hasActiveFilters && (
|
||||
<Button.Text
|
||||
variant={Button.Variants.Secondary}
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
onClick={clearAllFilters}
|
||||
className='flex items-center gap-2 w-full'
|
||||
>
|
||||
<FontAwesomeIcon icon={faTimes} className='w-4 h-4' />
|
||||
Clear All Filters
|
||||
</Button.Text>
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -293,7 +278,7 @@ const ServerActivityLogContainer = () => {
|
||||
<div className='w-5 h-5 rounded-lg bg-[#ffffff11] flex items-center justify-center'>
|
||||
<FontAwesomeIcon icon={faHistory} className='w-2.5 h-2.5 text-zinc-400' />
|
||||
</div>
|
||||
<h3 className='text-base font-semibold text-zinc-100'>Server Activity Events</h3>
|
||||
<h3 className='text-base font-semibold text-zinc-100'>Events</h3>
|
||||
{filteredData?.items && (
|
||||
<span className='text-sm text-zinc-400'>
|
||||
({filteredData.items.length} {filteredData.items.length === 1 ? 'event' : 'events'})
|
||||
@@ -317,18 +302,18 @@ const ServerActivityLogContainer = () => {
|
||||
</p>
|
||||
{hasActiveFilters && (
|
||||
<div className='flex gap-2 justify-center'>
|
||||
<Button.Text
|
||||
variant={Button.Variants.Secondary}
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
onClick={clearAllFilters}
|
||||
>
|
||||
Clear All Filters
|
||||
</Button.Text>
|
||||
<Button.Text
|
||||
variant={Button.Variants.Secondary}
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
onClick={() => setShowFilters(true)}
|
||||
>
|
||||
Adjust Filters
|
||||
</Button.Text>
|
||||
</ActionButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import { Form, Formik, Field as FormikField, FormikHelpers, useFormikContext } from 'formik';
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
import { boolean, object, string } from 'yup';
|
||||
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import Can from '@/components/elements/Can';
|
||||
import Field from '@/components/elements/Field';
|
||||
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
|
||||
import FormikSwitchV2 from '@/components/elements/FormikSwitchV2';
|
||||
import { Textarea } from '@/components/elements/Input';
|
||||
import { MainPageHeader } from '@/components/elements/MainPageHeader';
|
||||
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
|
||||
import Pagination from '@/components/elements/Pagination';
|
||||
import ServerContentBlock from '@/components/elements/ServerContentBlock';
|
||||
import { PageListContainer } from '@/components/elements/pages/PageList';
|
||||
import BackupRow from '@/components/server/backups/BackupRow';
|
||||
import CreateBackupButton from '@/components/server/backups/CreateBackupButton';
|
||||
|
||||
import createServerBackup from '@/api/server/backups/createServerBackup';
|
||||
|
||||
import getServerBackups, { Context as ServerBackupContext } from '@/api/swr/getServerBackups';
|
||||
|
||||
@@ -15,13 +24,111 @@ import { ServerContext } from '@/state/server';
|
||||
|
||||
import useFlash from '@/plugins/useFlash';
|
||||
|
||||
interface BackupValues {
|
||||
name: string;
|
||||
ignored: string;
|
||||
isLocked: boolean;
|
||||
}
|
||||
|
||||
const ModalContent = ({ ...props }: RequiredModalProps) => {
|
||||
const { isSubmitting } = useFormikContext<BackupValues>();
|
||||
|
||||
return (
|
||||
<Modal {...props} showSpinnerOverlay={isSubmitting} title='Create server backup'>
|
||||
<Form>
|
||||
<FlashMessageRender byKey={'backups:create'} />
|
||||
<Field
|
||||
name={'name'}
|
||||
label={'Backup name'}
|
||||
description={'If provided, the name that should be used to reference this backup.'}
|
||||
/>
|
||||
<div className={`mt-6 flex flex-col`}>
|
||||
<FormikFieldWrapper
|
||||
className='flex flex-col gap-2'
|
||||
name={'ignored'}
|
||||
label={'Ignored Files & Directories'}
|
||||
description={`
|
||||
Enter the files or folders to ignore while generating this backup. Leave blank to use
|
||||
the contents of the .pteroignore file in the root of the server directory if present.
|
||||
Wildcard matching of files and folders is supported in addition to negating a rule by
|
||||
prefixing the path with an exclamation point.
|
||||
`}
|
||||
>
|
||||
<FormikField
|
||||
as={Textarea}
|
||||
className='px-4 py-2 rounded-lg outline-hidden bg-[#ffffff17] text-sm'
|
||||
name={'ignored'}
|
||||
rows={6}
|
||||
/>
|
||||
</FormikFieldWrapper>
|
||||
</div>
|
||||
<Can action={'backup.delete'}>
|
||||
<div className={`my-6`}>
|
||||
<FormikSwitchV2
|
||||
name={'isLocked'}
|
||||
label={'Locked'}
|
||||
description={'Prevents this backup from being deleted until explicitly unlocked.'}
|
||||
/>
|
||||
</div>
|
||||
</Can>
|
||||
<div className={`flex justify-end mb-6`}>
|
||||
<ActionButton variant="primary" type={'submit'} disabled={isSubmitting}>
|
||||
Start backup
|
||||
</ActionButton>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const BackupContainer = () => {
|
||||
const { page, setPage } = useContext(ServerBackupContext);
|
||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||
const { data: backups, error, isValidating } = getServerBackups();
|
||||
const { data: backups, error, isValidating, mutate } = getServerBackups();
|
||||
const [createModalVisible, setCreateModalVisible] = useState(false);
|
||||
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
const backupLimit = ServerContext.useStoreState((state) => state.server.data!.featureLimits.backups);
|
||||
|
||||
const hasBackupsInProgress = backups?.items.some(backup => backup.completedAt === null) || false;
|
||||
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout;
|
||||
|
||||
if (hasBackupsInProgress) {
|
||||
interval = setInterval(() => {
|
||||
mutate();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
};
|
||||
}, [hasBackupsInProgress, mutate]);
|
||||
|
||||
useEffect(() => {
|
||||
clearFlashes('backups:create');
|
||||
}, [createModalVisible]);
|
||||
|
||||
const submitBackup = (values: BackupValues, { setSubmitting }: FormikHelpers<BackupValues>) => {
|
||||
clearFlashes('backups:create');
|
||||
createServerBackup(uuid, values)
|
||||
.then(async (backup) => {
|
||||
await mutate(
|
||||
(data) => ({ ...data!, items: data!.items.concat(backup), backupCount: data!.backupCount + 1 }),
|
||||
false,
|
||||
);
|
||||
setSubmitting(false);
|
||||
setCreateModalVisible(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
clearAndAddHttpError({ key: 'backups:create', error });
|
||||
setSubmitting(false);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!error) {
|
||||
clearFlashes('backups');
|
||||
@@ -50,16 +157,34 @@ const BackupContainer = () => {
|
||||
<MainPageHeader title={'Backups'}>
|
||||
<Can action={'backup.create'}>
|
||||
<div className='flex flex-col sm:flex-row items-center justify-end gap-4'>
|
||||
{backupLimit > 0 && backups.backupCount > 0 && (
|
||||
{backupLimit > 0 && (
|
||||
<p className='text-sm text-zinc-300 text-center sm:text-right'>
|
||||
{backups.backupCount} of {backupLimit} backups
|
||||
</p>
|
||||
)}
|
||||
{backupLimit > 0 && backupLimit > backups.backupCount && <CreateBackupButton />}
|
||||
{backupLimit > 0 && backupLimit > backups.backupCount && (
|
||||
<ActionButton variant='primary' onClick={() => setCreateModalVisible(true)}>
|
||||
New Backup
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
</Can>
|
||||
</MainPageHeader>
|
||||
|
||||
{createModalVisible && (
|
||||
<Formik
|
||||
onSubmit={submitBackup}
|
||||
initialValues={{ name: '', ignored: '', isLocked: false }}
|
||||
validationSchema={object().shape({
|
||||
name: string().max(191),
|
||||
ignored: string(),
|
||||
isLocked: boolean(),
|
||||
})}
|
||||
>
|
||||
<ModalContent visible={createModalVisible} onDismissed={() => setCreateModalVisible(false)} />
|
||||
</Formik>
|
||||
)}
|
||||
|
||||
<Pagination data={backups} onPageSelect={setPage}>
|
||||
{({ items }) =>
|
||||
!items.length ? (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import Can from '@/components/elements/Can';
|
||||
import Input from '@/components/elements/Input';
|
||||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||
@@ -162,60 +163,65 @@ const BackupContextMenu = ({ backup }: Props) => {
|
||||
{backup.isSuccessful ? (
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Can action={'backup.download'}>
|
||||
<button
|
||||
type='button'
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={doDownload}
|
||||
disabled={loading}
|
||||
className='flex items-center justify-center gap-2 px-3 py-2 bg-[#ffffff11] hover:bg-[#ffffff19] rounded-lg text-sm text-zinc-300 hover:text-zinc-100 transition-colors duration-150 disabled:opacity-50'
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<HugeIconsFileDownload className='h-4 w-4' fill='currentColor' />
|
||||
<span className='hidden sm:inline'>Download</span>
|
||||
</button>
|
||||
</ActionButton>
|
||||
</Can>
|
||||
<Can action={'backup.restore'}>
|
||||
<button
|
||||
type='button'
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setModal('restore')}
|
||||
disabled={loading}
|
||||
className='flex items-center justify-center gap-2 px-3 py-2 bg-[#ffffff11] hover:bg-[#ffffff19] rounded-lg text-sm text-zinc-300 hover:text-zinc-100 transition-colors duration-150 disabled:opacity-50'
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<HugeIconsCloudUp className='h-4 w-4' fill='currentColor' />
|
||||
<span className='hidden sm:inline'>Restore</span>
|
||||
</button>
|
||||
</ActionButton>
|
||||
</Can>
|
||||
<Can action={'backup.delete'}>
|
||||
<button
|
||||
type='button'
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onLockToggle}
|
||||
disabled={loading}
|
||||
className='flex items-center justify-center gap-2 px-3 py-2 bg-[#ffffff11] hover:bg-[#ffffff19] rounded-lg text-sm text-zinc-300 hover:text-zinc-100 transition-colors duration-150 disabled:opacity-50'
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<HugeIconsFileSecurity className='h-4 w-4' fill='currentColor' />
|
||||
<span className='hidden sm:inline'>{backup.isLocked ? 'Unlock' : 'Lock'}</span>
|
||||
</button>
|
||||
</ActionButton>
|
||||
{!backup.isLocked && (
|
||||
<button
|
||||
type='button'
|
||||
<ActionButton
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => setModal('delete')}
|
||||
disabled={loading}
|
||||
className='flex items-center justify-center gap-2 px-3 py-2 bg-[#ffffff11] hover:bg-red-600/20 rounded-lg text-sm text-zinc-300 hover:text-red-400 transition-colors duration-150 disabled:opacity-50'
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<HugeIconsDelete className='h-4 w-4' fill='currentColor' />
|
||||
<span className='hidden sm:inline'>Delete</span>
|
||||
</button>
|
||||
</ActionButton>
|
||||
)}
|
||||
</Can>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type='button'
|
||||
<ActionButton
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => setModal('delete')}
|
||||
disabled={loading}
|
||||
className='flex items-center justify-center gap-2 px-3 py-2 bg-[#ffffff11] hover:bg-red-600/20 rounded-lg text-sm text-zinc-300 hover:text-red-400 transition-colors duration-150 disabled:opacity-50'
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<HugeIconsDelete className='h-4 w-4' fill='currentColor' />
|
||||
<span className='hidden sm:inline'>Delete</span>
|
||||
</button>
|
||||
</ActionButton>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
import { Form, Formik, Field as FormikField, FormikHelpers, useFormikContext } from 'formik';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { boolean, object, string } from 'yup';
|
||||
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import Can from '@/components/elements/Can';
|
||||
import Field from '@/components/elements/Field';
|
||||
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
|
||||
import FormikSwitchV2 from '@/components/elements/FormikSwitchV2';
|
||||
import { Textarea } from '@/components/elements/Input';
|
||||
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
|
||||
import { Button } from '@/components/elements/button/index';
|
||||
|
||||
import createServerBackup from '@/api/server/backups/createServerBackup';
|
||||
import getServerBackups from '@/api/swr/getServerBackups';
|
||||
|
||||
import { ServerContext } from '@/state/server';
|
||||
|
||||
import useFlash from '@/plugins/useFlash';
|
||||
|
||||
interface Values {
|
||||
name: string;
|
||||
ignored: string;
|
||||
isLocked: boolean;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const ModalContent = ({ ...props }: RequiredModalProps) => {
|
||||
const { isSubmitting } = useFormikContext<Values>();
|
||||
|
||||
return (
|
||||
<Modal {...props} showSpinnerOverlay={isSubmitting} title='Create server backup'>
|
||||
<Form>
|
||||
<FlashMessageRender byKey={'backups:create'} />
|
||||
<Field
|
||||
name={'name'}
|
||||
label={'Backup name'}
|
||||
description={'If provided, the name that should be used to reference this backup.'}
|
||||
/>
|
||||
<div className={`mt-6 flex flex-col`}>
|
||||
<FormikFieldWrapper
|
||||
className='flex flex-col gap-2'
|
||||
name={'ignored'}
|
||||
label={'Ignored Files & Directories'}
|
||||
description={`
|
||||
Enter the files or folders to ignore while generating this backup. Leave blank to use
|
||||
the contents of the .pteroignore file in the root of the server directory if present.
|
||||
Wildcard matching of files and folders is supported in addition to negating a rule by
|
||||
prefixing the path with an exclamation point.
|
||||
`}
|
||||
>
|
||||
<FormikField
|
||||
as={Textarea}
|
||||
className='px-4 py-2 rounded-lg outline-hidden bg-[#ffffff17] text-sm'
|
||||
name={'ignored'}
|
||||
rows={6}
|
||||
/>
|
||||
</FormikFieldWrapper>
|
||||
</div>
|
||||
<Can action={'backup.delete'}>
|
||||
<div className={`my-6`}>
|
||||
<FormikSwitchV2
|
||||
name={'isLocked'}
|
||||
label={'Locked'}
|
||||
description={'Prevents this backup from being deleted until explicitly unlocked.'}
|
||||
/>
|
||||
</div>
|
||||
</Can>
|
||||
<div className={`flex justify-end mb-6`}>
|
||||
<Button role={'switch'} type={'submit'} disabled={isSubmitting}>
|
||||
Start backup
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const CreateBackupButton = () => {
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const { mutate } = getServerBackups();
|
||||
|
||||
useEffect(() => {
|
||||
clearFlashes('backups:create');
|
||||
}, [visible]);
|
||||
|
||||
const submit = (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
|
||||
clearFlashes('backups:create');
|
||||
createServerBackup(uuid, values)
|
||||
.then(async (backup) => {
|
||||
await mutate(
|
||||
(data) => ({ ...data!, items: data!.items.concat(backup), backupCount: data!.backupCount + 1 }),
|
||||
false,
|
||||
);
|
||||
setSubmitting(false);
|
||||
setVisible(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
clearAndAddHttpError({ key: 'backups:create', error });
|
||||
setSubmitting(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{visible && (
|
||||
<Formik
|
||||
onSubmit={submit}
|
||||
initialValues={{ name: '', ignored: '', isLocked: false }}
|
||||
validationSchema={object().shape({
|
||||
name: string().max(191),
|
||||
ignored: string(),
|
||||
isLocked: boolean(),
|
||||
})}
|
||||
>
|
||||
<ModalContent appear visible={visible} onDismissed={() => setVisible(false)} />
|
||||
</Formik>
|
||||
)}
|
||||
<button
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(124.75% 124.75% at 50.01% -10.55%, rgb(36, 36, 36) 0%, rgb(20, 20, 20) 100%)',
|
||||
}}
|
||||
className='px-8 py-3 border-[1px] border-[#ffffff12] rounded-full text-sm font-bold shadow-md cursor-pointer hover:bg-[#ffffff11] transition-colors duration-150'
|
||||
onClick={() => setVisible(true)}
|
||||
>
|
||||
New Backup
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateBackupButton;
|
||||
@@ -22,7 +22,7 @@ export type PowerAction = 'start' | 'stop' | 'restart' | 'kill';
|
||||
|
||||
const ServerConsoleContainer = () => {
|
||||
const name = ServerContext.useStoreState((state) => state.server.data!.name);
|
||||
// const description = ServerContext.useStoreState((state) => state.server.data!.description);
|
||||
const description = ServerContext.useStoreState((state) => state.server.data!.description);
|
||||
const isInstalling = ServerContext.useStoreState((state) => state.server.isInstalling);
|
||||
const isTransferring = ServerContext.useStoreState((state) => state.server.data!.isTransferring);
|
||||
const eggFeatures = ServerContext.useStoreState((state) => state.server.data!.eggFeatures, isEqual);
|
||||
@@ -72,6 +72,21 @@ const ServerConsoleContainer = () => {
|
||||
</MainPageHeader>
|
||||
</div>
|
||||
|
||||
{description && (
|
||||
<div
|
||||
className='transform-gpu skeleton-anim-2 mb-3 sm:mb-4'
|
||||
style={{
|
||||
animationDelay: '100ms',
|
||||
animationTimingFunction:
|
||||
'linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)',
|
||||
}}
|
||||
>
|
||||
<div className='bg-gradient-to-b from-[#ffffff08] to-[#ffffff05] border-[1px] border-[#ffffff12] rounded-xl p-3 sm:p-4 hover:border-[#ffffff20] transition-all duration-150 shadow-sm'>
|
||||
<p className='text-sm text-zinc-300 leading-relaxed'>{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='flex flex-col gap-3 sm:gap-4'>
|
||||
<div
|
||||
className='transform-gpu skeleton-anim-2'
|
||||
@@ -82,15 +97,6 @@ const ServerConsoleContainer = () => {
|
||||
}}
|
||||
>
|
||||
<div className='bg-gradient-to-b from-[#ffffff08] to-[#ffffff05] border-[1px] border-[#ffffff12] rounded-xl p-3 sm:p-4 hover:border-[#ffffff20] transition-all duration-150 shadow-sm'>
|
||||
<div className='flex items-center gap-2 sm:gap-3 mb-3 sm:mb-4'>
|
||||
<div className='w-5 h-5 sm:w-6 sm:h-6 rounded-lg bg-[#ffffff11] flex items-center justify-center'>
|
||||
<FontAwesomeIcon
|
||||
icon={faServer}
|
||||
className='w-2.5 h-2.5 sm:w-3 sm:h-3 text-zinc-400'
|
||||
/>
|
||||
</div>
|
||||
<h3 className='text-sm sm:text-base font-semibold text-zinc-100'>Server Resources</h3>
|
||||
</div>
|
||||
<ServerDetailsBlock />
|
||||
</div>
|
||||
</div>
|
||||
@@ -104,15 +110,6 @@ const ServerConsoleContainer = () => {
|
||||
}}
|
||||
>
|
||||
<div className='bg-gradient-to-b from-[#ffffff08] to-[#ffffff05] border-[1px] border-[#ffffff12] rounded-xl p-3 sm:p-4 hover:border-[#ffffff20] transition-all duration-150 shadow-sm'>
|
||||
<div className='flex items-center gap-2 sm:gap-3 mb-3 sm:mb-4'>
|
||||
<div className='w-5 h-5 sm:w-6 sm:h-6 rounded-lg bg-[#ffffff11] flex items-center justify-center'>
|
||||
<FontAwesomeIcon
|
||||
icon={faTerminal}
|
||||
className='w-2.5 h-2.5 sm:w-3 sm:h-3 text-zinc-400'
|
||||
/>
|
||||
</div>
|
||||
<h3 className='text-sm sm:text-base font-semibold text-zinc-100'>Console</h3>
|
||||
</div>
|
||||
<Console />
|
||||
</div>
|
||||
</div>
|
||||
@@ -126,17 +123,6 @@ const ServerConsoleContainer = () => {
|
||||
}}
|
||||
>
|
||||
<div className='bg-gradient-to-b from-[#ffffff08] to-[#ffffff05] border-[1px] border-[#ffffff12] rounded-xl p-3 sm:p-4 hover:border-[#ffffff20] transition-all duration-150 shadow-sm'>
|
||||
<div className='flex items-center gap-2 sm:gap-3 mb-3 sm:mb-4'>
|
||||
<div className='w-5 h-5 sm:w-6 sm:h-6 rounded-lg bg-[#ffffff11] flex items-center justify-center'>
|
||||
<FontAwesomeIcon
|
||||
icon={faChartBar}
|
||||
className='w-2.5 h-2.5 sm:w-3 sm:h-3 text-zinc-400'
|
||||
/>
|
||||
</div>
|
||||
<h3 className='text-sm sm:text-base font-semibold text-zinc-100'>
|
||||
Performance Metrics
|
||||
</h3>
|
||||
</div>
|
||||
<div className={'grid grid-cols-1 md:grid-cols-3 gap-3 sm:gap-4'}>
|
||||
<Spinner.Suspense>
|
||||
<StatGraphs />
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import { Form, Formik, FormikHelpers } from 'formik';
|
||||
import { useState } from 'react';
|
||||
import { object, string } from 'yup';
|
||||
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import Field from '@/components/elements/Field';
|
||||
import Modal from '@/components/elements/Modal';
|
||||
import { Button } from '@/components/elements/button/index';
|
||||
|
||||
import { httpErrorToHuman } from '@/api/http';
|
||||
import createServerDatabase from '@/api/server/databases/createServerDatabase';
|
||||
|
||||
import { ServerContext } from '@/state/server';
|
||||
|
||||
import useFlash from '@/plugins/useFlash';
|
||||
|
||||
interface Values {
|
||||
databaseName: string;
|
||||
connectionsFrom: string;
|
||||
}
|
||||
|
||||
const schema = object().shape({
|
||||
databaseName: string()
|
||||
.required('A database name must be provided.')
|
||||
.min(3, 'Database name must be at least 3 characters.')
|
||||
.max(48, 'Database name must not exceed 48 characters.')
|
||||
.matches(
|
||||
/^[\w\-.]{3,48}$/,
|
||||
'Database name should only contain alphanumeric characters, underscores, dashes, and/or periods.',
|
||||
),
|
||||
connectionsFrom: string().matches(/^[\w\-/.%:]+$/, 'A valid host address must be provided.'),
|
||||
});
|
||||
|
||||
const CreateDatabaseButton = () => {
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
const { addError, clearFlashes } = useFlash();
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
const appendDatabase = ServerContext.useStoreActions((actions) => actions.databases.appendDatabase);
|
||||
|
||||
const submit = (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
|
||||
clearFlashes('database:create');
|
||||
createServerDatabase(uuid, {
|
||||
databaseName: values.databaseName,
|
||||
connectionsFrom: values.connectionsFrom || '%',
|
||||
})
|
||||
.then((database) => {
|
||||
appendDatabase(database);
|
||||
setSubmitting(false);
|
||||
setVisible(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
addError({ key: 'database:create', message: httpErrorToHuman(error) });
|
||||
setSubmitting(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Formik
|
||||
onSubmit={submit}
|
||||
initialValues={{ databaseName: '', connectionsFrom: '' }}
|
||||
validationSchema={schema}
|
||||
>
|
||||
{({ isSubmitting, resetForm }) => (
|
||||
<Modal
|
||||
visible={visible}
|
||||
dismissable={!isSubmitting}
|
||||
showSpinnerOverlay={isSubmitting}
|
||||
onDismissed={() => {
|
||||
resetForm();
|
||||
setVisible(false);
|
||||
}}
|
||||
title='Create new database'
|
||||
>
|
||||
<div className='flex flex-col'>
|
||||
<FlashMessageRender byKey={'database:create'} />
|
||||
<Form>
|
||||
<Field
|
||||
type={'string'}
|
||||
id={'database_name'}
|
||||
name={'databaseName'}
|
||||
label={'Database Name'}
|
||||
description={'A descriptive name for your database instance.'}
|
||||
/>
|
||||
<div className={`mt-6`}>
|
||||
<Field
|
||||
type={'string'}
|
||||
id={'connections_from'}
|
||||
name={'connectionsFrom'}
|
||||
label={'Connections From'}
|
||||
description={
|
||||
'Where connections should be allowed from. Leave blank to allow connections from anywhere.'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className={`flex gap-3 justify-end my-6`}>
|
||||
<Button type={'submit'}>Create Database</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</Formik>
|
||||
<button
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(124.75% 124.75% at 50.01% -10.55%, rgb(36, 36, 36) 0%, rgb(20, 20, 20) 100%)',
|
||||
}}
|
||||
className='px-8 py-3 border-[1px] border-[#ffffff12] rounded-full text-sm font-bold shadow-md cursor-pointer hover:bg-[#ffffff11] transition-colors duration-150'
|
||||
onClick={() => setVisible(true)}
|
||||
>
|
||||
New Database
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateDatabaseButton;
|
||||
@@ -7,12 +7,12 @@ import styled from 'styled-components';
|
||||
import { object, string } from 'yup';
|
||||
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import Can from '@/components/elements/Can';
|
||||
import CopyOnClick from '@/components/elements/CopyOnClick';
|
||||
import Field from '@/components/elements/Field';
|
||||
import Input from '@/components/elements/Input';
|
||||
import Modal from '@/components/elements/Modal';
|
||||
import { Button } from '@/components/elements/button/index';
|
||||
import RotatePasswordButton from '@/components/server/databases/RotatePasswordButton';
|
||||
|
||||
import { httpErrorToHuman } from '@/api/http';
|
||||
@@ -95,9 +95,9 @@ const DatabaseRow = ({ database }: Props) => {
|
||||
label={'Confirm Database Name'}
|
||||
description={'Enter the database name to confirm deletion.'}
|
||||
/>
|
||||
<Button type={'submit'} color={'red'} className='min-w-full my-6' disabled={!isValid}>
|
||||
<ActionButton variant="danger" type={'submit'} className='min-w-full my-6' disabled={!isValid}>
|
||||
Delete Database
|
||||
</Button>
|
||||
</ActionButton>
|
||||
</Form>
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -198,23 +198,25 @@ const DatabaseRow = ({ database }: Props) => {
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-2 sm:flex-col sm:gap-3'>
|
||||
<button
|
||||
type='button'
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setConnectionVisible(true)}
|
||||
className='flex items-center justify-center gap-2 px-3 py-2 bg-[#ffffff11] hover:bg-[#ffffff19] rounded-lg text-sm text-zinc-300 hover:text-zinc-100 transition-colors duration-150'
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEye} className='w-4 h-4' />
|
||||
<span className='hidden sm:inline'>Details</span>
|
||||
</button>
|
||||
</ActionButton>
|
||||
<Can action={'database.delete'}>
|
||||
<button
|
||||
type='button'
|
||||
<ActionButton
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => setVisible(true)}
|
||||
className='flex items-center justify-center gap-2 px-3 py-2 bg-[#ffffff11] hover:bg-red-600/20 rounded-lg text-sm text-zinc-300 hover:text-red-400 transition-colors duration-150'
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrashAlt} className='w-4 h-4' />
|
||||
<span className='hidden sm:inline'>Delete</span>
|
||||
</button>
|
||||
</ActionButton>
|
||||
</Can>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { For } from 'million/react';
|
||||
import { Form, Formik, FormikHelpers } from 'formik';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { object, string } from 'yup';
|
||||
import { faDatabase } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import Can from '@/components/elements/Can';
|
||||
import Field from '@/components/elements/Field';
|
||||
import { MainPageHeader } from '@/components/elements/MainPageHeader';
|
||||
import Modal from '@/components/elements/Modal';
|
||||
import ServerContentBlock from '@/components/elements/ServerContentBlock';
|
||||
import { PageListContainer, PageListItem } from '@/components/elements/pages/PageList';
|
||||
import CreateDatabaseButton from '@/components/server/databases/CreateDatabaseButton';
|
||||
import DatabaseRow from '@/components/server/databases/DatabaseRow';
|
||||
|
||||
import { httpErrorToHuman } from '@/api/http';
|
||||
import createServerDatabase from '@/api/server/databases/createServerDatabase';
|
||||
import getServerDatabases from '@/api/server/databases/getServerDatabases';
|
||||
|
||||
import { ServerContext } from '@/state/server';
|
||||
@@ -17,15 +24,51 @@ import { ServerContext } from '@/state/server';
|
||||
import { useDeepMemoize } from '@/plugins/useDeepMemoize';
|
||||
import useFlash from '@/plugins/useFlash';
|
||||
|
||||
interface DatabaseValues {
|
||||
databaseName: string;
|
||||
connectionsFrom: string;
|
||||
}
|
||||
|
||||
const databaseSchema = object().shape({
|
||||
databaseName: string()
|
||||
.required('A database name must be provided.')
|
||||
.min(3, 'Database name must be at least 3 characters.')
|
||||
.max(48, 'Database name must not exceed 48 characters.')
|
||||
.matches(
|
||||
/^[\w\-.]{3,48}$/,
|
||||
'Database name should only contain alphanumeric characters, underscores, dashes, and/or periods.',
|
||||
),
|
||||
connectionsFrom: string().matches(/^[\w\-/.%:]+$/, 'A valid host address must be provided.'),
|
||||
});
|
||||
|
||||
const DatabasesContainer = () => {
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
const databaseLimit = ServerContext.useStoreState((state) => state.server.data!.featureLimits.databases);
|
||||
|
||||
const { addError, clearFlashes } = useFlash();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [createModalVisible, setCreateModalVisible] = useState(false);
|
||||
|
||||
const databases = useDeepMemoize(ServerContext.useStoreState((state) => state.databases.data));
|
||||
const setDatabases = ServerContext.useStoreActions((state) => state.databases.setDatabases);
|
||||
const appendDatabase = ServerContext.useStoreActions((actions) => actions.databases.appendDatabase);
|
||||
|
||||
const submitDatabase = (values: DatabaseValues, { setSubmitting }: FormikHelpers<DatabaseValues>) => {
|
||||
clearFlashes('database:create');
|
||||
createServerDatabase(uuid, {
|
||||
databaseName: values.databaseName,
|
||||
connectionsFrom: values.connectionsFrom || '%',
|
||||
})
|
||||
.then((database) => {
|
||||
appendDatabase(database);
|
||||
setSubmitting(false);
|
||||
setCreateModalVisible(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
addError({ key: 'database:create', message: httpErrorToHuman(error) });
|
||||
setSubmitting(false);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(!databases.length);
|
||||
@@ -46,15 +89,65 @@ const DatabasesContainer = () => {
|
||||
<MainPageHeader title={'Databases'}>
|
||||
<Can action={'database.create'}>
|
||||
<div className='flex flex-col sm:flex-row items-center justify-end gap-4'>
|
||||
{databaseLimit > 0 && databases.length > 0 && (
|
||||
{databaseLimit > 0 && (
|
||||
<p className='text-sm text-zinc-300 text-center sm:text-right'>
|
||||
{databases.length} of {databaseLimit} databases
|
||||
</p>
|
||||
)}
|
||||
{databaseLimit > 0 && databaseLimit !== databases.length && <CreateDatabaseButton />}
|
||||
{databaseLimit > 0 && databaseLimit !== databases.length && (
|
||||
<ActionButton variant='primary' onClick={() => setCreateModalVisible(true)}>
|
||||
New Database
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
</Can>
|
||||
</MainPageHeader>
|
||||
|
||||
<Formik
|
||||
onSubmit={submitDatabase}
|
||||
initialValues={{ databaseName: '', connectionsFrom: '' }}
|
||||
validationSchema={databaseSchema}
|
||||
>
|
||||
{({ isSubmitting, resetForm }) => (
|
||||
<Modal
|
||||
visible={createModalVisible}
|
||||
dismissable={!isSubmitting}
|
||||
showSpinnerOverlay={isSubmitting}
|
||||
onDismissed={() => {
|
||||
resetForm();
|
||||
setCreateModalVisible(false);
|
||||
}}
|
||||
title='Create new database'
|
||||
>
|
||||
<div className='flex flex-col'>
|
||||
<FlashMessageRender byKey={'database:create'} />
|
||||
<Form>
|
||||
<Field
|
||||
type={'string'}
|
||||
id={'database_name'}
|
||||
name={'databaseName'}
|
||||
label={'Database Name'}
|
||||
description={'A descriptive name for your database instance.'}
|
||||
/>
|
||||
<div className={`mt-6`}>
|
||||
<Field
|
||||
type={'string'}
|
||||
id={'connections_from'}
|
||||
name={'connectionsFrom'}
|
||||
label={'Connections From'}
|
||||
description={
|
||||
'Where connections should be allowed from. Leave blank to allow connections from anywhere.'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className={`flex gap-3 justify-end my-6`}>
|
||||
<ActionButton variant="primary" type={'submit'}>Create Database</ActionButton>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</Formik>
|
||||
|
||||
{!databases.length && loading ? (
|
||||
<div className='flex items-center justify-center py-12'>
|
||||
@@ -70,13 +163,7 @@ const DatabasesContainer = () => {
|
||||
<div className='flex flex-col items-center justify-center py-12 px-4'>
|
||||
<div className='text-center'>
|
||||
<div className='w-16 h-16 mx-auto mb-4 rounded-full bg-[#ffffff11] flex items-center justify-center'>
|
||||
<svg className='w-8 h-8 text-zinc-400' fill='currentColor' viewBox='0 0 20 20'>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
d='M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zm0 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V8zm0 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1v-2z'
|
||||
clipRule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
<FontAwesomeIcon icon={faDatabase} className='w-8 h-8 text-zinc-400' />
|
||||
</div>
|
||||
<h3 className='text-lg font-medium text-zinc-200 mb-2'>
|
||||
{databaseLimit > 0 ? 'No databases found' : 'Databases unavailable'}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Actions, useStoreActions } from 'easy-peasy';
|
||||
import { useState } from 'react';
|
||||
|
||||
import Spinner from '@/components/elements/Spinner';
|
||||
import { Button } from '@/components/elements/button/index';
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
|
||||
import { httpErrorToHuman } from '@/api/http';
|
||||
import { ServerDatabase } from '@/api/server/databases/getServerDatabases';
|
||||
@@ -51,12 +51,12 @@ const RotatePasswordButton = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Button onClick={rotate} className='flex-none'>
|
||||
<ActionButton onClick={rotate} className='flex-none'>
|
||||
<div className='flex justify-center items-center h-4 w-4'>
|
||||
{!loading && <FontAwesomeIcon icon={faRotateRight}></FontAwesomeIcon>}
|
||||
{loading && <Spinner size={'small'} />}
|
||||
</div>
|
||||
</Button>
|
||||
</ActionButton>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect, useState } from 'react';
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import Field from '@/components/elements/Field';
|
||||
import Modal from '@/components/elements/Modal';
|
||||
import { Button } from '@/components/elements/button/index';
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import { SocketEvent, SocketRequest } from '@/components/server/events';
|
||||
|
||||
import updateStartupVariable from '@/api/server/updateStartupVariable';
|
||||
@@ -93,7 +93,7 @@ const GSLTokenModalFeature = () => {
|
||||
/>
|
||||
</div>
|
||||
<div className={`my-6 sm:flex items-center justify-end`}>
|
||||
<Button type={'submit'}>Update GSL Token</Button>
|
||||
<ActionButton variant="primary" type={'submit'}>Update GSL Token</ActionButton>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from '@/components/elements/DropdownMenu';
|
||||
import Modal from '@/components/elements/Modal';
|
||||
import Spinner from '@/components/elements/Spinner';
|
||||
import { Button } from '@/components/elements/button/index';
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import HugeIconsArrowDown from '@/components/elements/hugeicons/ArrowDown';
|
||||
import HugeIconsArrowUp from '@/components/elements/hugeicons/ArrowUp';
|
||||
import { SocketEvent, SocketRequest } from '@/components/server/events';
|
||||
@@ -137,9 +137,9 @@ const JavaVersionModalFeature = () => {
|
||||
Cancel
|
||||
</Button> */}
|
||||
<Can action={'startup.docker-image'}>
|
||||
<Button onClick={updateJava} className={`w-full sm:w-auto`}>
|
||||
<ActionButton variant="primary" onClick={updateJava} className={`w-full sm:w-auto`}>
|
||||
Update
|
||||
</Button>
|
||||
</ActionButton>
|
||||
</Can>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import Modal from '@/components/elements/Modal';
|
||||
import { Button } from '@/components/elements/button/index';
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import { SocketEvent, SocketRequest } from '@/components/server/events';
|
||||
|
||||
import saveFileContents from '@/api/server/files/saveFileContents';
|
||||
@@ -83,8 +83,8 @@ const EulaModalFeature = () => {
|
||||
.
|
||||
</p>
|
||||
<div className={`my-6 gap-3 flex items-center justify-end`}>
|
||||
<Button.Text onClick={() => setVisible(false)}>I don't accept</Button.Text>
|
||||
<Button onClick={onAcceptEULA}>I accept</Button>
|
||||
<ActionButton variant="secondary" onClick={() => setVisible(false)}>I don't accept</ActionButton>
|
||||
<ActionButton variant="primary" onClick={onAcceptEULA}>I accept</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Form, Formik, FormikHelpers } from 'formik';
|
||||
|
||||
import Field from '@/components/elements/Field';
|
||||
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
|
||||
import { Button } from '@/components/elements/button/index';
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
|
||||
import chmodFiles from '@/api/server/files/chmodFiles';
|
||||
|
||||
@@ -78,7 +78,7 @@ const ChmodFileModal = ({ files, ...props }: OwnProps) => {
|
||||
/>
|
||||
</div>
|
||||
<div className={`flex justify-end w-full my-6`}>
|
||||
<Button>Update</Button>
|
||||
<ActionButton variant="primary" type="submit">Update</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import Can from '@/components/elements/Can';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -235,41 +236,39 @@ const FileEditContainer = () => {
|
||||
{action === 'edit' ? (
|
||||
<Can action={'file.update'}>
|
||||
<div className='flex gap-1 items-center justify-center'>
|
||||
<button
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(109.26% 109.26% at 49.83% 13.37%, rgb(255, 52, 60) 0%, rgb(240, 111, 83) 100%)',
|
||||
}}
|
||||
className='h-[46px] pl-8 pr-6 py-3 border-[1px] border-[#ffffff12] rounded-l-full text-sm font-bold shadow-md cursor-pointer'
|
||||
<ActionButton
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="rounded-l-full rounded-r-none pl-8 pr-6"
|
||||
onClick={() => save()}
|
||||
>
|
||||
Save{' '}
|
||||
<span className='ml-2 font-mono text-xs font-bold uppercase lg:inline-block hidden'>
|
||||
CTRL + S
|
||||
</span>
|
||||
</button>
|
||||
</ActionButton>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(109.26% 109.26% at 49.83% 13.37%, rgb(255, 52, 60) 0%, rgb(240, 111, 83) 100%)',
|
||||
}}
|
||||
className='h-[46px] px-2 py-3 border-[1px] border-[#ffffff12] rounded-r-full text-sm font-bold shadow-md'
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='13'
|
||||
height='13'
|
||||
viewBox='0 0 13 13'
|
||||
fill='none'
|
||||
<DropdownMenuTrigger asChild>
|
||||
<ActionButton
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="rounded-r-full rounded-l-none px-2"
|
||||
>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
d='M3.39257 5.3429C3.48398 5.25161 3.60788 5.20033 3.73707 5.20033C3.86626 5.20033 3.99016 5.25161 4.08157 5.3429L6.49957 7.7609L8.91757 5.3429C8.9622 5.29501 9.01602 5.25659 9.07582 5.22995C9.13562 5.2033 9.20017 5.18897 9.26563 5.18782C9.33109 5.18667 9.39611 5.19871 9.45681 5.22322C9.51751 5.24774 9.57265 5.28424 9.61895 5.33053C9.66524 5.37682 9.70173 5.43196 9.72625 5.49267C9.75077 5.55337 9.76281 5.61839 9.76166 5.68384C9.7605 5.7493 9.74617 5.81385 9.71953 5.87365C9.69288 5.93345 9.65447 5.98727 9.60657 6.0319L6.84407 8.7944C6.75266 8.8857 6.62876 8.93698 6.49957 8.93698C6.37038 8.93698 6.24648 8.8857 6.15507 8.7944L3.39257 6.0319C3.30128 5.9405 3.25 5.81659 3.25 5.6874C3.25 5.55822 3.30128 5.43431 3.39257 5.3429Z'
|
||||
fill='white'
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='13'
|
||||
height='13'
|
||||
viewBox='0 0 13 13'
|
||||
fill='none'
|
||||
>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
d='M3.39257 5.3429C3.48398 5.25161 3.60788 5.20033 3.73707 5.20033C3.86626 5.20033 3.99016 5.25161 4.08157 5.3429L6.49957 7.7609L8.91757 5.3429C8.9622 5.29501 9.01602 5.25659 9.07582 5.22995C9.13562 5.2033 9.20017 5.18897 9.26563 5.18782C9.33109 5.18667 9.39611 5.19871 9.45681 5.22322C9.51751 5.24774 9.57265 5.28424 9.61895 5.33053C9.66524 5.37682 9.70173 5.43196 9.72625 5.49267C9.75077 5.55337 9.76281 5.61839 9.76166 5.68384C9.7605 5.7493 9.74617 5.81385 9.71953 5.87365C9.69288 5.93345 9.65447 5.98727 9.60657 6.0319L6.84407 8.7944C6.75266 8.8857 6.62876 8.93698 6.49957 8.93698C6.37038 8.93698 6.24648 8.8857 6.15507 8.7944L3.39257 6.0319C3.30128 5.9405 3.25 5.81659 3.25 5.6874C3.25 5.55822 3.30128 5.43431 3.39257 5.3429Z'
|
||||
fill='white'
|
||||
/>
|
||||
</svg>
|
||||
</ActionButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className='max-h-[calc(100vh-4rem)] overflow-auto z-99999'
|
||||
@@ -284,16 +283,13 @@ const FileEditContainer = () => {
|
||||
</Can>
|
||||
) : (
|
||||
<Can action={'file.create'}>
|
||||
<button
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(124.75% 124.75% at 50.01% -10.55%, rgb(36, 36, 36) 0%, rgb(20, 20, 20) 100%)',
|
||||
}}
|
||||
className='px-8 py-3 border-[1px] border-[#ffffff12] rounded-full text-sm font-bold shadow-md cursor-pointer'
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
onClick={() => setModalVisible(true)}
|
||||
>
|
||||
Create File
|
||||
</button>
|
||||
</ActionButton>
|
||||
</Can>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
import { useSignal } from '@preact/signals-react';
|
||||
import { useContext, useEffect } from 'react';
|
||||
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import Code from '@/components/elements/Code';
|
||||
import { Button } from '@/components/elements/button/index';
|
||||
import { Dialog, DialogWrapperContext } from '@/components/elements/dialog';
|
||||
|
||||
import asDialog from '@/hoc/asDialog';
|
||||
@@ -52,19 +52,21 @@ const FileUploadList = () => {
|
||||
</div>
|
||||
{/* </Tooltip> */}
|
||||
<Code className={'flex-1 truncate'}>{name}</Code>
|
||||
<button
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={cancelFileUpload.bind(this, name)}
|
||||
className={'text-zinc-500 hover:text-zinc-200 transition-colors duration-75 cursor-pointer'}
|
||||
className="hover:!text-red-400"
|
||||
>
|
||||
FIXME: add X icon Cancel Upload
|
||||
</button>
|
||||
Cancel
|
||||
</ActionButton>
|
||||
</div>
|
||||
))}
|
||||
<Dialog.Footer>
|
||||
<Button.Danger variant={Button.Variants.Secondary} onClick={() => clearFileUploads()}>
|
||||
<ActionButton variant="danger" onClick={() => clearFileUploads()}>
|
||||
Cancel Uploads
|
||||
</Button.Danger>
|
||||
<Button.Text onClick={close}>Close</Button.Text>
|
||||
</ActionButton>
|
||||
<ActionButton variant="secondary" onClick={close}>Close</ActionButton>
|
||||
</Dialog.Footer>
|
||||
</div>
|
||||
);
|
||||
@@ -90,12 +92,14 @@ const FileManagerStatus = () => {
|
||||
<>
|
||||
{count > 0 && (
|
||||
// <Tooltip content={`${count} files are uploading, click to view`}>
|
||||
<button
|
||||
className={'flex items-center justify-center w-10 h-10 cursor-pointer'}
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="w-10 h-10 p-0"
|
||||
onClick={() => (open.value = true)}
|
||||
>
|
||||
<svg
|
||||
className='animate-spin -ml-1 mr-3 h-5 w-5 text-white'
|
||||
className='animate-spin h-5 w-5 text-white'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
fill='none'
|
||||
viewBox='0 0 24 24'
|
||||
@@ -114,7 +118,7 @@ const FileManagerStatus = () => {
|
||||
d='M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z'
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</ActionButton>
|
||||
// </Tooltip>
|
||||
)}
|
||||
<FileUploadListDialog open={open.value} onClose={() => (open.value = false)} />
|
||||
|
||||
@@ -2,9 +2,9 @@ import { Form, Formik, FormikHelpers } from 'formik';
|
||||
import { join } from 'pathe';
|
||||
import { object, string } from 'yup';
|
||||
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import Field from '@/components/elements/Field';
|
||||
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
|
||||
import { Button } from '@/components/elements/button/index';
|
||||
|
||||
import { ServerContext } from '@/state/server';
|
||||
|
||||
@@ -50,7 +50,7 @@ const FileNameModal = ({ onFileNamed, onDismissed, ...props }: Props) => {
|
||||
autoFocus
|
||||
/>
|
||||
<div className={`flex justify-end w-full my-4`}>
|
||||
<Button>Create File</Button>
|
||||
<ActionButton variant="primary">Create File</ActionButton>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||
import { Button } from '@/components/elements/button/index';
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import { Dialog } from '@/components/elements/dialog';
|
||||
import FadeTransition from '@/components/elements/transitions/FadeTransition';
|
||||
import RenameFileModal from '@/components/server/files/RenameFileModal';
|
||||
@@ -101,11 +101,11 @@ const MassActionsBar = () => {
|
||||
}
|
||||
>
|
||||
<div className={`flex items-center space-x-4 pointer-events-auto rounded-sm p-4 bg-black/50`}>
|
||||
<Button onClick={() => setShowMove(true)}>Move</Button>
|
||||
<Button onClick={onClickCompress}>Archive</Button>
|
||||
<Button.Danger variant={Button.Variants.Secondary} onClick={() => setShowConfirm(true)}>
|
||||
<ActionButton onClick={() => setShowMove(true)}>Move</ActionButton>
|
||||
<ActionButton onClick={onClickCompress}>Archive</ActionButton>
|
||||
<ActionButton variant="danger" onClick={() => setShowConfirm(true)}>
|
||||
Delete
|
||||
</Button.Danger>
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</FadeTransition>
|
||||
|
||||
@@ -4,9 +4,9 @@ import { useContext, useEffect, useState } from 'react';
|
||||
import { object, string } from 'yup';
|
||||
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import Code from '@/components/elements/Code';
|
||||
import Field from '@/components/elements/Field';
|
||||
import { Button } from '@/components/elements/button/index';
|
||||
import { Dialog, DialogWrapperContext } from '@/components/elements/dialog';
|
||||
|
||||
import asDialog from '@/hoc/asDialog';
|
||||
@@ -89,12 +89,12 @@ const NewDirectoryDialog = asDialog({
|
||||
</p>
|
||||
</Form>
|
||||
<Dialog.Footer>
|
||||
<Button.Text className={'w-full sm:w-auto'} onClick={close}>
|
||||
<ActionButton variant="secondary" className={'w-full sm:w-auto'} onClick={close}>
|
||||
Cancel
|
||||
</Button.Text>
|
||||
<Button className={'w-full sm:w-auto'} onClick={submitForm}>
|
||||
</ActionButton>
|
||||
<ActionButton variant="primary" className={'w-full sm:w-auto'} onClick={submitForm}>
|
||||
Create
|
||||
</Button>
|
||||
</ActionButton>
|
||||
</Dialog.Footer>
|
||||
</>
|
||||
)}
|
||||
@@ -108,16 +108,9 @@ const NewDirectoryButton = () => {
|
||||
return (
|
||||
<>
|
||||
<NewDirectoryDialog open={open} onClose={setOpen.bind(this, false)} />
|
||||
<button
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(124.75% 124.75% at 50.01% -10.55%, rgb(36, 36, 36) 0%, rgb(20, 20, 20) 100%)',
|
||||
}}
|
||||
className='px-8 py-3 border-[1px] border-[#ffffff12] rounded-l-full rounded-r-md text-sm font-bold shadow-md cursor-pointer'
|
||||
onClick={setOpen.bind(this, true)}
|
||||
>
|
||||
<ActionButton variant='secondary' onClick={setOpen.bind(this, true)}>
|
||||
New Folder
|
||||
</button>
|
||||
</ActionButton>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
|
||||
const NewFileButton = ({ id }: { id: string }) => {
|
||||
return (
|
||||
<NavLink to={`/server/${id}/files/new${window.location.hash}`}>
|
||||
<div
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(124.75% 124.75% at 50.01% -10.55%, rgb(36, 36, 36) 0%, rgb(20, 20, 20) 100%)',
|
||||
}}
|
||||
className='px-8 py-3 border-[1px] border-[#ffffff12] rounded-none text-sm font-bold shadow-md'
|
||||
>
|
||||
<ActionButton variant="secondary" size="md">
|
||||
New File
|
||||
</div>
|
||||
</ActionButton>
|
||||
</NavLink>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import { join } from 'pathe';
|
||||
import Code from '@/components/elements/Code';
|
||||
import Field from '@/components/elements/Field';
|
||||
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
|
||||
import { Button } from '@/components/elements/button/index';
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
|
||||
import renameFiles from '@/api/server/files/renameFiles';
|
||||
|
||||
@@ -82,7 +82,7 @@ const RenameFileModal = ({ files, useMoveTerminology, ...props }: OwnProps) => {
|
||||
</p>
|
||||
)}
|
||||
<div className={`flex justify-end w-full my-6`}>
|
||||
<Button>{useMoveTerminology ? 'Move' : 'Rename'}</Button>
|
||||
<ActionButton variant="primary" type="submit">{useMoveTerminology ? 'Move' : 'Rename'}</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import axios from 'axios';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import { ModalMask } from '@/components/elements/Modal';
|
||||
import FadeTransition from '@/components/elements/transitions/FadeTransition';
|
||||
|
||||
@@ -166,16 +167,12 @@ const UploadButton = () => {
|
||||
}}
|
||||
multiple
|
||||
/>
|
||||
<button
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(124.75% 124.75% at 50.01% -10.55%, rgb(36, 36, 36) 0%, rgb(20, 20, 20) 100%)',
|
||||
}}
|
||||
className='px-8 py-3 border-[1px] border-[#ffffff12] rounded-r-full rounded-l-md text-sm font-bold shadow-md cursor-pointer'
|
||||
<ActionButton
|
||||
variant='secondary'
|
||||
onClick={() => fileUploadInput.current && fileUploadInput.current.click()}
|
||||
>
|
||||
Upload
|
||||
</button>
|
||||
</ActionButton>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { faNetworkWired } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faNetworkWired, faCheck, faTimes, faCrown, faTrash, faCopy } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import debounce from 'debounce';
|
||||
import { memo, useCallback, useState } from 'react';
|
||||
import { memo, useCallback, useState, useRef, useEffect } from 'react';
|
||||
import isEqual from 'react-fast-compare';
|
||||
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import Can from '@/components/elements/Can';
|
||||
import Code from '@/components/elements/Code';
|
||||
import CopyOnClick from '@/components/elements/CopyOnClick';
|
||||
import { Textarea } from '@/components/elements/Input';
|
||||
import InputSpinner from '@/components/elements/InputSpinner';
|
||||
import { Button } from '@/components/elements/button/index';
|
||||
import DeleteAllocationButton from '@/components/server/network/DeleteAllocationButton';
|
||||
import deleteServerAllocation from '@/api/server/network/deleteServerAllocation';
|
||||
|
||||
import { ip } from '@/lib/formatters';
|
||||
|
||||
@@ -29,6 +29,9 @@ interface Props {
|
||||
|
||||
const AllocationRow = ({ allocation }: Props) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isEditingNotes, setIsEditingNotes] = useState(false);
|
||||
const [notesValue, setNotesValue] = useState(allocation.notes || '');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const { clearFlashes, clearAndAddHttpError } = useFlashKey('server:network');
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
const { mutate } = getServerAllocations();
|
||||
@@ -37,15 +40,37 @@ const AllocationRow = ({ allocation }: Props) => {
|
||||
mutate((data) => data?.map((a) => (a.id === id ? { ...a, notes } : a)), false);
|
||||
}, []);
|
||||
|
||||
const setAllocationNotes = debounce((notes: string) => {
|
||||
const saveNotes = useCallback(() => {
|
||||
setLoading(true);
|
||||
clearFlashes();
|
||||
|
||||
setServerAllocationNotes(uuid, allocation.id, notes)
|
||||
.then(() => onNotesChanged(allocation.id, notes))
|
||||
setServerAllocationNotes(uuid, allocation.id, notesValue)
|
||||
.then(() => {
|
||||
onNotesChanged(allocation.id, notesValue);
|
||||
setIsEditingNotes(false);
|
||||
})
|
||||
.catch((error) => clearAndAddHttpError(error))
|
||||
.then(() => setLoading(false));
|
||||
}, 750);
|
||||
}, [uuid, allocation.id, notesValue, onNotesChanged, clearFlashes, clearAndAddHttpError]);
|
||||
|
||||
const cancelEdit = useCallback(() => {
|
||||
setNotesValue(allocation.notes || '');
|
||||
setIsEditingNotes(false);
|
||||
}, [allocation.notes]);
|
||||
|
||||
const startEdit = useCallback(() => {
|
||||
setIsEditingNotes(true);
|
||||
setTimeout(() => textareaRef.current?.focus(), 0);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setNotesValue(allocation.notes || '');
|
||||
}, [allocation.notes]);
|
||||
|
||||
// Format the full allocation string for copying
|
||||
const allocationString = allocation.alias
|
||||
? `${allocation.alias}:${allocation.port}`
|
||||
: `${ip(allocation.ip)}:${allocation.port}`;
|
||||
|
||||
const setPrimaryAllocation = () => {
|
||||
clearFlashes();
|
||||
@@ -57,29 +82,44 @@ const AllocationRow = ({ allocation }: Props) => {
|
||||
});
|
||||
};
|
||||
|
||||
const deleteAllocation = () => {
|
||||
if (!confirm('Are you sure you want to delete this allocation?')) return;
|
||||
|
||||
clearFlashes();
|
||||
setLoading(true);
|
||||
|
||||
deleteServerAllocation(uuid, allocation.id)
|
||||
.then(() => {
|
||||
mutate((data) => data?.filter((a) => a.id !== allocation.id), false);
|
||||
})
|
||||
.catch((error) => clearAndAddHttpError(error))
|
||||
.then(() => setLoading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='bg-linear-to-b from-[#ffffff08] to-[#ffffff05] border-[1px] border-[#ffffff15] p-4 sm:p-5 rounded-xl hover:border-[#ffffff20] transition-all'>
|
||||
<div className='flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4'>
|
||||
<div className='flex-1 min-w-0'>
|
||||
<div className='flex items-center gap-3 mb-2'>
|
||||
<div className='flex items-center gap-3 mb-3'>
|
||||
<div className='flex-shrink-0 w-8 h-8 rounded-lg bg-[#ffffff11] flex items-center justify-center'>
|
||||
<FontAwesomeIcon icon={faNetworkWired} className='text-zinc-400 w-4 h-4' />
|
||||
</div>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='flex items-center flex-wrap'>
|
||||
{allocation.alias ? (
|
||||
<CopyOnClick text={allocation.alias}>
|
||||
<h3 className='text-base font-medium text-zinc-100 font-mono truncate'>{allocation.alias}</h3>
|
||||
</CopyOnClick>
|
||||
) : (
|
||||
<CopyOnClick text={ip(allocation.ip)}>
|
||||
<h3 className='text-base font-medium text-zinc-100 font-mono truncate'>{ip(allocation.ip)}</h3>
|
||||
</CopyOnClick>
|
||||
)}
|
||||
<span className='text-zinc-500'>:</span>
|
||||
<span className='text-base font-medium text-zinc-100 font-mono'>{allocation.port}</span>
|
||||
<div className='flex items-center flex-wrap gap-2'>
|
||||
<CopyOnClick text={allocationString}>
|
||||
<div className='flex items-center gap-2 cursor-pointer hover:text-zinc-50 transition-colors group'>
|
||||
<h3 className='text-base font-medium text-zinc-100 font-mono truncate'>
|
||||
{allocation.alias ? allocation.alias : ip(allocation.ip)}:{allocation.port}
|
||||
</h3>
|
||||
<FontAwesomeIcon
|
||||
icon={faCopy}
|
||||
className='w-3 h-3 text-zinc-500 group-hover:text-zinc-400 transition-colors'
|
||||
/>
|
||||
</div>
|
||||
</CopyOnClick>
|
||||
{allocation.isDefault && (
|
||||
<span className='text-xs text-brand font-medium bg-brand/10 px-2 py-1 rounded ml-2'>
|
||||
<span className='flex items-center gap-1 text-xs text-brand font-medium bg-brand/10 px-2 py-1 rounded'>
|
||||
<FontAwesomeIcon icon={faCrown} className='w-3 h-3' />
|
||||
Primary
|
||||
</span>
|
||||
)}
|
||||
@@ -87,44 +127,86 @@ const AllocationRow = ({ allocation }: Props) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{allocation.notes && (
|
||||
<div className='mt-2'>
|
||||
<p className='text-xs text-zinc-500 uppercase tracking-wide mb-1'>Notes</p>
|
||||
<p className='text-sm text-zinc-400'>{allocation.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes Section - Inline Editable */}
|
||||
<div className='mt-3'>
|
||||
<InputSpinner visible={loading}>
|
||||
<Textarea
|
||||
className='w-full bg-[#ffffff06] border border-[#ffffff08] rounded-lg p-2 text-sm text-zinc-300 placeholder-zinc-500 resize-none focus:ring-1 focus:ring-[#ffffff20] focus:border-[#ffffff20] transition-all'
|
||||
placeholder='Add notes for this allocation...'
|
||||
defaultValue={allocation.notes || undefined}
|
||||
onChange={(e) => setAllocationNotes(e.currentTarget.value)}
|
||||
rows={2}
|
||||
/>
|
||||
</InputSpinner>
|
||||
<p className='text-xs text-zinc-500 uppercase tracking-wide mb-2'>Notes</p>
|
||||
|
||||
{isEditingNotes ? (
|
||||
<div className='space-y-2'>
|
||||
<InputSpinner visible={loading}>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
className='w-full bg-[#ffffff06] border border-[#ffffff08] rounded-lg p-3 text-sm text-zinc-300 placeholder-zinc-500 resize-none focus:ring-1 focus:ring-[#ffffff20] focus:border-[#ffffff20] transition-all'
|
||||
placeholder='Add notes for this allocation...'
|
||||
value={notesValue}
|
||||
onChange={(e) => setNotesValue(e.currentTarget.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</InputSpinner>
|
||||
<div className='flex items-center gap-2'>
|
||||
<ActionButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={saveNotes}
|
||||
disabled={loading}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCheck} className='w-3 h-3 mr-1' />
|
||||
Save
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={cancelEdit}
|
||||
disabled={loading}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTimes} className='w-3 h-3 mr-1' />
|
||||
Cancel
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Can action={'allocation.update'}>
|
||||
<div
|
||||
className={`min-h-[2.5rem] p-3 rounded-lg border border-[#ffffff08] bg-[#ffffff03] cursor-pointer hover:border-[#ffffff15] transition-colors ${
|
||||
allocation.notes
|
||||
? 'text-sm text-zinc-300'
|
||||
: 'text-sm text-zinc-500 italic'
|
||||
}`}
|
||||
onClick={startEdit}
|
||||
>
|
||||
{allocation.notes || 'Click to add notes...'}
|
||||
</div>
|
||||
</Can>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-2 sm:flex-col sm:gap-3'>
|
||||
{!allocation.isDefault && (
|
||||
<>
|
||||
<Can action={'allocation.update'}>
|
||||
<button
|
||||
type='button'
|
||||
onClick={setPrimaryAllocation}
|
||||
className='flex items-center justify-center gap-2 px-3 py-2 bg-[#ffffff11] hover:bg-[#ffffff19] rounded-lg text-sm text-zinc-300 hover:text-zinc-100 transition-colors duration-150'
|
||||
>
|
||||
<span className='hidden sm:inline'>Make Primary</span>
|
||||
<span className='sm:hidden'>Primary</span>
|
||||
</button>
|
||||
</Can>
|
||||
<Can action={'allocation.delete'}>
|
||||
<DeleteAllocationButton allocation={allocation.id} />
|
||||
</Can>
|
||||
</>
|
||||
)}
|
||||
<div className='flex items-center justify-center gap-2 sm:flex-col sm:gap-3'>
|
||||
<Can action={'allocation.update'}>
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={setPrimaryAllocation}
|
||||
disabled={allocation.isDefault}
|
||||
title={allocation.isDefault ? 'This is already the primary allocation' : 'Make this the primary allocation'}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCrown} className='w-3 h-3 mr-1' />
|
||||
<span className='hidden sm:inline'>Make Primary</span>
|
||||
<span className='sm:hidden'>Primary</span>
|
||||
</ActionButton>
|
||||
</Can>
|
||||
<Can action={'allocation.delete'}>
|
||||
<ActionButton
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={deleteAllocation}
|
||||
disabled={allocation.isDefault || loading}
|
||||
title={allocation.isDefault ? 'Cannot delete the primary allocation' : 'Delete this allocation'}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} className='w-3 h-3 mr-1' />
|
||||
<span className='hidden sm:inline'>Delete</span>
|
||||
</ActionButton>
|
||||
</Can>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import { Dialog } from '@/components/elements/dialog';
|
||||
import HugeIconsDelete from '@/components/elements/hugeicons/Delete';
|
||||
|
||||
@@ -48,14 +49,15 @@ const DeleteAllocationButton = ({ allocation }: Props) => {
|
||||
>
|
||||
This allocation will be immediately removed from your server.
|
||||
</Dialog.Confirm>
|
||||
<button
|
||||
type='button'
|
||||
<ActionButton
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => setConfirm(true)}
|
||||
className='flex items-center justify-center gap-2 px-3 py-2 bg-[#ffffff11] hover:bg-red-600/20 rounded-lg text-sm text-zinc-300 hover:text-red-400 transition-colors duration-150'
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<HugeIconsDelete className='h-4 w-4' fill='currentColor' />
|
||||
<span className='hidden sm:inline'>Delete</span>
|
||||
</button>
|
||||
</ActionButton>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
|
||||
import isEqual from 'react-fast-compare';
|
||||
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import Can from '@/components/elements/Can';
|
||||
import { MainPageHeader } from '@/components/elements/MainPageHeader';
|
||||
import ServerContentBlock from '@/components/elements/ServerContentBlock';
|
||||
@@ -58,30 +59,19 @@ const NetworkContainer = () => {
|
||||
<ServerContentBlock title={'Network'}>
|
||||
<FlashMessageRender byKey={'server:network'} />
|
||||
<MainPageHeader title={'Network'}>
|
||||
{!data ? null : (
|
||||
<>
|
||||
{allocationLimit > 0 && (
|
||||
<Can action={'allocation.create'}>
|
||||
<div className='flex flex-col sm:flex-row items-center justify-end gap-4'>
|
||||
<p className='text-sm text-zinc-300 text-center sm:text-right'>
|
||||
{data.length} of {allocationLimit} allowed allocations
|
||||
</p>
|
||||
{allocationLimit > data.length && (
|
||||
<button
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(124.75% 124.75% at 50.01% -10.55%, rgb(36, 36, 36) 0%, rgb(20, 20, 20) 100%)',
|
||||
}}
|
||||
className='px-8 py-3 border-[1px] border-[#ffffff12] rounded-full text-sm font-bold shadow-md cursor-pointer hover:bg-[#ffffff11] transition-colors duration-150'
|
||||
onClick={onCreateAllocation}
|
||||
>
|
||||
New Allocation
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Can>
|
||||
)}
|
||||
</>
|
||||
{data && allocationLimit > 0 && (
|
||||
<Can action={'allocation.create'}>
|
||||
<div className='flex flex-col sm:flex-row items-center justify-end gap-4'>
|
||||
<p className='text-sm text-zinc-300 text-center sm:text-right'>
|
||||
{data.length} of {allocationLimit} allowed allocations
|
||||
</p>
|
||||
{allocationLimit > data.length && (
|
||||
<ActionButton variant='primary' onClick={onCreateAllocation}>
|
||||
New Allocation
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
</Can>
|
||||
)}
|
||||
</MainPageHeader>
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Actions, useStoreActions } from 'easy-peasy';
|
||||
import { useState } from 'react';
|
||||
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||
import { Button } from '@/components/elements/button/index';
|
||||
import { Dialog } from '@/components/elements/dialog';
|
||||
|
||||
import { httpErrorToHuman } from '@/api/http';
|
||||
@@ -51,13 +51,13 @@ const DeleteScheduleButton = ({ scheduleId, onDeleted }: Props) => {
|
||||
<SpinnerOverlay visible={isLoading} />
|
||||
All tasks will be removed and any running processes will be terminated.
|
||||
</Dialog.Confirm>
|
||||
<Button.Danger
|
||||
variant={Button.Variants.Secondary}
|
||||
className={'flex-1 sm:flex-none border-transparent'}
|
||||
<ActionButton
|
||||
variant="danger"
|
||||
className={'flex-1 sm:flex-none'}
|
||||
onClick={() => setVisible(true)}
|
||||
>
|
||||
Delete
|
||||
</Button.Danger>
|
||||
</ActionButton>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,10 +8,10 @@ import { Form, Formik, FormikHelpers } from 'formik';
|
||||
import { useContext, useEffect, useMemo } from 'react';
|
||||
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import Field from '@/components/elements/Field';
|
||||
import FormikSwitchV2 from '@/components/elements/FormikSwitchV2';
|
||||
import ItemContainer from '@/components/elements/ItemContainer';
|
||||
import { Button } from '@/components/elements/button/index';
|
||||
|
||||
import asModal from '@/hoc/asModal';
|
||||
|
||||
@@ -262,9 +262,9 @@ const EditScheduleModal = ({ schedule }: Props) => {
|
||||
/>
|
||||
</div>
|
||||
<div className={`mb-6 text-right`}>
|
||||
<Button className={'w-full sm:w-auto'} type={'submit'} disabled={isSubmitting}>
|
||||
<ActionButton variant="primary" className={'w-full sm:w-auto'} type={'submit'} disabled={isSubmitting}>
|
||||
{schedule ? 'Save changes' : 'Create schedule'}
|
||||
</Button>
|
||||
</ActionButton>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import clsx from 'clsx';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Button } from '@/components/elements/button/index';
|
||||
import TaskDetailsModal from '@/components/server/schedules/TaskDetailsModal';
|
||||
|
||||
import { Schedule } from '@/api/server/schedules/getServerSchedules';
|
||||
|
||||
interface Props {
|
||||
schedule: Schedule;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const NewTaskButton = ({ schedule, className }: Props) => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TaskDetailsModal schedule={schedule} visible={visible} onModalDismissed={() => setVisible(false)} />
|
||||
<Button onClick={() => setVisible(true)} className={clsx(className)}>
|
||||
New Task
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewTaskButton;
|
||||
@@ -1,50 +0,0 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||
import { Button } from '@/components/elements/button/index';
|
||||
|
||||
import { Schedule } from '@/api/server/schedules/getServerSchedules';
|
||||
import triggerScheduleExecution from '@/api/server/schedules/triggerScheduleExecution';
|
||||
|
||||
import { ServerContext } from '@/state/server';
|
||||
|
||||
import useFlash from '@/plugins/useFlash';
|
||||
|
||||
const RunScheduleButton = ({ schedule }: { schedule: Schedule }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||
|
||||
const id = ServerContext.useStoreState((state) => state.server.data!.id);
|
||||
const appendSchedule = ServerContext.useStoreActions((actions) => actions.schedules.appendSchedule);
|
||||
|
||||
const onTriggerExecute = useCallback(() => {
|
||||
clearFlashes('schedule');
|
||||
setLoading(true);
|
||||
triggerScheduleExecution(id, schedule.id)
|
||||
.then(() => {
|
||||
setLoading(false);
|
||||
appendSchedule({ ...schedule, isProcessing: true });
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
clearAndAddHttpError({ error, key: 'schedules' });
|
||||
})
|
||||
.then(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SpinnerOverlay visible={loading} size={'large'} />
|
||||
<Button
|
||||
variant={Button.Variants.Secondary}
|
||||
className={'flex-1 sm:flex-none'}
|
||||
disabled={schedule.isProcessing}
|
||||
onClick={onTriggerExecute}
|
||||
>
|
||||
Run Now
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default RunScheduleButton;
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import Can from '@/components/elements/Can';
|
||||
import { MainPageHeader } from '@/components/elements/MainPageHeader';
|
||||
import ServerContentBlock from '@/components/elements/ServerContentBlock';
|
||||
@@ -43,16 +44,9 @@ function ScheduleContainer() {
|
||||
<MainPageHeader title={'Schedules'}>
|
||||
<Can action={'schedule.create'}>
|
||||
<EditScheduleModal visible={visible} onModalDismissed={() => setVisible(false)} />
|
||||
<button
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(124.75% 124.75% at 50.01% -10.55%, rgb(36, 36, 36) 0%, rgb(20, 20, 20) 100%)',
|
||||
}}
|
||||
className='rounded-full border-[1px] border-[#ffffff12] px-8 py-3 text-sm font-bold shadow-md cursor-pointer'
|
||||
onClick={() => setVisible(true)}
|
||||
>
|
||||
<ActionButton variant='primary' onClick={() => setVisible(true)}>
|
||||
New Schedule
|
||||
</button>
|
||||
</ActionButton>
|
||||
</Can>
|
||||
</MainPageHeader>
|
||||
{!schedules.length && loading ? null : (
|
||||
|
||||
@@ -8,12 +8,14 @@ import Can from '@/components/elements/Can';
|
||||
import ItemContainer from '@/components/elements/ItemContainer';
|
||||
import PageContentBlock from '@/components/elements/PageContentBlock';
|
||||
import Spinner from '@/components/elements/Spinner';
|
||||
import { Button } from '@/components/elements/button/index';
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||
import DeleteScheduleButton from '@/components/server/schedules/DeleteScheduleButton';
|
||||
import EditScheduleModal from '@/components/server/schedules/EditScheduleModal';
|
||||
import NewTaskButton from '@/components/server/schedules/NewTaskButton';
|
||||
import RunScheduleButton from '@/components/server/schedules/RunScheduleButton';
|
||||
import ScheduleTaskRow from '@/components/server/schedules/ScheduleTaskRow';
|
||||
import TaskDetailsModal from '@/components/server/schedules/TaskDetailsModal';
|
||||
|
||||
import triggerScheduleExecution from '@/api/server/schedules/triggerScheduleExecution';
|
||||
|
||||
import getServerSchedule from '@/api/server/schedules/getServerSchedule';
|
||||
|
||||
@@ -41,6 +43,8 @@ const ScheduleEditContainer = () => {
|
||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [showTaskModal, setShowTaskModal] = useState(false);
|
||||
const [runLoading, setRunLoading] = useState(false);
|
||||
|
||||
const schedule = ServerContext.useStoreState(
|
||||
(st) => st.schedules.data.find((s) => s.id === Number(scheduleId)),
|
||||
@@ -68,6 +72,21 @@ const ScheduleEditContainer = () => {
|
||||
setShowEditModal((s) => !s);
|
||||
}, []);
|
||||
|
||||
const onTriggerExecute = useCallback(() => {
|
||||
clearFlashes('schedule');
|
||||
setRunLoading(true);
|
||||
triggerScheduleExecution(id, schedule!.id)
|
||||
.then(() => {
|
||||
setRunLoading(false);
|
||||
appendSchedule({ ...schedule!, isProcessing: true });
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
clearAndAddHttpError({ error, key: 'schedules' });
|
||||
})
|
||||
.then(() => setRunLoading(false));
|
||||
}, [schedule, id, clearFlashes, clearAndAddHttpError, appendSchedule]);
|
||||
|
||||
return (
|
||||
<PageContentBlock title={'Schedules'}>
|
||||
<FlashMessageRender byKey={'schedules'} />
|
||||
@@ -112,10 +131,12 @@ const ScheduleEditContainer = () => {
|
||||
</div>
|
||||
<div className={`flex gap-2 flex-col md:flex-row md:min-w-0 min-w-full`}>
|
||||
<Can action={'schedule.update'}>
|
||||
<Button.Text onClick={toggleEditModal} className={'flex-1 min-w-max'}>
|
||||
<ActionButton variant='secondary' onClick={toggleEditModal} className={'flex-1 min-w-max'}>
|
||||
Edit
|
||||
</Button.Text>
|
||||
<NewTaskButton schedule={schedule} className={'flex-1 min-w-max'} />
|
||||
</ActionButton>
|
||||
<ActionButton variant='primary' onClick={() => setShowTaskModal(true)} className={'flex-1 min-w-max'}>
|
||||
New Task
|
||||
</ActionButton>
|
||||
</Can>
|
||||
</div>
|
||||
</div>
|
||||
@@ -151,10 +172,19 @@ const ScheduleEditContainer = () => {
|
||||
</Can>
|
||||
{schedule.tasks.length > 0 && (
|
||||
<Can action={'schedule.update'}>
|
||||
<RunScheduleButton schedule={schedule} />
|
||||
<SpinnerOverlay visible={runLoading} size={'large'} />
|
||||
<ActionButton
|
||||
variant='secondary'
|
||||
className={'flex-1 sm:flex-none'}
|
||||
disabled={schedule.isProcessing}
|
||||
onClick={onTriggerExecute}
|
||||
>
|
||||
Run Now
|
||||
</ActionButton>
|
||||
</Can>
|
||||
)}
|
||||
</div>
|
||||
<TaskDetailsModal schedule={schedule} visible={showTaskModal} onModalDismissed={() => setShowTaskModal(false)} />
|
||||
</div>
|
||||
)}
|
||||
</PageContentBlock>
|
||||
|
||||
@@ -5,11 +5,12 @@ import {
|
||||
faPowerOff,
|
||||
faQuestion,
|
||||
faTerminal,
|
||||
faTrash,
|
||||
faTrashAlt,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { useState } from 'react';
|
||||
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import Can from '@/components/elements/Can';
|
||||
import ConfirmationModal from '@/components/elements/ConfirmationModal';
|
||||
import ItemContainer from '@/components/elements/ItemContainer';
|
||||
@@ -110,7 +111,7 @@ const ScheduleTaskRow = ({ schedule, task }: Props) => {
|
||||
</div>
|
||||
)}
|
||||
</div> */}
|
||||
<div className={`flex flex-none items-end sm:items-center flex-col sm:flex-row`}>
|
||||
<div className={`flex flex-none items-end sm:items-center flex-col sm:flex-row gap-2`}>
|
||||
<div className='mr-0 sm:mr-6'>
|
||||
{task.continueOnFailure && (
|
||||
<div className={`px-2 py-1 bg-yellow-500 text-yellow-800 text-sm rounded-full`}>
|
||||
@@ -122,26 +123,28 @@ const ScheduleTaskRow = ({ schedule, task }: Props) => {
|
||||
)}
|
||||
</div>
|
||||
<Can action={'schedule.update'}>
|
||||
<button
|
||||
type={'button'}
|
||||
aria-label={'Edit scheduled task'}
|
||||
className={`block text-sm p-2 text-zinc-500 hover:text-zinc-100 transition-colors duration-150 mr-4 ml-auto sm:ml-0 cursor-pointer`}
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="flex flex-row items-center gap-2 ml-auto sm:ml-0"
|
||||
onClick={() => setIsEditing(true)}
|
||||
aria-label="Edit scheduled task"
|
||||
>
|
||||
<FontAwesomeIcon icon={faPen} className={`px-5`} size='lg' />
|
||||
<FontAwesomeIcon icon={faPen} />
|
||||
Edit
|
||||
</button>
|
||||
</ActionButton>
|
||||
</Can>
|
||||
<Can action={'schedule.update'}>
|
||||
<button
|
||||
type={'button'}
|
||||
aria-label={'Delete scheduled task'}
|
||||
className={`block text-sm p-2 text-zinc-500 hover:text-red-600 transition-colors duration-150 cursor-pointer`}
|
||||
<ActionButton
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => setVisible(true)}
|
||||
className="flex items-center gap-2"
|
||||
aria-label="Delete scheduled task"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} className={`px-5`} size='lg' />
|
||||
Delete
|
||||
</button>
|
||||
<FontAwesomeIcon icon={faTrashAlt} className='w-4 h-4' />
|
||||
<span className='hidden sm:inline'>Delete</span>
|
||||
</ActionButton>
|
||||
</Can>
|
||||
</div>
|
||||
</ItemContainer>
|
||||
|
||||
@@ -5,12 +5,12 @@ import styled from 'styled-components';
|
||||
import { boolean, number, object, string } from 'yup';
|
||||
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import Field from '@/components/elements/Field';
|
||||
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
|
||||
import FormikSwitchV2 from '@/components/elements/FormikSwitchV2';
|
||||
import { Textarea } from '@/components/elements/Input';
|
||||
import Select from '@/components/elements/Select';
|
||||
import { Button } from '@/components/elements/button/index';
|
||||
|
||||
import asModal from '@/hoc/asModal';
|
||||
|
||||
@@ -234,9 +234,9 @@ const TaskDetailsModal = ({ schedule, task }: Props) => {
|
||||
label={'Continue on Failure'}
|
||||
/>
|
||||
<div className={`flex justify-end my-6`}>
|
||||
<Button type={'submit'} disabled={isSubmitting}>
|
||||
<ActionButton variant="primary" type={'submit'} disabled={isSubmitting}>
|
||||
{task ? 'Save Changes' : 'Create Task'}
|
||||
</Button>
|
||||
</ActionButton>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Actions, useStoreActions } from 'easy-peasy';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import TitledGreyBox from '@/components/elements/TitledGreyBox';
|
||||
import { Button } from '@/components/elements/button/index';
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import { Dialog } from '@/components/elements/dialog';
|
||||
|
||||
import { httpErrorToHuman } from '@/api/http';
|
||||
@@ -59,9 +59,9 @@ const ReinstallServerBox = () => {
|
||||
</strong>
|
||||
</p>
|
||||
<div className={`mt-6 text-right`}>
|
||||
<Button.Danger variant={Button.Variants.Secondary} onClick={() => setModalVisible(true)}>
|
||||
<ActionButton variant="danger" onClick={() => setModalVisible(true)}>
|
||||
Reinstall Server
|
||||
</Button.Danger>
|
||||
</ActionButton>
|
||||
</div>
|
||||
</TitledGreyBox>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { object, string } from 'yup';
|
||||
|
||||
import Field from '@/components/elements/Field';
|
||||
import TitledGreyBox from '@/components/elements/TitledGreyBox';
|
||||
import { Button } from '@/components/elements/button/index';
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
|
||||
import { httpErrorToHuman } from '@/api/http';
|
||||
import renameServer from '@/api/server/renameServer';
|
||||
@@ -25,7 +25,7 @@ const RenameServerForm = () => {
|
||||
<Field id={'name'} name={'name'} label={'Server Name'} type={'text'} />
|
||||
<Field id={'description'} name={'description'} label={'Server Description'} type={'text'} />
|
||||
<div className={`mt-6 text-right`}>
|
||||
<Button type={'submit'}>Save</Button>
|
||||
<ActionButton variant="primary" type={'submit'}>Save</ActionButton>
|
||||
</div>
|
||||
</Form>
|
||||
</TitledGreyBox>
|
||||
|
||||
@@ -2,13 +2,13 @@ import { useStoreState } from 'easy-peasy';
|
||||
import isEqual from 'react-fast-compare';
|
||||
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import Can from '@/components/elements/Can';
|
||||
import CopyOnClick from '@/components/elements/CopyOnClick';
|
||||
import Label from '@/components/elements/Label';
|
||||
import { MainPageHeader } from '@/components/elements/MainPageHeader';
|
||||
import ServerContentBlock from '@/components/elements/ServerContentBlock';
|
||||
import TitledGreyBox from '@/components/elements/TitledGreyBox';
|
||||
import { Button } from '@/components/elements/button/index';
|
||||
import ReinstallServerBox from '@/components/server/settings/ReinstallServerBox';
|
||||
|
||||
import { ip } from '@/lib/formatters';
|
||||
@@ -78,7 +78,7 @@ const SettingsContainer = () => {
|
||||
</div>
|
||||
<div className={`ml-4`}>
|
||||
<a href={`sftp://${username}.${id}@${ip(sftp.ip)}:${sftp.port}`}>
|
||||
<Button.Text variant={Button.Variants.Secondary}>Launch SFTP</Button.Text>
|
||||
<ActionButton variant='secondary'>Launch SFTP</ActionButton>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import isEqual from 'react-fast-compare';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import Button from '@/components/elements/ButtonV2';
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import { MainPageHeader } from '@/components/elements/MainPageHeader';
|
||||
import Pagination from '@/components/elements/Pagination';
|
||||
import ServerContentBlock from '@/components/elements/ServerContentBlock';
|
||||
@@ -114,6 +114,7 @@ const SoftwareContainer = () => {
|
||||
const { data, mutate } = getServerStartup(uuid, {
|
||||
...variables,
|
||||
dockerImages: { [variables.dockerImage]: variables.dockerImage },
|
||||
rawStartupCommand: variables.invocation,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -307,26 +308,26 @@ const SoftwareContainer = () => {
|
||||
|
||||
{!visible && (
|
||||
<div className='relative rounded-xl overflow-hidden shadow-md border-[1px] border-[#ffffff07] bg-[#ffffff08]'>
|
||||
<div className='w-full h-full'>
|
||||
<div className='flex items-center justify-between pb-4 p-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<HugeIconsEggs fill='currentColor' />
|
||||
<div className='w-full h-full p-6'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<HugeIconsEggs fill='currentColor' className='w-8 h-8 text-brand' />
|
||||
<div className='flex flex-col'>
|
||||
<h1 className='text-2xl'>Current Egg</h1>
|
||||
<h1 className='text-2xl font-semibold text-zinc-100'>Current Egg</h1>
|
||||
{currentEggName &&
|
||||
(currentEggName?.includes(blank_egg_prefix) ? (
|
||||
<p className='text-neutral-300 text-sm'>Please select a egg</p>
|
||||
<p className='text-neutral-300 text-sm'>Please select an egg</p>
|
||||
) : (
|
||||
<p className='text-neutral-300 text-sm'>{currentEggName}</p>
|
||||
<p className='text-neutral-300 text-sm font-medium'>{currentEggName}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className='rounded-full border-[1px] cursor-pointer border-[#ffffff12] px-4 py-2 text-sm font-bold shadow-md hover:border-[#ffffff22] hover:shadow-lg bg-linear-to-b from-[#ffffff10] to-[#ffffff09] text-white'
|
||||
<ActionButton
|
||||
variant="primary"
|
||||
onClick={() => setVisible(true)}
|
||||
>
|
||||
Change Egg
|
||||
</button>
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -386,7 +387,7 @@ const SoftwareContainer = () => {
|
||||
<p className='text-neutral-200 text-md'>
|
||||
{nest.attributes.name}
|
||||
</p>
|
||||
<Button onClick={() => handleNestSelect(nest)}>Select</Button>
|
||||
<ActionButton variant="primary" onClick={() => handleNestSelect(nest)}>Select</ActionButton>
|
||||
</div>
|
||||
<p className='text-neutral-400 text-xs mt-2'>
|
||||
{nest.attributes.description}
|
||||
@@ -412,14 +413,15 @@ const SoftwareContainer = () => {
|
||||
>
|
||||
<div className='flex items-center justify-between'>
|
||||
<p className='text-neutral-300 text-md'>{egg.attributes.name}</p>
|
||||
<Button
|
||||
<ActionButton
|
||||
variant="primary"
|
||||
onClick={async () => {
|
||||
setSelectedEgg(egg);
|
||||
await handleEggSelect(egg);
|
||||
}}
|
||||
>
|
||||
Select
|
||||
</Button>
|
||||
</ActionButton>
|
||||
</div>
|
||||
<p className='text-neutral-400 text-xs mt-2'>
|
||||
{renderEggDescription(egg.attributes.description, eggIndex)}
|
||||
@@ -431,7 +433,9 @@ const SoftwareContainer = () => {
|
||||
)) ||
|
||||
(step == 1 && (
|
||||
<div className='flex items-center justify-center h-[63svh]'>
|
||||
<p className='text-neutral-300 '>Please select a game first</p>
|
||||
<div className='text-center'>
|
||||
<p className='text-neutral-300'>Please select a game first</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -534,12 +538,18 @@ const SoftwareContainer = () => {
|
||||
|
||||
<div className='border-t border-[#ffffff20]' />
|
||||
|
||||
<Button onClick={() => confirmSelection()}>Confirm</Button>
|
||||
<div className='flex justify-center'>
|
||||
<ActionButton variant="primary" onClick={() => confirmSelection()}>
|
||||
Confirm Selection
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
)) ||
|
||||
(step == 2 && !currentEggName?.includes(blank_egg_prefix) && (
|
||||
<div className='flex items-center justify-center h-[63svh]'>
|
||||
<p className='text-neutral-300 '>Please select a egg first</p>
|
||||
<div className='text-center'>
|
||||
<p className='text-neutral-300'>Please select an egg first</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -548,19 +558,18 @@ const SoftwareContainer = () => {
|
||||
)}
|
||||
|
||||
{!visible && (
|
||||
<div className='relative rounded-xl overflow-hidden shadow-md border-[1px] border-[#ffffff07] bg-[#ffffff08] mt-6 p-1 flex flex-row justify-between items-center'>
|
||||
<div className='flex flex-row items-center gap-2 h-full'>
|
||||
<HugeIconsAlert
|
||||
fill='currentColor'
|
||||
className='w-[40px] h-[40px] m-2 mr-0 text-brand hidden md:block'
|
||||
/>
|
||||
<div className='flex flex-col pb-1 m-2'>
|
||||
<h1 className='text-xl'>Danger Zone</h1>
|
||||
<p className='text-sm text-neutral-300'>
|
||||
During this process some files may be deleted or modified either make a backup before
|
||||
hand or pick the option when prompted.
|
||||
</p>
|
||||
<div className='relative rounded-xl overflow-hidden shadow-md border-[1px] border-[#ffffff07] bg-[#ffffff08] mt-6'>
|
||||
<div className='p-6'>
|
||||
<div className='flex items-center gap-3 mb-3'>
|
||||
<HugeIconsAlert
|
||||
fill='currentColor'
|
||||
className='w-6 h-6 text-brand flex-shrink-0'
|
||||
/>
|
||||
<h2 className='text-xl font-semibold text-zinc-100'>Danger Zone</h2>
|
||||
</div>
|
||||
<p className='text-sm text-neutral-300 leading-relaxed'>
|
||||
During this process some files may be deleted or modified. Either make a backup beforehand or select the backup option when prompted.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import isEqual from 'react-fast-compare';
|
||||
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import CopyOnClick from '@/components/elements/CopyOnClick';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -281,28 +282,34 @@ const StartupContainer = () => {
|
||||
</div>
|
||||
<div className='flex flex-col sm:flex-row gap-3 sm:gap-4 pt-4 border-t border-[#ffffff08]'>
|
||||
<InputSpinner visible={commandLoading}>
|
||||
<button
|
||||
<ActionButton
|
||||
variant='primary'
|
||||
size='md'
|
||||
onClick={updateCommand}
|
||||
disabled={commandLoading || !commandValue.trim()}
|
||||
className='w-full sm:w-auto sm:flex-1 lg:flex-none lg:min-w-[140px] px-4 py-3 sm:px-6 text-sm font-medium text-white bg-linear-to-b from-green-600/70 to-green-700/70 border border-green-500/40 rounded-xl hover:from-green-500/80 hover:to-green-600/80 hover:border-green-500/60 disabled:opacity-50 disabled:cursor-not-allowed transition-all touch-manipulation'
|
||||
className='w-full sm:w-auto sm:flex-1 lg:flex-none lg:min-w-[140px]'
|
||||
>
|
||||
{commandLoading ? 'Saving...' : 'Save Command'}
|
||||
</button>
|
||||
</ActionButton>
|
||||
</InputSpinner>
|
||||
<button
|
||||
<ActionButton
|
||||
variant='secondary'
|
||||
size='md'
|
||||
onClick={loadDefaultCommand}
|
||||
disabled={commandLoading}
|
||||
className='w-full sm:w-auto sm:flex-1 lg:flex-none lg:min-w-[140px] px-4 py-3 sm:px-6 text-sm font-medium text-neutral-200 bg-linear-to-b from-[#ffffff12] to-[#ffffff08] border border-[#ffffff20] rounded-xl hover:from-[#ffffff18] hover:to-[#ffffff12] hover:border-[#ffffff30] hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-all touch-manipulation'
|
||||
className='w-full sm:w-auto sm:flex-1 lg:flex-none lg:min-w-[140px]'
|
||||
>
|
||||
Load Default
|
||||
</button>
|
||||
<button
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
variant='secondary'
|
||||
size='md'
|
||||
onClick={cancelEditingCommand}
|
||||
disabled={commandLoading}
|
||||
className='w-full sm:w-auto sm:flex-1 lg:flex-none lg:min-w-[140px] px-4 py-3 sm:px-6 text-sm font-medium text-neutral-300 bg-linear-to-b from-[#ffffff08] to-[#ffffff05] border border-[#ffffff15] rounded-xl hover:from-[#ffffff12] hover:to-[#ffffff08] hover:border-[#ffffff25] hover:text-neutral-200 disabled:opacity-50 disabled:cursor-not-allowed transition-all touch-manipulation'
|
||||
className='w-full sm:w-auto sm:flex-1 lg:flex-none lg:min-w-[140px]'
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -312,12 +319,14 @@ const StartupContainer = () => {
|
||||
<div className='flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3'>
|
||||
<label className='text-sm font-medium text-neutral-300'>Raw Command</label>
|
||||
{canEditCommand && (
|
||||
<button
|
||||
<ActionButton
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
onClick={startEditingCommand}
|
||||
className='w-full sm:w-auto px-4 py-2.5 sm:py-2 text-sm sm:text-xs font-medium text-neutral-200 bg-linear-to-b from-[#ffffff10] to-[#ffffff08] border border-[#ffffff15] rounded-xl hover:from-[#ffffff15] hover:to-[#ffffff10] hover:border-[#ffffff25] hover:text-white transition-all touch-manipulation'
|
||||
className='w-full sm:w-auto'
|
||||
>
|
||||
Edit Command
|
||||
</button>
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
<CopyOnClick text={data.rawStartupCommand}>
|
||||
@@ -456,13 +465,15 @@ const StartupContainer = () => {
|
||||
{canEditDockerImage && (
|
||||
<div className='flex-shrink-0'>
|
||||
<InputSpinner visible={loading}>
|
||||
<button
|
||||
<ActionButton
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
onClick={() => setRevertModalVisible(true)}
|
||||
disabled={loading}
|
||||
className='w-full sm:w-auto px-4 py-2.5 text-sm font-medium text-amber-200 bg-linear-to-b from-amber-600/20 to-amber-700/20 border border-amber-500/40 rounded-xl hover:from-amber-500/30 hover:to-amber-600/30 hover:border-amber-500/60 hover:text-amber-100 disabled:opacity-50 disabled:cursor-not-allowed transition-all touch-manipulation'
|
||||
className='w-full sm:w-auto text-amber-200 bg-linear-to-b from-amber-600/20 to-amber-700/20 border-amber-500/40 hover:from-amber-500/30 hover:to-amber-600/30 hover:border-amber-500/60 hover:text-amber-100'
|
||||
>
|
||||
Revert to Default
|
||||
</button>
|
||||
</ActionButton>
|
||||
</InputSpinner>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import EditSubuserModal from '@/components/server/users/EditSubuserModal';
|
||||
|
||||
const AddSubuserButton = () => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EditSubuserModal visible={visible} onModalDismissed={() => setVisible(false)} />
|
||||
<button
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(124.75% 124.75% at 50.01% -10.55%, rgb(36, 36, 36) 0%, rgb(20, 20, 20) 100%)',
|
||||
}}
|
||||
className='px-8 py-3 border-[1px] border-[#ffffff12] rounded-full text-sm font-bold shadow-md cursor-pointer'
|
||||
onClick={() => setVisible(true)}
|
||||
>
|
||||
New User
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddSubuserButton;
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { faArrowLeft } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import { MainPageHeader } from '@/components/elements/MainPageHeader';
|
||||
import ServerContentBlock from '@/components/elements/ServerContentBlock';
|
||||
import UserFormComponent from '@/components/server/users/UserFormComponent';
|
||||
|
||||
import { ServerContext } from '@/state/server';
|
||||
|
||||
const CreateUserContainer = () => {
|
||||
const navigate = useNavigate();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const serverId = ServerContext.useStoreState((state) => state.server.data!.id);
|
||||
|
||||
const handleSuccess = () => {
|
||||
navigate(`/server/${serverId}/users`);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
navigate(`/server/${serverId}/users`);
|
||||
};
|
||||
|
||||
return (
|
||||
<ServerContentBlock title={'Create User'}>
|
||||
<MainPageHeader title={'Create New User'}>
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
onClick={() => navigate(`/server/${serverId}/users`)}
|
||||
className="flex items-center gap-2"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<FontAwesomeIcon icon={faArrowLeft} className="w-4 h-4" />
|
||||
Back to Users
|
||||
</ActionButton>
|
||||
</MainPageHeader>
|
||||
|
||||
<UserFormComponent
|
||||
onSuccess={handleSuccess}
|
||||
onCancel={handleCancel}
|
||||
flashKey="user:create"
|
||||
isSubmitting={isSubmitting}
|
||||
setIsSubmitting={setIsSubmitting}
|
||||
/>
|
||||
</ServerContentBlock>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateUserContainer;
|
||||
@@ -1,172 +0,0 @@
|
||||
import ModalContext from '@/context/ModalContext';
|
||||
import { Actions, useStoreActions, useStoreState } from 'easy-peasy';
|
||||
import { Form, Formik } from 'formik';
|
||||
import { useContext, useEffect, useRef } from 'react';
|
||||
import { array, object, string } from 'yup';
|
||||
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import Can from '@/components/elements/Can';
|
||||
import Field from '@/components/elements/Field';
|
||||
import { Button } from '@/components/elements/button/index';
|
||||
import PermissionRow from '@/components/server/users/PermissionRow';
|
||||
import PermissionTitleBox from '@/components/server/users/PermissionTitleBox';
|
||||
|
||||
import asModal from '@/hoc/asModal';
|
||||
|
||||
import createOrUpdateSubuser from '@/api/server/users/createOrUpdateSubuser';
|
||||
|
||||
import { ApplicationStore } from '@/state';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import { Subuser } from '@/state/server/subusers';
|
||||
|
||||
import { useDeepCompareMemo } from '@/plugins/useDeepCompareMemo';
|
||||
import { usePermissions } from '@/plugins/usePermissions';
|
||||
|
||||
type Props = {
|
||||
subuser?: Subuser;
|
||||
};
|
||||
|
||||
interface Values {
|
||||
email: string;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
const EditSubuserModal = ({ subuser }: Props) => {
|
||||
const ref = useRef<HTMLHeadingElement>(null);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
const appendSubuser = ServerContext.useStoreActions((actions) => actions.subusers.appendSubuser);
|
||||
const { clearFlashes, clearAndAddHttpError } = useStoreActions(
|
||||
(actions: Actions<ApplicationStore>) => actions.flashes,
|
||||
);
|
||||
const { dismiss, setPropOverrides } = useContext(ModalContext);
|
||||
|
||||
const isRootAdmin = useStoreState((state) => state.user.data!.rootAdmin);
|
||||
const permissions = useStoreState((state) => state.permissions.data);
|
||||
// The currently logged in user's permissions. We're going to filter out any permissions
|
||||
// that they should not need.
|
||||
const loggedInPermissions = ServerContext.useStoreState((state) => state.server.permissions);
|
||||
const [canEditUser] = usePermissions(subuser ? ['user.update'] : ['user.create']);
|
||||
|
||||
useEffect(() => {
|
||||
setPropOverrides({ title: subuser ? `Permissions for ${subuser.email}` : 'Create new subuser' });
|
||||
}, []);
|
||||
|
||||
// The permissions that can be modified by this user.
|
||||
const editablePermissions = useDeepCompareMemo(() => {
|
||||
const cleaned = Object.keys(permissions).map((key) =>
|
||||
Object.keys(permissions[key]?.keys ?? {}).map((pkey) => `${key}.${pkey}`),
|
||||
);
|
||||
|
||||
const list: string[] = ([] as string[]).concat.apply([], Object.values(cleaned));
|
||||
|
||||
if (isRootAdmin || (loggedInPermissions.length === 1 && loggedInPermissions[0] === '*')) {
|
||||
return list;
|
||||
}
|
||||
|
||||
return list.filter((key) => loggedInPermissions.indexOf(key) >= 0);
|
||||
}, [isRootAdmin, permissions, loggedInPermissions]);
|
||||
|
||||
const submit = (values: Values) => {
|
||||
setPropOverrides({ showSpinnerOverlay: true });
|
||||
clearFlashes('user:edit');
|
||||
|
||||
createOrUpdateSubuser(uuid, values, subuser)
|
||||
.then((subuser) => {
|
||||
appendSubuser(subuser);
|
||||
dismiss();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
setPropOverrides(null);
|
||||
clearAndAddHttpError({ key: 'user:edit', error });
|
||||
|
||||
if (ref.current) {
|
||||
ref.current.scrollIntoView();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
clearFlashes('user:edit');
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Formik
|
||||
onSubmit={submit}
|
||||
initialValues={
|
||||
{
|
||||
email: subuser?.email || '',
|
||||
permissions: subuser?.permissions || [],
|
||||
} as Values
|
||||
}
|
||||
validationSchema={object().shape({
|
||||
email: string()
|
||||
.max(191, 'Email addresses must not exceed 191 characters.')
|
||||
.email('A valid email address must be provided.')
|
||||
.required('A valid email address must be provided.'),
|
||||
permissions: array().of(string()),
|
||||
})}
|
||||
>
|
||||
<Form>
|
||||
<FlashMessageRender byKey={'user:edit'} />
|
||||
{!isRootAdmin && loggedInPermissions[0] !== '*' && (
|
||||
<div className={`mt-4 pl-4 py-2 border-l-4 border-blue-400`}>
|
||||
<p className={`text-sm text-zinc-300`}>
|
||||
Only permissions which your account is currently assigned may be selected when creating or
|
||||
modifying other users.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!subuser && (
|
||||
<div className={`mb-6`}>
|
||||
<Field
|
||||
name={'email'}
|
||||
label={'User Email'}
|
||||
description={
|
||||
'Enter the email address of the user you wish to invite as a subuser for this server.'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={`flex flex-col gap-4`}>
|
||||
{Object.keys(permissions)
|
||||
.filter((key) => key !== 'websocket')
|
||||
.map((key, _) => (
|
||||
<PermissionTitleBox
|
||||
key={`permission_${key}`}
|
||||
title={key}
|
||||
isEditable={canEditUser}
|
||||
permissions={Object.keys(permissions[key]?.keys ?? {}).map((pkey) => `${key}.${pkey}`)}
|
||||
>
|
||||
<p className={`text-sm text-neutral-400 mb-4`}>{permissions[key]?.description}</p>
|
||||
<div className='flex flex-col gap-4'>
|
||||
{Object.keys(permissions[key]?.keys ?? {}).map((pkey) => (
|
||||
<PermissionRow
|
||||
key={`permission_${key}.${pkey}`}
|
||||
permission={`${key}.${pkey}`}
|
||||
disabled={!canEditUser || editablePermissions.indexOf(`${key}.${pkey}`) < 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</PermissionTitleBox>
|
||||
))}
|
||||
</div>
|
||||
<Can action={subuser ? 'user.update' : 'user.create'}>
|
||||
<div className={`my-6 flex justify-end`}>
|
||||
<Button type={'submit'} className={`w-full sm:w-auto`}>
|
||||
{subuser ? 'Save' : 'Invite User'}
|
||||
</Button>
|
||||
</div>
|
||||
</Can>
|
||||
</Form>
|
||||
</Formik>
|
||||
);
|
||||
};
|
||||
|
||||
export default asModal<Props>({
|
||||
top: false,
|
||||
children: <EditSubuserModal />,
|
||||
})(EditSubuserModal);
|
||||
116
resources/scripts/components/server/users/EditUserContainer.tsx
Normal file
116
resources/scripts/components/server/users/EditUserContainer.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { faArrowLeft, faUser } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import { MainPageHeader } from '@/components/elements/MainPageHeader';
|
||||
import ServerContentBlock from '@/components/elements/ServerContentBlock';
|
||||
import UserFormComponent from '@/components/server/users/UserFormComponent';
|
||||
|
||||
import { ServerContext } from '@/state/server';
|
||||
import { Subuser } from '@/state/server/subusers';
|
||||
|
||||
const EditUserContainer = () => {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const serverId = ServerContext.useStoreState((state) => state.server.data!.id);
|
||||
const subusers = ServerContext.useStoreState((state) => state.subusers.data);
|
||||
|
||||
// Find the subuser by UUID
|
||||
const subuser = subusers.find((s: Subuser) => s.uuid === id);
|
||||
|
||||
useEffect(() => {
|
||||
// If subuser not found, redirect back to users list
|
||||
if (!subuser && subusers.length > 0) {
|
||||
navigate(`/server/${serverId}/users`);
|
||||
}
|
||||
}, [subuser, subusers, navigate, serverId]);
|
||||
|
||||
const handleSuccess = () => {
|
||||
navigate(`/server/${serverId}/users`);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
navigate(`/server/${serverId}/users`);
|
||||
};
|
||||
|
||||
// Show loading state while we're waiting for subusers to load
|
||||
if (!subuser && subusers.length === 0) {
|
||||
return (
|
||||
<ServerContentBlock title={'Edit User'}>
|
||||
<MainPageHeader title={'Edit User'}>
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
onClick={() => navigate(`/server/${serverId}/users`)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<FontAwesomeIcon icon={faArrowLeft} className="w-4 h-4" />
|
||||
Back to Users
|
||||
</ActionButton>
|
||||
</MainPageHeader>
|
||||
<div className='flex items-center justify-center py-12'>
|
||||
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-brand'></div>
|
||||
</div>
|
||||
</ServerContentBlock>
|
||||
);
|
||||
}
|
||||
|
||||
// If subuser not found after loading, show not found message
|
||||
if (!subuser) {
|
||||
return (
|
||||
<ServerContentBlock title={'Edit User'}>
|
||||
<MainPageHeader title={'Edit User'}>
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
onClick={() => navigate(`/server/${serverId}/users`)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<FontAwesomeIcon icon={faArrowLeft} className="w-4 h-4" />
|
||||
Back to Users
|
||||
</ActionButton>
|
||||
</MainPageHeader>
|
||||
<div className='flex flex-col items-center justify-center py-12 px-4'>
|
||||
<div className='text-center'>
|
||||
<div className='w-16 h-16 mx-auto mb-4 rounded-full bg-[#ffffff11] flex items-center justify-center'>
|
||||
<FontAwesomeIcon icon={faUser} className='w-8 h-8 text-zinc-400' />
|
||||
</div>
|
||||
<h3 className='text-lg font-medium text-zinc-200 mb-2'>User not found</h3>
|
||||
<p className='text-sm text-zinc-400 max-w-sm'>
|
||||
The user you're trying to edit could not be found.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</ServerContentBlock>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ServerContentBlock title={'Edit User'}>
|
||||
<MainPageHeader title={`Edit User: ${subuser.email}`}>
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
onClick={() => navigate(`/server/${serverId}/users`)}
|
||||
className="flex items-center gap-2"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<FontAwesomeIcon icon={faArrowLeft} className="w-4 h-4" />
|
||||
Back to Users
|
||||
</ActionButton>
|
||||
</MainPageHeader>
|
||||
|
||||
<UserFormComponent
|
||||
subuser={subuser}
|
||||
onSuccess={handleSuccess}
|
||||
onCancel={handleCancel}
|
||||
flashKey="user:edit"
|
||||
isSubmitting={isSubmitting}
|
||||
setIsSubmitting={setIsSubmitting}
|
||||
/>
|
||||
</ServerContentBlock>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditUserContainer;
|
||||
@@ -3,6 +3,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { Actions, useStoreActions } from 'easy-peasy';
|
||||
import { useState } from 'react';
|
||||
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import ConfirmationModal from '@/components/elements/ConfirmationModal';
|
||||
|
||||
import { httpErrorToHuman } from '@/api/http';
|
||||
@@ -48,15 +49,16 @@ const RemoveSubuserButton = ({ subuser }: { subuser: Subuser }) => {
|
||||
>
|
||||
All access to the server will be removed immediately.
|
||||
</ConfirmationModal>
|
||||
<button
|
||||
type={'button'}
|
||||
aria-label={'Delete subuser'}
|
||||
className={`text-sm p-2 text-zinc-500 hover:text-red-600 transition-colors duration-150 flex align-middle items-center justify-center flex-col cursor-pointer`}
|
||||
<ActionButton
|
||||
variant="danger"
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => setShowConfirmation(true)}
|
||||
aria-label="Delete subuser"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrashAlt} className={`px-5`} size='lg' />
|
||||
<FontAwesomeIcon icon={faTrashAlt} className="w-4 h-4" />
|
||||
Delete
|
||||
</button>
|
||||
</ActionButton>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
260
resources/scripts/components/server/users/UserFormComponent.tsx
Normal file
260
resources/scripts/components/server/users/UserFormComponent.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
import { Actions, useStoreActions, useStoreState } from 'easy-peasy';
|
||||
import { Form, Formik } from 'formik';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { array, object, string } from 'yup';
|
||||
import { faUser, faShield, faServer, faDatabase, faFile, faNetworkWired, faCog, faCalendarAlt, faClone } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import Can from '@/components/elements/Can';
|
||||
import Field from '@/components/elements/Field';
|
||||
import PermissionRow from '@/components/server/users/PermissionRow';
|
||||
|
||||
import createOrUpdateSubuser from '@/api/server/users/createOrUpdateSubuser';
|
||||
|
||||
import { ApplicationStore } from '@/state';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import { Subuser } from '@/state/server/subusers';
|
||||
|
||||
import { useDeepCompareMemo } from '@/plugins/useDeepCompareMemo';
|
||||
import { usePermissions } from '@/plugins/usePermissions';
|
||||
|
||||
interface Values {
|
||||
email: string;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
subuser?: Subuser;
|
||||
onSuccess: (subuser: Subuser) => void;
|
||||
onCancel: () => void;
|
||||
flashKey: string;
|
||||
isSubmitting?: boolean;
|
||||
setIsSubmitting?: (submitting: boolean) => void;
|
||||
}
|
||||
|
||||
const UserFormComponent = ({ subuser, onSuccess, onCancel, flashKey, isSubmitting, setIsSubmitting }: Props) => {
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
const appendSubuser = ServerContext.useStoreActions((actions) => actions.subusers.appendSubuser);
|
||||
const { clearFlashes, clearAndAddHttpError } = useStoreActions(
|
||||
(actions: Actions<ApplicationStore>) => actions.flashes,
|
||||
);
|
||||
|
||||
const isRootAdmin = useStoreState((state) => state.user.data!.rootAdmin);
|
||||
const permissions = useStoreState((state) => state.permissions.data);
|
||||
const loggedInPermissions = ServerContext.useStoreState((state) => state.server.permissions);
|
||||
const [canEditUser] = usePermissions(subuser ? ['user.update'] : ['user.create']);
|
||||
|
||||
// The permissions that can be modified by this user.
|
||||
const editablePermissions = useDeepCompareMemo(() => {
|
||||
const cleaned = Object.keys(permissions).map((key) =>
|
||||
Object.keys(permissions[key]?.keys ?? {}).map((pkey) => `${key}.${pkey}`),
|
||||
);
|
||||
|
||||
const list: string[] = ([] as string[]).concat.apply([], Object.values(cleaned));
|
||||
|
||||
if (isRootAdmin || (loggedInPermissions.length === 1 && loggedInPermissions[0] === '*')) {
|
||||
return list;
|
||||
}
|
||||
|
||||
return list.filter((key) => loggedInPermissions.indexOf(key) >= 0);
|
||||
}, [isRootAdmin, permissions, loggedInPermissions]);
|
||||
|
||||
const submit = (values: Values) => {
|
||||
if (setIsSubmitting) setIsSubmitting(true);
|
||||
clearFlashes(flashKey);
|
||||
|
||||
createOrUpdateSubuser(uuid, values, subuser)
|
||||
.then((subuser) => {
|
||||
appendSubuser(subuser);
|
||||
onSuccess(subuser);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
if (setIsSubmitting) setIsSubmitting(false);
|
||||
clearAndAddHttpError({ key: flashKey, error });
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
clearFlashes(flashKey);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
|
||||
const getPermissionIcon = (key: string) => {
|
||||
switch (key) {
|
||||
case 'control': return faServer;
|
||||
case 'user': return faUser;
|
||||
case 'file': return faFile;
|
||||
case 'backup': return faClone;
|
||||
case 'allocation': return faNetworkWired;
|
||||
case 'startup': return faCog;
|
||||
case 'database': return faDatabase;
|
||||
case 'schedule': return faCalendarAlt;
|
||||
default: return faShield;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<FlashMessageRender byKey={flashKey} />
|
||||
|
||||
<Formik
|
||||
onSubmit={submit}
|
||||
initialValues={
|
||||
{
|
||||
email: subuser?.email || '',
|
||||
permissions: subuser?.permissions || [],
|
||||
} as Values
|
||||
}
|
||||
validationSchema={object().shape({
|
||||
email: string()
|
||||
.max(191, 'Email addresses must not exceed 191 characters.')
|
||||
.email('A valid email address must be provided.')
|
||||
.required('A valid email address must be provided.'),
|
||||
permissions: array().of(string()),
|
||||
})}
|
||||
>
|
||||
{({ setFieldValue, values }) => (
|
||||
<Form className="space-y-6">
|
||||
{/* User Information Section */}
|
||||
{!subuser && (
|
||||
<div className="bg-gradient-to-b from-[#ffffff08] to-[#ffffff05] border border-[#ffffff12] rounded-xl p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-lg bg-brand/20 flex items-center justify-center">
|
||||
<FontAwesomeIcon icon={faUser} className="w-5 h-5 text-brand" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-zinc-100">User Information</h3>
|
||||
</div>
|
||||
<Field
|
||||
name={'email'}
|
||||
label={'Email Address'}
|
||||
description={'Enter the email address of the user you wish to invite as a subuser for this server.'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Permissions Section */}
|
||||
<div className="bg-gradient-to-b from-[#ffffff08] to-[#ffffff05] border border-[#ffffff12] rounded-xl p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-brand/20 flex items-center justify-center">
|
||||
<FontAwesomeIcon icon={faCog} className="w-5 h-5 text-brand" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-zinc-100">Detailed Permissions</h3>
|
||||
</div>
|
||||
{canEditUser && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const allPermissions = editablePermissions;
|
||||
const allSelected = allPermissions.every(p => values.permissions.includes(p));
|
||||
if (allSelected) {
|
||||
setFieldValue('permissions', []);
|
||||
} else {
|
||||
setFieldValue('permissions', [...allPermissions]);
|
||||
}
|
||||
}}
|
||||
className="text-sm px-4 py-2 rounded-lg bg-brand/10 hover:bg-brand/20 text-brand border border-brand/20 hover:border-brand/30 transition-colors font-medium"
|
||||
>
|
||||
{editablePermissions.every(p => values.permissions.includes(p)) ? 'Deselect All' : 'Select All'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isRootAdmin && loggedInPermissions[0] !== '*' && (
|
||||
<div className="mb-6 p-4 bg-brand/10 border border-brand/20 rounded-lg">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<FontAwesomeIcon icon={faShield} className="w-5 h-5 text-brand" />
|
||||
<span className="text-sm font-semibold text-brand">Permission Restriction</span>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-300 leading-relaxed">
|
||||
You can only assign permissions that you currently have access to.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{Object.keys(permissions)
|
||||
.filter((key) => key !== 'websocket')
|
||||
.map((key) => (
|
||||
<div key={key} className="border border-[#ffffff12] rounded-lg p-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-start gap-3 flex-1 min-w-0">
|
||||
<FontAwesomeIcon icon={getPermissionIcon(key)} className="w-4 h-4 text-brand flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-medium text-zinc-200 capitalize">{key}</h4>
|
||||
<p className="text-xs text-zinc-400 mt-1 break-words">{permissions[key]?.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
{canEditUser && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const categoryPermissions = Object.keys(permissions[key]?.keys ?? {}).map((pkey) => `${key}.${pkey}`);
|
||||
const allSelected = categoryPermissions.every(p => values.permissions.includes(p));
|
||||
if (allSelected) {
|
||||
setFieldValue('permissions', values.permissions.filter(p => !categoryPermissions.includes(p)));
|
||||
} else {
|
||||
const newPermissions = [...values.permissions];
|
||||
categoryPermissions.forEach(p => {
|
||||
if (!newPermissions.includes(p) && editablePermissions.includes(p)) {
|
||||
newPermissions.push(p);
|
||||
}
|
||||
});
|
||||
setFieldValue('permissions', newPermissions);
|
||||
}
|
||||
}}
|
||||
className="text-xs px-3 py-1.5 rounded bg-zinc-700 hover:bg-zinc-600 text-zinc-300 transition-colors whitespace-nowrap flex-shrink-0"
|
||||
>
|
||||
{Object.keys(permissions[key]?.keys ?? {}).map((pkey) => `${key}.${pkey}`).every(p => values.permissions.includes(p)) ? 'Deselect All' : 'Select All'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{Object.keys(permissions[key]?.keys ?? {}).map((pkey) => (
|
||||
<PermissionRow
|
||||
key={`permission_${key}.${pkey}`}
|
||||
permission={`${key}.${pkey}`}
|
||||
disabled={!canEditUser || editablePermissions.indexOf(`${key}.${pkey}`) < 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Can action={subuser ? 'user.update' : 'user.create'}>
|
||||
<div className="flex gap-3 justify-end pt-4 border-t border-[#ffffff12]">
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
variant="primary"
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{subuser ? 'Save Changes' : 'Invite User'}
|
||||
</ActionButton>
|
||||
</div>
|
||||
</Can>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserFormComponent;
|
||||
@@ -1,13 +1,14 @@
|
||||
import { faEdit } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { useStoreState } from 'easy-peasy';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import Can from '@/components/elements/Can';
|
||||
import { PageListItem } from '@/components/elements/pages/PageList';
|
||||
import EditSubuserModal from '@/components/server/users/EditSubuserModal';
|
||||
import RemoveSubuserButton from '@/components/server/users/RemoveSubuserButton';
|
||||
|
||||
import { ServerContext } from '@/state/server';
|
||||
import { Subuser } from '@/state/server/subusers';
|
||||
|
||||
interface Props {
|
||||
@@ -16,11 +17,15 @@ interface Props {
|
||||
|
||||
const UserRow = ({ subuser }: Props) => {
|
||||
const uuid = useStoreState((state) => state.user!.data!.uuid);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const serverId = ServerContext.useStoreState((state) => state.server.data!.id);
|
||||
|
||||
const handleEditClick = () => {
|
||||
navigate(`/server/${serverId}/users/${subuser.uuid}/edit`);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageListItem>
|
||||
<EditSubuserModal subuser={subuser} visible={visible} onModalDismissed={() => setVisible(false)} />
|
||||
<div className={`w-10 h-10 rounded-full bg-white border-2 border-zinc-800 overflow-hidden hidden md:block`}>
|
||||
<img className={`w-full h-full`} src={`${subuser.image}?s=400`} />
|
||||
</div>
|
||||
@@ -40,21 +45,22 @@ const UserRow = ({ subuser }: Props) => {
|
||||
</div>
|
||||
{subuser.uuid !== uuid && (
|
||||
<>
|
||||
<div className='flex align-middle items-center justify-center'>
|
||||
<div className='flex align-middle items-center justify-center gap-2'>
|
||||
<Can action={'user.update'}>
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
onClick={handleEditClick}
|
||||
aria-label="Edit subuser"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEdit} className="w-4 h-4" />
|
||||
Edit
|
||||
</ActionButton>
|
||||
</Can>
|
||||
<Can action={'user.delete'}>
|
||||
<RemoveSubuserButton subuser={subuser} />
|
||||
</Can>
|
||||
<Can action={'user.update'}>
|
||||
<button
|
||||
type={'button'}
|
||||
aria-label={'Edit subuser'}
|
||||
className={`text-sm p-2 text-zinc-500 hover:text-zinc-100 transition-colors duration-150 flex align-middle items-center justify-center flex-col cursor-pointer`}
|
||||
onClick={() => setVisible(true)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faEdit} className={`px-5`} size='lg' />
|
||||
Edit
|
||||
</button>
|
||||
</Can>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { Actions, useStoreActions, useStoreState } from 'easy-peasy';
|
||||
import { For } from 'million/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { faUser, faPlus } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import Can from '@/components/elements/Can';
|
||||
import { MainPageHeader } from '@/components/elements/MainPageHeader';
|
||||
import ServerContentBlock from '@/components/elements/ServerContentBlock';
|
||||
import { PageListContainer } from '@/components/elements/pages/PageList';
|
||||
import AddSubuserButton from '@/components/server/users/AddSubuserButton';
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import UserRow from '@/components/server/users/UserRow';
|
||||
|
||||
import { httpErrorToHuman } from '@/api/http';
|
||||
@@ -18,8 +21,10 @@ import { ServerContext } from '@/state/server';
|
||||
|
||||
const UsersContainer = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
const serverId = ServerContext.useStoreState((state) => state.server.data!.id);
|
||||
const subusers = ServerContext.useStoreState((state) => state.subusers.data);
|
||||
const setSubusers = ServerContext.useStoreActions((actions) => actions.subusers.setSubusers);
|
||||
|
||||
@@ -50,7 +55,27 @@ const UsersContainer = () => {
|
||||
if (!subusers.length && (loading || !Object.keys(permissions).length)) {
|
||||
return (
|
||||
<ServerContentBlock title={'Users'}>
|
||||
<h1 className='text-[52px] font-extrabold leading-[98%] tracking-[-0.14rem]'>Users</h1>
|
||||
<FlashMessageRender byKey={'users'} />
|
||||
<MainPageHeader title={'Users'}>
|
||||
<div className='flex flex-col sm:flex-row items-center justify-end gap-4'>
|
||||
<p className='text-sm text-zinc-300 text-center sm:text-right'>
|
||||
0 users
|
||||
</p>
|
||||
<Can action={'user.create'}>
|
||||
<ActionButton
|
||||
variant='primary'
|
||||
onClick={() => navigate(`/server/${serverId}/users/new`)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} className="w-4 h-4" />
|
||||
New User
|
||||
</ActionButton>
|
||||
</Can>
|
||||
</div>
|
||||
</MainPageHeader>
|
||||
<div className='flex items-center justify-center py-12'>
|
||||
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-brand'></div>
|
||||
</div>
|
||||
</ServerContentBlock>
|
||||
);
|
||||
}
|
||||
@@ -59,14 +84,34 @@ const UsersContainer = () => {
|
||||
<ServerContentBlock title={'Users'}>
|
||||
<FlashMessageRender byKey={'users'} />
|
||||
<MainPageHeader title={'Users'}>
|
||||
<Can action={'user.create'}>
|
||||
<AddSubuserButton />
|
||||
</Can>
|
||||
<div className='flex flex-col sm:flex-row items-center justify-end gap-4'>
|
||||
<p className='text-sm text-zinc-300 text-center sm:text-right'>
|
||||
{subusers.length} users
|
||||
</p>
|
||||
<Can action={'user.create'}>
|
||||
<ActionButton
|
||||
variant='primary'
|
||||
onClick={() => navigate(`/server/${serverId}/users/new`)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} className="w-4 h-4" />
|
||||
New User
|
||||
</ActionButton>
|
||||
</Can>
|
||||
</div>
|
||||
</MainPageHeader>
|
||||
{!subusers.length ? (
|
||||
<p className={`text-center text-sm text-zinc-300`}>
|
||||
Your server does not have any additional users. Add others to help you manage your server.
|
||||
</p>
|
||||
<div className='flex flex-col items-center justify-center py-12 px-4'>
|
||||
<div className='text-center'>
|
||||
<div className='w-16 h-16 mx-auto mb-4 rounded-full bg-[#ffffff11] flex items-center justify-center'>
|
||||
<FontAwesomeIcon icon={faUser} className='w-8 h-8 text-zinc-400' />
|
||||
</div>
|
||||
<h3 className='text-lg font-medium text-zinc-200 mb-2'>No users found</h3>
|
||||
<p className='text-sm text-zinc-400 max-w-sm'>
|
||||
Your server does not have any additional users. Add others to help you manage your server.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<PageListContainer data-pyro-users-container-users>
|
||||
<For each={subusers} memo>
|
||||
|
||||
@@ -17,6 +17,8 @@ import SettingsContainer from '@/components/server/settings/SettingsContainer';
|
||||
import ShellContainer from '@/components/server/shell/ShellContainer';
|
||||
import StartupContainer from '@/components/server/startup/StartupContainer';
|
||||
import UsersContainer from '@/components/server/users/UsersContainer';
|
||||
import CreateUserContainer from '@/components/server/users/CreateUserContainer';
|
||||
import EditUserContainer from '@/components/server/users/EditUserContainer';
|
||||
|
||||
// Each of the router files is already code split out appropriately — so
|
||||
// all the items above will only be loaded in when that router is loaded.
|
||||
@@ -131,6 +133,18 @@ export default {
|
||||
name: 'Users',
|
||||
component: UsersContainer,
|
||||
},
|
||||
{
|
||||
route: 'users/new',
|
||||
permission: 'user.*',
|
||||
name: undefined,
|
||||
component: CreateUserContainer,
|
||||
},
|
||||
{
|
||||
route: 'users/:id/edit',
|
||||
permission: 'user.*',
|
||||
name: undefined,
|
||||
component: EditUserContainer,
|
||||
},
|
||||
{
|
||||
route: 'backups/*',
|
||||
path: 'backups',
|
||||
|
||||
Reference in New Issue
Block a user