Merge branch 'develop' into develop
This commit is contained in:
commit
665a4dd8a4
115 changed files with 3434 additions and 1970 deletions
|
@ -1,19 +1,22 @@
|
|||
import React, { useEffect, useMemo, useRef } from 'react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ITerminalOptions, Terminal } from 'xterm';
|
||||
import * as TerminalFit from 'xterm/lib/addons/fit/fit';
|
||||
import { FitAddon } from 'xterm-addon-fit';
|
||||
import { SearchAddon } from 'xterm-addon-search';
|
||||
import { SearchBarAddon } from 'xterm-addon-search-bar';
|
||||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import styled from 'styled-components/macro';
|
||||
import { usePermissions } from '@/plugins/usePermissions';
|
||||
import tw from 'twin.macro';
|
||||
import 'xterm/dist/xterm.css';
|
||||
import tw, { theme as th } from 'twin.macro';
|
||||
import 'xterm/css/xterm.css';
|
||||
import useEventListener from '@/plugins/useEventListener';
|
||||
import { debounce } from 'debounce';
|
||||
import { usePersistedState } from '@/plugins/usePersistedState';
|
||||
|
||||
const theme = {
|
||||
background: 'transparent',
|
||||
background: th`colors.black`.toString(),
|
||||
cursor: 'transparent',
|
||||
black: '#000000',
|
||||
black: th`colors.black`.toString(),
|
||||
red: '#E54B4B',
|
||||
green: '#9ECE58',
|
||||
yellow: '#FAED70',
|
||||
|
@ -29,6 +32,7 @@ const theme = {
|
|||
brightMagenta: '#C792EA',
|
||||
brightCyan: '#89DDFF',
|
||||
brightWhite: '#ffffff',
|
||||
selection: '#FAF089',
|
||||
};
|
||||
|
||||
const terminalProps: ITerminalOptions = {
|
||||
|
@ -55,8 +59,14 @@ export default () => {
|
|||
const TERMINAL_PRELUDE = '\u001b[1m\u001b[33mcontainer@pterodactyl~ \u001b[0m';
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const terminal = useMemo(() => new Terminal({ ...terminalProps }), []);
|
||||
const fitAddon = new FitAddon();
|
||||
const searchAddon = new SearchAddon();
|
||||
const searchBar = new SearchBarAddon({ searchAddon });
|
||||
const { connected, instance } = ServerContext.useStoreState(state => state.socket);
|
||||
const [ canSendCommands ] = usePermissions([ 'control.console' ]);
|
||||
const serverId = ServerContext.useStoreState(state => state.server.data!.id);
|
||||
const [ history, setHistory ] = usePersistedState<string[]>(`${serverId}:command_history`, []);
|
||||
const [ historyIndex, setHistoryIndex ] = useState(-1);
|
||||
|
||||
const handleConsoleOutput = (line: string, prelude = false) => terminal.writeln(
|
||||
(prelude ? TERMINAL_PRELUDE : '') + line.replace(/(?:\r\n|\r|\n)$/im, '') + '\u001b[0m',
|
||||
|
@ -71,37 +81,61 @@ export default () => {
|
|||
);
|
||||
|
||||
const handleCommandKeydown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key !== 'Enter' || (e.key === 'Enter' && e.currentTarget.value.length < 1)) {
|
||||
return;
|
||||
if (e.key === 'ArrowUp') {
|
||||
const newIndex = Math.min(historyIndex + 1, history!.length - 1);
|
||||
|
||||
setHistoryIndex(newIndex);
|
||||
e.currentTarget.value = history![newIndex] || '';
|
||||
}
|
||||
|
||||
instance && instance.send('send command', e.currentTarget.value);
|
||||
e.currentTarget.value = '';
|
||||
if (e.key === 'ArrowDown') {
|
||||
const newIndex = Math.max(historyIndex - 1, -1);
|
||||
|
||||
setHistoryIndex(newIndex);
|
||||
e.currentTarget.value = history![newIndex] || '';
|
||||
}
|
||||
|
||||
const command = e.currentTarget.value;
|
||||
if (e.key === 'Enter' && command.length > 0) {
|
||||
setHistory(prevHistory => [ command, ...prevHistory! ].slice(0, 32));
|
||||
setHistoryIndex(-1);
|
||||
|
||||
instance && instance.send('send command', command);
|
||||
e.currentTarget.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (connected && ref.current && !terminal.element) {
|
||||
terminal.open(ref.current);
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.loadAddon(searchAddon);
|
||||
terminal.loadAddon(searchBar);
|
||||
fitAddon.fit();
|
||||
|
||||
// @see https://github.com/xtermjs/xterm.js/issues/2265
|
||||
// @see https://github.com/xtermjs/xterm.js/issues/2230
|
||||
TerminalFit.fit(terminal);
|
||||
|
||||
// Add support for copying terminal text.
|
||||
// Add support for capturing keys
|
||||
terminal.attachCustomKeyEventHandler((e: KeyboardEvent) => {
|
||||
// Ctrl + C
|
||||
if (e.ctrlKey && (e.key === 'c')) {
|
||||
// Ctrl + C ( Copy )
|
||||
if (e.ctrlKey && e.key === 'c') {
|
||||
document.execCommand('copy');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (e.ctrlKey && e.key === 'f') {
|
||||
searchBar.show();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
searchBar.hidden();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}, [ terminal, connected ]);
|
||||
|
||||
const fit = debounce(() => {
|
||||
TerminalFit.fit(terminal);
|
||||
fitAddon.fit();
|
||||
}, 100);
|
||||
|
||||
useEventListener('resize', () => fit());
|
||||
|
|
|
@ -16,6 +16,7 @@ import Select from '@/components/elements/Select';
|
|||
import modes from '@/modes';
|
||||
import useFlash from '@/plugins/useFlash';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import ErrorBoundary from '@/components/elements/ErrorBoundary';
|
||||
|
||||
const LazyCodemirrorEditor = lazy(() => import(/* webpackChunkName: "editor" */'@/components/elements/CodemirrorEditor'));
|
||||
|
||||
|
@ -60,9 +61,7 @@ export default () => {
|
|||
setLoading(true);
|
||||
clearFlashes('files:view');
|
||||
fetchFileContent()
|
||||
.then(content => {
|
||||
return saveFileContents(uuid, name || hash.replace(/^#/, ''), content);
|
||||
})
|
||||
.then(content => saveFileContents(uuid, encodeURIComponent(name || hash.replace(/^#/, '')), content))
|
||||
.then(() => {
|
||||
if (name) {
|
||||
history.push(`/server/${id}/files/edit#/${name}`);
|
||||
|
@ -87,7 +86,9 @@ export default () => {
|
|||
return (
|
||||
<PageContentBlock>
|
||||
<FlashMessageRender byKey={'files:view'} css={tw`mb-4`}/>
|
||||
<FileManagerBreadcrumbs withinFileEditor isNewFile={action !== 'edit'}/>
|
||||
<ErrorBoundary>
|
||||
<FileManagerBreadcrumbs withinFileEditor isNewFile={action !== 'edit'}/>
|
||||
</ErrorBoundary>
|
||||
{hash.replace(/^#/, '').endsWith('.pteroignore') &&
|
||||
<div css={tw`mb-4 p-4 border-l-4 bg-neutral-900 rounded border-cyan-400`}>
|
||||
<p css={tw`text-neutral-300 text-sm`}>
|
||||
|
|
|
@ -33,10 +33,10 @@ export default ({ withinFileEditor, isNewFile }: Props) => {
|
|||
.filter(directory => !!directory)
|
||||
.map((directory, index, dirs) => {
|
||||
if (!withinFileEditor && index === dirs.length - 1) {
|
||||
return { name: decodeURIComponent(directory) };
|
||||
return { name: decodeURIComponent(encodeURIComponent(directory)) };
|
||||
}
|
||||
|
||||
return { name: decodeURIComponent(directory), path: `/${dirs.slice(0, index + 1).join('/')}` };
|
||||
return { name: decodeURIComponent(encodeURIComponent(directory)), path: `/${dirs.slice(0, index + 1).join('/')}` };
|
||||
});
|
||||
|
||||
const onSelectAllClick = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
|
@ -79,7 +79,7 @@ export default ({ withinFileEditor, isNewFile }: Props) => {
|
|||
}
|
||||
{file &&
|
||||
<React.Fragment>
|
||||
<span css={tw`px-1 text-neutral-300`}>{decodeURIComponent(file)}</span>
|
||||
<span css={tw`px-1 text-neutral-300`}>{decodeURIComponent(encodeURIComponent(file))}</span>
|
||||
</React.Fragment>
|
||||
}
|
||||
</div>
|
||||
|
|
|
@ -17,6 +17,7 @@ import MassActionsBar from '@/components/server/files/MassActionsBar';
|
|||
import UploadButton from '@/components/server/files/UploadButton';
|
||||
import ServerContentBlock from '@/components/elements/ServerContentBlock';
|
||||
import { useStoreActions } from '@/state/hooks';
|
||||
import ErrorBoundary from '@/components/elements/ErrorBoundary';
|
||||
|
||||
const sortFiles = (files: FileObject[]): FileObject[] => {
|
||||
return files.sort((a, b) => a.name.localeCompare(b.name))
|
||||
|
@ -50,7 +51,9 @@ export default () => {
|
|||
|
||||
return (
|
||||
<ServerContentBlock title={'File Manager'} showFlashKey={'files'}>
|
||||
<FileManagerBreadcrumbs/>
|
||||
<ErrorBoundary>
|
||||
<FileManagerBreadcrumbs/>
|
||||
</ErrorBoundary>
|
||||
{
|
||||
!files ?
|
||||
<Spinner size={'large'} centered/>
|
||||
|
@ -81,18 +84,20 @@ export default () => {
|
|||
</CSSTransition>
|
||||
}
|
||||
<Can action={'file.create'}>
|
||||
<div css={tw`flex flex-wrap-reverse justify-end mt-4`}>
|
||||
<NewDirectoryButton css={tw`w-full flex-none mt-4 sm:mt-0 sm:w-auto sm:mr-4`}/>
|
||||
<UploadButton css={tw`flex-1 mr-4 sm:flex-none sm:mt-0`}/>
|
||||
<NavLink
|
||||
to={`/server/${id}/files/new${window.location.hash}`}
|
||||
css={tw`flex-1 sm:flex-none sm:mt-0`}
|
||||
>
|
||||
<Button css={tw`w-full`}>
|
||||
New File
|
||||
</Button>
|
||||
</NavLink>
|
||||
</div>
|
||||
<ErrorBoundary>
|
||||
<div css={tw`flex flex-wrap-reverse justify-end mt-4`}>
|
||||
<NewDirectoryButton css={tw`w-full flex-none mt-4 sm:mt-0 sm:w-auto sm:mr-4`}/>
|
||||
<UploadButton css={tw`flex-1 mr-4 sm:flex-none sm:mt-0`}/>
|
||||
<NavLink
|
||||
to={`/server/${id}/files/new${window.location.hash}`}
|
||||
css={tw`flex-1 sm:flex-none sm:mt-0`}
|
||||
>
|
||||
<Button css={tw`w-full`}>
|
||||
New File
|
||||
</Button>
|
||||
</NavLink>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
</Can>
|
||||
</>
|
||||
}
|
||||
|
|
|
@ -92,9 +92,9 @@ export default ({ className }: WithClassname) => {
|
|||
<span css={tw`text-neutral-200`}>This directory will be created as</span>
|
||||
/home/container/
|
||||
<span css={tw`text-cyan-200`}>
|
||||
{decodeURIComponent(
|
||||
{decodeURIComponent(encodeURIComponent(
|
||||
join(directory, values.directoryName).replace(/^(\.\.\/|\/)+/, ''),
|
||||
)}
|
||||
))}
|
||||
</span>
|
||||
</p>
|
||||
<div css={tw`flex justify-end`}>
|
||||
|
|
|
@ -14,6 +14,7 @@ import { debounce } from 'debounce';
|
|||
import setServerAllocationNotes from '@/api/server/network/setServerAllocationNotes';
|
||||
import useFlash from '@/plugins/useFlash';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import CopyOnClick from '@/components/elements/CopyOnClick';
|
||||
|
||||
const Code = styled.code`${tw`font-mono py-1 px-2 bg-neutral-900 rounded text-sm inline-block`}`;
|
||||
const Label = styled.label`${tw`uppercase text-xs mt-1 text-neutral-400 block px-1 select-none transition-colors duration-150`}`;
|
||||
|
@ -43,11 +44,12 @@ const AllocationRow = ({ allocation, onSetPrimary, onNotesChanged }: Props) => {
|
|||
<GreyRowBox $hoverable={false} css={tw`flex-wrap md:flex-no-wrap mt-2`}>
|
||||
<div css={tw`flex items-center w-full md:w-auto`}>
|
||||
<div css={tw`pl-4 pr-6 text-neutral-400`}>
|
||||
<FontAwesomeIcon icon={faNetworkWired}/>
|
||||
<FontAwesomeIcon icon={faNetworkWired} />
|
||||
</div>
|
||||
<div css={tw`mr-4 flex-1 md:w-40`}>
|
||||
<Code>{allocation.alias || allocation.ip}</Code>
|
||||
<Label>IP Address</Label>
|
||||
{allocation.alias ? <CopyOnClick text={allocation.alias}><Code css={tw`w-40 truncate`}>{allocation.alias}</Code></CopyOnClick> :
|
||||
<CopyOnClick text={allocation.ip}><Code>{allocation.ip}</Code></CopyOnClick>}
|
||||
<Label>{allocation.alias ? 'Hostname' : 'IP Address'}</Label>
|
||||
</div>
|
||||
<div css={tw`w-16 md:w-24 overflow-hidden`}>
|
||||
<Code>{allocation.port}</Code>
|
||||
|
|
|
@ -49,7 +49,7 @@ export default ({ scheduleId, onDeleted }: Props) => {
|
|||
Are you sure you want to delete this schedule? All tasks will be removed and any running processes
|
||||
will be terminated.
|
||||
</ConfirmationModal>
|
||||
<Button css={tw`mr-4`} color={'red'} isSecondary onClick={() => setVisible(true)}>
|
||||
<Button css={tw`flex-1 sm:flex-none mr-4 border-transparent`} color={'red'} isSecondary onClick={() => setVisible(true)}>
|
||||
Delete
|
||||
</Button>
|
||||
</>
|
||||
|
|
|
@ -2,6 +2,7 @@ import React, { useState } from 'react';
|
|||
import { Schedule } from '@/api/server/schedules/getServerSchedules';
|
||||
import TaskDetailsModal from '@/components/server/schedules/TaskDetailsModal';
|
||||
import Button from '@/components/elements/Button';
|
||||
import tw from 'twin.macro';
|
||||
|
||||
interface Props {
|
||||
schedule: Schedule;
|
||||
|
@ -18,7 +19,7 @@ export default ({ schedule }: Props) => {
|
|||
onDismissed={() => setVisible(false)}
|
||||
/>
|
||||
}
|
||||
<Button onClick={() => setVisible(true)}>
|
||||
<Button onClick={() => setVisible(true)} css={tw`flex-1`}>
|
||||
New Task
|
||||
</Button>
|
||||
</>
|
||||
|
|
|
@ -0,0 +1,48 @@
|
|||
import React, { useCallback, useState } from 'react';
|
||||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||
import tw from 'twin.macro';
|
||||
import Button from '@/components/elements/Button';
|
||||
import triggerScheduleExecution from '@/api/server/schedules/triggerScheduleExecution';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import useFlash from '@/plugins/useFlash';
|
||||
import { Schedule } from '@/api/server/schedules/getServerSchedules';
|
||||
|
||||
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
|
||||
isSecondary
|
||||
color={'grey'}
|
||||
css={tw`flex-1 sm:flex-none border-transparent`}
|
||||
disabled={schedule.isProcessing}
|
||||
onClick={onTriggerExecute}
|
||||
>
|
||||
Run Now
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default RunScheduleButton;
|
|
@ -0,0 +1,35 @@
|
|||
import React from 'react';
|
||||
import tw from 'twin.macro';
|
||||
import { Schedule } from '@/api/server/schedules/getServerSchedules';
|
||||
|
||||
interface Props {
|
||||
cron: Schedule['cron'];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ScheduleCronRow = ({ cron, className }: Props) => (
|
||||
<div css={tw`flex`} className={className}>
|
||||
<div css={tw`w-1/5 sm:w-auto text-center`}>
|
||||
<p css={tw`font-medium`}>{cron.minute}</p>
|
||||
<p css={tw`text-2xs text-neutral-500 uppercase`}>Minute</p>
|
||||
</div>
|
||||
<div css={tw`w-1/5 sm:w-auto text-center ml-4`}>
|
||||
<p css={tw`font-medium`}>{cron.hour}</p>
|
||||
<p css={tw`text-2xs text-neutral-500 uppercase`}>Hour</p>
|
||||
</div>
|
||||
<div css={tw`w-1/5 sm:w-auto text-center ml-4`}>
|
||||
<p css={tw`font-medium`}>{cron.dayOfMonth}</p>
|
||||
<p css={tw`text-2xs text-neutral-500 uppercase`}>Day (Month)</p>
|
||||
</div>
|
||||
<div css={tw`w-1/5 sm:w-auto text-center ml-4`}>
|
||||
<p css={tw`font-medium`}>*</p>
|
||||
<p css={tw`text-2xs text-neutral-500 uppercase`}>Month</p>
|
||||
</div>
|
||||
<div css={tw`w-1/5 sm:w-auto text-center ml-4`}>
|
||||
<p css={tw`font-medium`}>{cron.dayOfWeek}</p>
|
||||
<p css={tw`text-2xs text-neutral-500 uppercase`}>Day (Week)</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default ScheduleCronRow;
|
|
@ -1,12 +1,9 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { RouteComponentProps } from 'react-router-dom';
|
||||
import { Schedule } from '@/api/server/schedules/getServerSchedules';
|
||||
import getServerSchedule from '@/api/server/schedules/getServerSchedule';
|
||||
import Spinner from '@/components/elements/Spinner';
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import { httpErrorToHuman } from '@/api/http';
|
||||
import ScheduleRow from '@/components/server/schedules/ScheduleRow';
|
||||
import ScheduleTaskRow from '@/components/server/schedules/ScheduleTaskRow';
|
||||
import EditScheduleModal from '@/components/server/schedules/EditScheduleModal';
|
||||
import NewTaskButton from '@/components/server/schedules/NewTaskButton';
|
||||
import DeleteScheduleButton from '@/components/server/schedules/DeleteScheduleButton';
|
||||
|
@ -16,7 +13,11 @@ import { ServerContext } from '@/state/server';
|
|||
import PageContentBlock from '@/components/elements/PageContentBlock';
|
||||
import tw from 'twin.macro';
|
||||
import Button from '@/components/elements/Button';
|
||||
import GreyRowBox from '@/components/elements/GreyRowBox';
|
||||
import ScheduleTaskRow from '@/components/server/schedules/ScheduleTaskRow';
|
||||
import isEqual from 'react-fast-compare';
|
||||
import { format } from 'date-fns';
|
||||
import ScheduleCronRow from '@/components/server/schedules/ScheduleCronRow';
|
||||
import RunScheduleButton from '@/components/server/schedules/RunScheduleButton';
|
||||
|
||||
interface Params {
|
||||
id: string;
|
||||
|
@ -26,15 +27,34 @@ interface State {
|
|||
schedule?: Schedule;
|
||||
}
|
||||
|
||||
const CronBox = ({ title, value }: { title: string; value: string }) => (
|
||||
<div css={tw`bg-neutral-700 rounded p-4`}>
|
||||
<p css={tw`text-neutral-300 text-sm`}>{title}</p>
|
||||
<p css={tw`text-2xl font-medium text-neutral-100`}>{value}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ActivePill = ({ active }: { active: boolean }) => (
|
||||
<span
|
||||
css={[
|
||||
tw`rounded-full px-2 py-px text-xs ml-4 uppercase`,
|
||||
active ? tw`bg-green-600 text-green-100` : tw`bg-red-600 text-red-100`,
|
||||
]}
|
||||
>
|
||||
{active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
);
|
||||
|
||||
export default ({ match, history, location: { state } }: RouteComponentProps<Params, Record<string, unknown>, State>) => {
|
||||
const id = ServerContext.useStoreState(state => state.server.data!.id);
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
|
||||
const { clearFlashes, addError } = useFlash();
|
||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||
const [ isLoading, setIsLoading ] = useState(true);
|
||||
const [ showEditModal, setShowEditModal ] = useState(false);
|
||||
|
||||
const schedule = ServerContext.useStoreState(st => st.schedules.data.find(s => s.id === state.schedule?.id), [ match ]);
|
||||
// @ts-ignore
|
||||
const schedule: Schedule | undefined = ServerContext.useStoreState(st => st.schedules.data.find(s => s.id === state.schedule?.id), isEqual);
|
||||
const appendSchedule = ServerContext.useStoreActions(actions => actions.schedules.appendSchedule);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -48,11 +68,15 @@ export default ({ match, history, location: { state } }: RouteComponentProps<Par
|
|||
.then(schedule => appendSchedule(schedule))
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
addError({ message: httpErrorToHuman(error), key: 'schedules' });
|
||||
clearAndAddHttpError({ error, key: 'schedules' });
|
||||
})
|
||||
.then(() => setIsLoading(false));
|
||||
}, [ match ]);
|
||||
|
||||
const toggleEditModal = useCallback(() => {
|
||||
setShowEditModal(s => !s);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PageContentBlock>
|
||||
<FlashMessageRender byKey={'schedules'} css={tw`mb-4`}/>
|
||||
|
@ -60,52 +84,73 @@ export default ({ match, history, location: { state } }: RouteComponentProps<Par
|
|||
<Spinner size={'large'} centered/>
|
||||
:
|
||||
<>
|
||||
<GreyRowBox css={tw`cursor-pointer mb-2 flex-wrap`}>
|
||||
<ScheduleRow schedule={schedule}/>
|
||||
</GreyRowBox>
|
||||
<EditScheduleModal
|
||||
visible={showEditModal}
|
||||
schedule={schedule}
|
||||
onDismissed={() => setShowEditModal(false)}
|
||||
/>
|
||||
<div css={tw`flex items-center mt-8 mb-4`}>
|
||||
<div css={tw`flex-1`}>
|
||||
<h2 css={tw`text-2xl`}>Configured Tasks</h2>
|
||||
<ScheduleCronRow cron={schedule.cron} css={tw`sm:hidden bg-neutral-700 rounded mb-4 p-3`}/>
|
||||
<div css={tw`hidden sm:grid grid-cols-5 md:grid-cols-7 gap-4 mb-6`}>
|
||||
<CronBox title={'Minute'} value={schedule.cron.minute}/>
|
||||
<CronBox title={'Hour'} value={schedule.cron.hour}/>
|
||||
<CronBox title={'Day (Month)'} value={schedule.cron.dayOfMonth}/>
|
||||
<CronBox title={'Month'} value={'*'}/>
|
||||
<CronBox title={'Day (Week)'} value={schedule.cron.dayOfWeek}/>
|
||||
</div>
|
||||
<div css={tw`rounded shadow`}>
|
||||
<div css={tw`sm:flex items-center bg-neutral-900 p-3 sm:p-6 border-b-4 border-neutral-600 rounded-t`}>
|
||||
<div css={tw`flex-1`}>
|
||||
<h3 css={tw`flex items-center text-neutral-100 text-2xl`}>
|
||||
{schedule.name}
|
||||
{schedule.isProcessing ?
|
||||
<span
|
||||
css={tw`flex items-center rounded-full px-2 py-px text-xs ml-4 uppercase bg-neutral-600 text-white`}
|
||||
>
|
||||
<Spinner css={tw`w-3! h-3! mr-2`}/>
|
||||
Processing
|
||||
</span>
|
||||
:
|
||||
<ActivePill active={schedule.isActive}/>
|
||||
}
|
||||
</h3>
|
||||
<p css={tw`mt-1 text-sm text-neutral-300`}>
|
||||
Last run at:
|
||||
{schedule.lastRunAt ? format(schedule.lastRunAt, 'MMM do \'at\' h:mma') : 'never'}
|
||||
</p>
|
||||
</div>
|
||||
<div css={tw`flex sm:block mt-3 sm:mt-0`}>
|
||||
<Can action={'schedule.update'}>
|
||||
<Button
|
||||
isSecondary
|
||||
color={'grey'}
|
||||
size={'small'}
|
||||
css={tw`flex-1 mr-4 border-transparent`}
|
||||
onClick={toggleEditModal}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<NewTaskButton schedule={schedule}/>
|
||||
</Can>
|
||||
</div>
|
||||
</div>
|
||||
<div css={tw`bg-neutral-700 rounded-b`}>
|
||||
{schedule.tasks.length > 0 ?
|
||||
schedule.tasks.map(task => (
|
||||
<ScheduleTaskRow key={`${schedule.id}_${task.id}`} task={task} schedule={schedule}/>
|
||||
))
|
||||
:
|
||||
null
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
{schedule.tasks.length > 0 ?
|
||||
<>
|
||||
{
|
||||
schedule.tasks
|
||||
.sort((a, b) => a.sequenceId - b.sequenceId)
|
||||
.map(task => (
|
||||
<ScheduleTaskRow key={task.id} task={task} schedule={schedule}/>
|
||||
))
|
||||
}
|
||||
{schedule.tasks.length > 1 &&
|
||||
<p css={tw`text-xs text-neutral-400`}>
|
||||
Task delays are relative to the previous task in the listing.
|
||||
</p>
|
||||
}
|
||||
</>
|
||||
:
|
||||
<p css={tw`text-sm text-neutral-400`}>
|
||||
There are no tasks configured for this schedule.
|
||||
</p>
|
||||
}
|
||||
<div css={tw`mt-8 flex justify-end`}>
|
||||
<EditScheduleModal visible={showEditModal} schedule={schedule} onDismissed={toggleEditModal}/>
|
||||
<div css={tw`mt-6 flex sm:justify-end`}>
|
||||
<Can action={'schedule.delete'}>
|
||||
<DeleteScheduleButton
|
||||
scheduleId={schedule.id}
|
||||
onDeleted={() => history.push(`/server/${id}/schedules`)}
|
||||
/>
|
||||
</Can>
|
||||
{schedule.isActive && schedule.tasks.length > 0 &&
|
||||
<Can action={'schedule.update'}>
|
||||
<Button css={tw`mr-4`} onClick={() => setShowEditModal(true)}>
|
||||
Edit
|
||||
</Button>
|
||||
<NewTaskButton schedule={schedule}/>
|
||||
<RunScheduleButton schedule={schedule}/>
|
||||
</Can>
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|||
import { faCalendarAlt } from '@fortawesome/free-solid-svg-icons';
|
||||
import { format } from 'date-fns';
|
||||
import tw from 'twin.macro';
|
||||
import ScheduleCronRow from '@/components/server/schedules/ScheduleCronRow';
|
||||
|
||||
export default ({ schedule }: { schedule: Schedule }) => (
|
||||
<>
|
||||
|
@ -27,36 +28,19 @@ export default ({ schedule }: { schedule: Schedule }) => (
|
|||
{schedule.isActive ? 'Active' : 'Inactive'}
|
||||
</p>
|
||||
</div>
|
||||
<div css={tw`flex items-center mx-auto sm:mx-8 w-full sm:w-auto mt-4 sm:mt-0`}>
|
||||
<div css={tw`w-1/5 sm:w-auto text-center`}>
|
||||
<p css={tw`font-medium`}>{schedule.cron.minute}</p>
|
||||
<p css={tw`text-2xs text-neutral-500 uppercase`}>Minute</p>
|
||||
</div>
|
||||
<div css={tw`w-1/5 sm:w-auto text-center ml-4`}>
|
||||
<p css={tw`font-medium`}>{schedule.cron.hour}</p>
|
||||
<p css={tw`text-2xs text-neutral-500 uppercase`}>Hour</p>
|
||||
</div>
|
||||
<div css={tw`w-1/5 sm:w-auto text-center ml-4`}>
|
||||
<p css={tw`font-medium`}>{schedule.cron.dayOfMonth}</p>
|
||||
<p css={tw`text-2xs text-neutral-500 uppercase`}>Day (Month)</p>
|
||||
</div>
|
||||
<div css={tw`w-1/5 sm:w-auto text-center ml-4`}>
|
||||
<p css={tw`font-medium`}>*</p>
|
||||
<p css={tw`text-2xs text-neutral-500 uppercase`}>Month</p>
|
||||
</div>
|
||||
<div css={tw`w-1/5 sm:w-auto text-center ml-4`}>
|
||||
<p css={tw`font-medium`}>{schedule.cron.dayOfWeek}</p>
|
||||
<p css={tw`text-2xs text-neutral-500 uppercase`}>Day (Week)</p>
|
||||
</div>
|
||||
</div>
|
||||
<ScheduleCronRow cron={schedule.cron} css={tw`mx-auto sm:mx-8 w-full sm:w-auto mt-4 sm:mt-0`}/>
|
||||
<div>
|
||||
<p
|
||||
css={[
|
||||
tw`py-1 px-3 rounded text-xs uppercase text-white hidden sm:block`,
|
||||
schedule.isActive ? tw`bg-green-600` : tw`bg-neutral-400`,
|
||||
schedule.isActive && !schedule.isProcessing ? tw`bg-green-600` : tw`bg-neutral-400`,
|
||||
]}
|
||||
>
|
||||
{schedule.isActive ? 'Active' : 'Inactive'}
|
||||
{schedule.isProcessing ?
|
||||
'Processing'
|
||||
:
|
||||
schedule.isActive ? 'Active' : 'Inactive'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Schedule, Task } from '@/api/server/schedules/getServerSchedules';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faCode, faFileArchive, faPencilAlt, faToggleOn, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faClock, faCode, faFileArchive, faPencilAlt, faToggleOn, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
|
||||
import deleteScheduleTask from '@/api/server/schedules/deleteScheduleTask';
|
||||
import { httpErrorToHuman } from '@/api/http';
|
||||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||
|
@ -11,6 +11,7 @@ import useFlash from '@/plugins/useFlash';
|
|||
import { ServerContext } from '@/state/server';
|
||||
import tw from 'twin.macro';
|
||||
import ConfirmationModal from '@/components/elements/ConfirmationModal';
|
||||
import Icon from '@/components/elements/Icon';
|
||||
|
||||
interface Props {
|
||||
schedule: Schedule;
|
||||
|
@ -56,7 +57,7 @@ export default ({ schedule, task }: Props) => {
|
|||
const [ title, icon ] = getActionDetails(task.action);
|
||||
|
||||
return (
|
||||
<div css={tw`flex flex-wrap items-center bg-neutral-700 border border-neutral-600 mb-2 px-6 py-4 rounded`}>
|
||||
<div css={tw`sm:flex items-center p-3 sm:p-6 border-b border-neutral-800`}>
|
||||
<SpinnerOverlay visible={isLoading} fixed size={'large'}/>
|
||||
{isEditing && <TaskDetailsModal
|
||||
schedule={schedule}
|
||||
|
@ -73,8 +74,8 @@ export default ({ schedule, task }: Props) => {
|
|||
Are you sure you want to delete this task? This action cannot be undone.
|
||||
</ConfirmationModal>
|
||||
<FontAwesomeIcon icon={icon} css={tw`text-lg text-white hidden md:block`}/>
|
||||
<div css={tw`flex-none sm:flex-1 mb-4 sm:mb-0 w-full md:w-auto overflow-x-auto`}>
|
||||
<p css={tw`md:ml-6 text-neutral-300 uppercase text-xs`}>
|
||||
<div css={tw`flex-none sm:flex-1 w-full sm:w-auto overflow-x-auto`}>
|
||||
<p css={tw`md:ml-6 text-neutral-200 uppercase text-sm`}>
|
||||
{title}
|
||||
</p>
|
||||
{task.payload &&
|
||||
|
@ -87,36 +88,36 @@ export default ({ schedule, task }: Props) => {
|
|||
</div>
|
||||
}
|
||||
</div>
|
||||
{task.sequenceId > 1 &&
|
||||
<div css={tw`mr-6`}>
|
||||
<p css={tw`text-center mb-1`}>
|
||||
{task.timeOffset}s
|
||||
</p>
|
||||
<p css={tw`text-neutral-300 uppercase text-2xs`}>
|
||||
Delay Run By
|
||||
</p>
|
||||
<div css={tw`mt-3 sm:mt-0 flex items-center w-full sm:w-auto`}>
|
||||
{task.sequenceId > 1 && task.timeOffset > 0 &&
|
||||
<div css={tw`mr-6`}>
|
||||
<div css={tw`flex items-center px-2 py-1 bg-neutral-500 text-sm rounded-full`}>
|
||||
<Icon icon={faClock} css={tw`w-3 h-3 mr-2`}/>
|
||||
{task.timeOffset}s later
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<Can action={'schedule.update'}>
|
||||
<button
|
||||
type={'button'}
|
||||
aria-label={'Edit scheduled task'}
|
||||
css={tw`block text-sm p-2 text-neutral-500 hover:text-neutral-100 transition-colors duration-150 mr-4 ml-auto sm:ml-0`}
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPencilAlt}/>
|
||||
</button>
|
||||
</Can>
|
||||
<Can action={'schedule.update'}>
|
||||
<button
|
||||
type={'button'}
|
||||
aria-label={'Delete scheduled task'}
|
||||
css={tw`block text-sm p-2 text-neutral-500 hover:text-red-600 transition-colors duration-150`}
|
||||
onClick={() => setVisible(true)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrashAlt}/>
|
||||
</button>
|
||||
</Can>
|
||||
</div>
|
||||
}
|
||||
<Can action={'schedule.update'}>
|
||||
<button
|
||||
type={'button'}
|
||||
aria-label={'Edit scheduled task'}
|
||||
css={tw`block text-sm p-2 text-neutral-500 hover:text-neutral-100 transition-colors duration-150 mr-4 ml-auto sm:ml-0`}
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPencilAlt}/>
|
||||
</button>
|
||||
</Can>
|
||||
<Can action={'schedule.update'}>
|
||||
<button
|
||||
type={'button'}
|
||||
aria-label={'Delete scheduled task'}
|
||||
css={tw`block text-sm p-2 text-neutral-500 hover:text-red-600 transition-colors duration-150`}
|
||||
onClick={() => setVisible(true)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrashAlt}/>
|
||||
</button>
|
||||
</Can>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -10,7 +10,7 @@ export default () => {
|
|||
|
||||
return (
|
||||
<>
|
||||
{visible && <EditSubuserModal appear visible onDismissed={() => setVisible(false)}/>}
|
||||
<EditSubuserModal visible={visible} onModalDismissed={() => setVisible(false)}/>
|
||||
<Button onClick={() => setVisible(true)}>
|
||||
<FontAwesomeIcon icon={faUserPlus} css={tw`mr-1`}/> New User
|
||||
</Button>
|
||||
|
|
|
@ -1,14 +1,10 @@
|
|||
import React, { forwardRef, memo, useCallback, useEffect, useRef } from 'react';
|
||||
import React, { useContext, useEffect, useRef } from 'react';
|
||||
import { Subuser } from '@/state/server/subusers';
|
||||
import { Form, Formik, FormikHelpers, useFormikContext } from 'formik';
|
||||
import { Form, Formik } from 'formik';
|
||||
import { array, object, string } from 'yup';
|
||||
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
|
||||
import Field from '@/components/elements/Field';
|
||||
import { Actions, useStoreActions, useStoreState } from 'easy-peasy';
|
||||
import { ApplicationStore } from '@/state';
|
||||
import TitledGreyBox from '@/components/elements/TitledGreyBox';
|
||||
import Checkbox from '@/components/elements/Checkbox';
|
||||
import styled from 'styled-components/macro';
|
||||
import createOrUpdateSubuser from '@/api/server/users/createOrUpdateSubuser';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
|
@ -17,104 +13,33 @@ import { usePermissions } from '@/plugins/usePermissions';
|
|||
import { useDeepCompareMemo } from '@/plugins/useDeepCompareMemo';
|
||||
import tw from 'twin.macro';
|
||||
import Button from '@/components/elements/Button';
|
||||
import Label from '@/components/elements/Label';
|
||||
import Input from '@/components/elements/Input';
|
||||
import isEqual from 'react-fast-compare';
|
||||
import PermissionTitleBox from '@/components/server/users/PermissionTitleBox';
|
||||
import asModal from '@/hoc/asModal';
|
||||
import PermissionRow from '@/components/server/users/PermissionRow';
|
||||
import ModalContext from '@/context/ModalContext';
|
||||
|
||||
type Props = {
|
||||
subuser?: Subuser;
|
||||
} & RequiredModalProps;
|
||||
};
|
||||
|
||||
interface Values {
|
||||
email: string;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
const PermissionLabel = styled.label`
|
||||
${tw`flex items-center border border-transparent rounded md:p-2 transition-colors duration-75`};
|
||||
text-transform: none;
|
||||
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, toggleSpinner } = useContext(ModalContext);
|
||||
|
||||
&:not(.disabled) {
|
||||
${tw`cursor-pointer`};
|
||||
|
||||
&:hover {
|
||||
${tw`border-neutral-500 bg-neutral-800`};
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:first-of-type) {
|
||||
${tw`mt-4 sm:mt-2`};
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
${tw`opacity-50`};
|
||||
|
||||
& input[type="checkbox"]:not(:checked) {
|
||||
${tw`border-0`};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface TitleProps {
|
||||
isEditable: boolean;
|
||||
permission: string;
|
||||
permissions: string[];
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const PermissionTitledBox = memo(({ isEditable, permission, permissions, className, children }: TitleProps) => {
|
||||
const { values, setFieldValue } = useFormikContext<Values>();
|
||||
|
||||
const onCheckboxClicked = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
console.log(e.currentTarget.checked, [
|
||||
...values.permissions,
|
||||
...permissions.filter(p => !values.permissions.includes(p)),
|
||||
]);
|
||||
|
||||
if (e.currentTarget.checked) {
|
||||
setFieldValue('permissions', [
|
||||
...values.permissions,
|
||||
...permissions.filter(p => !values.permissions.includes(p)),
|
||||
]);
|
||||
} else {
|
||||
setFieldValue('permissions', [
|
||||
...values.permissions.filter(p => !permissions.includes(p)),
|
||||
]);
|
||||
}
|
||||
}, [ permissions, values.permissions ]);
|
||||
|
||||
return (
|
||||
<TitledGreyBox
|
||||
title={
|
||||
<div css={tw`flex items-center`}>
|
||||
<p css={tw`text-sm uppercase flex-1`}>{permission}</p>
|
||||
{isEditable &&
|
||||
<Input
|
||||
type={'checkbox'}
|
||||
checked={permissions.every(p => values.permissions.includes(p))}
|
||||
onChange={onCheckboxClicked}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</TitledGreyBox>
|
||||
);
|
||||
}, isEqual);
|
||||
|
||||
const EditSubuserModal = forwardRef<HTMLHeadingElement, Props>(({ subuser, ...props }, ref) => {
|
||||
const { isSubmitting } = useFormikContext<Values>();
|
||||
const [ canEditUser ] = usePermissions(subuser ? [ 'user.update' ] : [ 'user.create' ]);
|
||||
const isRootAdmin = useStoreState(state => state.user.data!.rootAdmin);
|
||||
const permissions = useStoreState(state => state.permissions.data);
|
||||
|
||||
const user = useStoreState(state => state.user.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' ]);
|
||||
|
||||
// The permissions that can be modified by this user.
|
||||
const editablePermissions = useDeepCompareMemo(() => {
|
||||
|
@ -123,111 +48,25 @@ const EditSubuserModal = forwardRef<HTMLHeadingElement, Props>(({ subuser, ...pr
|
|||
|
||||
const list: string[] = ([] as string[]).concat.apply([], Object.values(cleaned));
|
||||
|
||||
if (user.rootAdmin || (loggedInPermissions.length === 1 && loggedInPermissions[0] === '*')) {
|
||||
if (isRootAdmin || (loggedInPermissions.length === 1 && loggedInPermissions[0] === '*')) {
|
||||
return list;
|
||||
}
|
||||
|
||||
return list.filter(key => loggedInPermissions.indexOf(key) >= 0);
|
||||
}, [ permissions, loggedInPermissions ]);
|
||||
}, [ isRootAdmin, permissions, loggedInPermissions ]);
|
||||
|
||||
return (
|
||||
<Modal {...props} top={false} showSpinnerOverlay={isSubmitting}>
|
||||
<h2 css={tw`text-2xl`} ref={ref}>
|
||||
{subuser ?
|
||||
`${canEditUser ? 'Modify' : 'View'} permissions for ${subuser.email}`
|
||||
:
|
||||
'Create new subuser'
|
||||
}
|
||||
</h2>
|
||||
<FlashMessageRender byKey={'user:edit'} css={tw`mt-4`}/>
|
||||
{(!user.rootAdmin && loggedInPermissions[0] !== '*') &&
|
||||
<div css={tw`mt-4 pl-4 py-2 border-l-4 border-cyan-400`}>
|
||||
<p css={tw`text-sm text-neutral-300`}>
|
||||
Only permissions which your account is currently assigned may be selected when creating or
|
||||
modifying other users.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
{!subuser &&
|
||||
<div css={tw`mt-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 css={tw`my-6`}>
|
||||
{Object.keys(permissions).filter(key => key !== 'websocket').map((key, index) => {
|
||||
const group = Object.keys(permissions[key].keys).map(pkey => `${key}.${pkey}`);
|
||||
|
||||
return (
|
||||
<PermissionTitledBox
|
||||
key={`permission_${key}`}
|
||||
isEditable={canEditUser}
|
||||
permission={key}
|
||||
permissions={group}
|
||||
css={index > 0 ? tw`mt-4` : undefined}
|
||||
>
|
||||
<p css={tw`text-sm text-neutral-400 mb-4`}>
|
||||
{permissions[key].description}
|
||||
</p>
|
||||
{Object.keys(permissions[key].keys).map(pkey => (
|
||||
<PermissionLabel
|
||||
key={`permission_${key}_${pkey}`}
|
||||
htmlFor={`permission_${key}_${pkey}`}
|
||||
className={(!canEditUser || editablePermissions.indexOf(`${key}.${pkey}`) < 0) ? 'disabled' : undefined}
|
||||
>
|
||||
<div css={tw`p-2`}>
|
||||
<Checkbox
|
||||
id={`permission_${key}_${pkey}`}
|
||||
name={'permissions'}
|
||||
value={`${key}.${pkey}`}
|
||||
css={tw`w-5 h-5 mr-2`}
|
||||
disabled={!canEditUser || editablePermissions.indexOf(`${key}.${pkey}`) < 0}
|
||||
/>
|
||||
</div>
|
||||
<div css={tw`flex-1`}>
|
||||
<Label as={'p'} css={tw`font-medium`}>{pkey}</Label>
|
||||
{permissions[key].keys[pkey].length > 0 &&
|
||||
<p css={tw`text-xs text-neutral-400 mt-1`}>
|
||||
{permissions[key].keys[pkey]}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</PermissionLabel>
|
||||
))}
|
||||
</PermissionTitledBox>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Can action={subuser ? 'user.update' : 'user.create'}>
|
||||
<div css={tw`pb-6 flex justify-end`}>
|
||||
<Button type={'submit'} css={tw`w-full sm:w-auto`}>
|
||||
{subuser ? 'Save' : 'Invite User'}
|
||||
</Button>
|
||||
</div>
|
||||
</Can>
|
||||
</Modal>
|
||||
);
|
||||
});
|
||||
|
||||
export default ({ subuser, ...props }: 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 submit = (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
|
||||
const submit = (values: Values) => {
|
||||
toggleSpinner(true);
|
||||
clearFlashes('user:edit');
|
||||
|
||||
createOrUpdateSubuser(uuid, values, subuser)
|
||||
.then(subuser => {
|
||||
appendSubuser(subuser);
|
||||
props.onDismissed();
|
||||
dismiss();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
setSubmitting(false);
|
||||
toggleSpinner(false);
|
||||
clearAndAddHttpError({ key: 'user:edit', error });
|
||||
|
||||
if (ref.current) {
|
||||
|
@ -236,10 +75,8 @@ export default ({ subuser, ...props }: Props) => {
|
|||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearFlashes('user:edit');
|
||||
};
|
||||
useEffect(() => () => {
|
||||
clearFlashes('user:edit');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
@ -258,8 +95,61 @@ export default ({ subuser, ...props }: Props) => {
|
|||
})}
|
||||
>
|
||||
<Form>
|
||||
<EditSubuserModal ref={ref} subuser={subuser} {...props}/>
|
||||
<h2 css={tw`text-2xl`} ref={ref}>
|
||||
{subuser ? `${canEditUser ? 'Modify' : 'View'} permissions for ${subuser.email}` : 'Create new subuser'}
|
||||
</h2>
|
||||
<FlashMessageRender byKey={'user:edit'} css={tw`mt-4`}/>
|
||||
{(!isRootAdmin && loggedInPermissions[0] !== '*') &&
|
||||
<div css={tw`mt-4 pl-4 py-2 border-l-4 border-cyan-400`}>
|
||||
<p css={tw`text-sm text-neutral-300`}>
|
||||
Only permissions which your account is currently assigned may be selected when creating or
|
||||
modifying other users.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
{!subuser &&
|
||||
<div css={tw`mt-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 css={tw`my-6`}>
|
||||
{Object.keys(permissions).filter(key => key !== 'websocket').map((key, index) => (
|
||||
<PermissionTitleBox
|
||||
key={`permission_${key}`}
|
||||
title={key}
|
||||
isEditable={canEditUser}
|
||||
permissions={Object.keys(permissions[key].keys).map(pkey => `${key}.${pkey}`)}
|
||||
css={index > 0 ? tw`mt-4` : undefined}
|
||||
>
|
||||
<p css={tw`text-sm text-neutral-400 mb-4`}>
|
||||
{permissions[key].description}
|
||||
</p>
|
||||
{Object.keys(permissions[key].keys).map(pkey => (
|
||||
<PermissionRow
|
||||
key={`permission_${key}.${pkey}`}
|
||||
permission={`${key}.${pkey}`}
|
||||
disabled={!canEditUser || editablePermissions.indexOf(`${key}.${pkey}`) < 0}
|
||||
/>
|
||||
))}
|
||||
</PermissionTitleBox>
|
||||
))}
|
||||
</div>
|
||||
<Can action={subuser ? 'user.update' : 'user.create'}>
|
||||
<div css={tw`pb-6 flex justify-end`}>
|
||||
<Button type={'submit'} css={tw`w-full sm:w-auto`}>
|
||||
{subuser ? 'Save' : 'Invite User'}
|
||||
</Button>
|
||||
</div>
|
||||
</Can>
|
||||
</Form>
|
||||
</Formik>
|
||||
);
|
||||
};
|
||||
|
||||
export default asModal<Props>({
|
||||
top: false,
|
||||
})(EditSubuserModal);
|
||||
|
|
65
resources/scripts/components/server/users/PermissionRow.tsx
Normal file
65
resources/scripts/components/server/users/PermissionRow.tsx
Normal file
|
@ -0,0 +1,65 @@
|
|||
import styled from 'styled-components/macro';
|
||||
import tw from 'twin.macro';
|
||||
import Checkbox from '@/components/elements/Checkbox';
|
||||
import React from 'react';
|
||||
import { useStoreState } from 'easy-peasy';
|
||||
import Label from '@/components/elements/Label';
|
||||
|
||||
const Container = styled.label`
|
||||
${tw`flex items-center border border-transparent rounded md:p-2 transition-colors duration-75`};
|
||||
text-transform: none;
|
||||
|
||||
&:not(.disabled) {
|
||||
${tw`cursor-pointer`};
|
||||
|
||||
&:hover {
|
||||
${tw`border-neutral-500 bg-neutral-800`};
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:first-of-type) {
|
||||
${tw`mt-4 sm:mt-2`};
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
${tw`opacity-50`};
|
||||
|
||||
& input[type="checkbox"]:not(:checked) {
|
||||
${tw`border-0`};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
permission: string;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
const PermissionRow = ({ permission, disabled }: Props) => {
|
||||
const [ key, pkey ] = permission.split('.', 2);
|
||||
const permissions = useStoreState(state => state.permissions.data);
|
||||
|
||||
return (
|
||||
<Container htmlFor={`permission_${permission}`} className={disabled ? 'disabled' : undefined}>
|
||||
<div css={tw`p-2`}>
|
||||
<Checkbox
|
||||
id={`permission_${permission}`}
|
||||
name={'permissions'}
|
||||
value={permission}
|
||||
css={tw`w-5 h-5 mr-2`}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<div css={tw`flex-1`}>
|
||||
<Label as={'p'} css={tw`font-medium`}>{pkey}</Label>
|
||||
{permissions[key].keys[pkey].length > 0 &&
|
||||
<p css={tw`text-xs text-neutral-400 mt-1`}>
|
||||
{permissions[key].keys[pkey]}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default PermissionRow;
|
|
@ -0,0 +1,50 @@
|
|||
import React, { memo, useCallback } from 'react';
|
||||
import { useField } from 'formik';
|
||||
import TitledGreyBox from '@/components/elements/TitledGreyBox';
|
||||
import tw from 'twin.macro';
|
||||
import Input from '@/components/elements/Input';
|
||||
import isEqual from 'react-fast-compare';
|
||||
|
||||
interface Props {
|
||||
isEditable: boolean;
|
||||
title: string;
|
||||
permissions: string[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const PermissionTitleBox: React.FC<Props> = memo(({ isEditable, title, permissions, className, children }) => {
|
||||
const [ { value }, , { setValue } ] = useField<string[]>('permissions');
|
||||
|
||||
const onCheckboxClicked = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.currentTarget.checked) {
|
||||
setValue([
|
||||
...value,
|
||||
...permissions.filter(p => !value.includes(p)),
|
||||
]);
|
||||
} else {
|
||||
setValue(value.filter(p => !permissions.includes(p)));
|
||||
}
|
||||
}, [ permissions, value ]);
|
||||
|
||||
return (
|
||||
<TitledGreyBox
|
||||
title={
|
||||
<div css={tw`flex items-center`}>
|
||||
<p css={tw`text-sm uppercase flex-1`}>{title}</p>
|
||||
{isEditable &&
|
||||
<Input
|
||||
type={'checkbox'}
|
||||
checked={permissions.every(p => value.includes(p))}
|
||||
onChange={onCheckboxClicked}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</TitledGreyBox>
|
||||
);
|
||||
}, isEqual);
|
||||
|
||||
export default PermissionTitleBox;
|
|
@ -19,14 +19,11 @@ export default ({ subuser }: Props) => {
|
|||
|
||||
return (
|
||||
<GreyRowBox css={tw`mb-2`}>
|
||||
{visible &&
|
||||
<EditSubuserModal
|
||||
appear
|
||||
visible
|
||||
subuser={subuser}
|
||||
onDismissed={() => setVisible(false)}
|
||||
visible={visible}
|
||||
onModalDismissed={() => setVisible(false)}
|
||||
/>
|
||||
}
|
||||
<div css={tw`w-10 h-10 rounded-full bg-white border-2 border-neutral-800 overflow-hidden hidden md:block`}>
|
||||
<img css={tw`w-full h-full`} src={`${subuser.image}?s=400`}/>
|
||||
</div>
|
||||
|
|
|
@ -9,7 +9,7 @@ import FlashMessageRender from '@/components/FlashMessageRender';
|
|||
import getServerSubusers from '@/api/server/users/getServerSubusers';
|
||||
import { httpErrorToHuman } from '@/api/http';
|
||||
import Can from '@/components/elements/Can';
|
||||
import PageContentBlock from '@/components/elements/PageContentBlock';
|
||||
import ServerContentBlock from '@/components/elements/ServerContentBlock';
|
||||
import tw from 'twin.macro';
|
||||
|
||||
export default () => {
|
||||
|
@ -48,7 +48,7 @@ export default () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<PageContentBlock title={'Subusers'}>
|
||||
<ServerContentBlock title={'Subusers'}>
|
||||
<FlashMessageRender byKey={'users'} css={tw`mb-4`}/>
|
||||
{!subusers.length ?
|
||||
<p css={tw`text-center text-sm text-neutral-400`}>
|
||||
|
@ -64,6 +64,6 @@ export default () => {
|
|||
<AddSubuserButton/>
|
||||
</div>
|
||||
</Can>
|
||||
</PageContentBlock>
|
||||
</ServerContentBlock>
|
||||
);
|
||||
};
|
||||
|
|
Reference in a new issue