Make it easier for plugins to extend the navigation and add routes

This commit is contained in:
DaneEveritt 2022-06-12 11:36:55 -04:00
parent 88a7bd7578
commit 04e97cc67e
No known key found for this signature in database
GPG key ID: EEA66103B3D71F53
5 changed files with 198 additions and 116 deletions

View file

@ -0,0 +1,30 @@
import React from 'react';
import { Route } from 'react-router-dom';
import { RouteProps } from 'react-router';
import Can from '@/components/elements/Can';
import { ServerError } from '@/components/elements/ScreenBlock';
interface Props extends Omit<RouteProps, 'path'> {
path: string;
permission: string | string[] | null;
}
export default ({ permission, children, ...props }: Props) => (
<Route {...props}>
{!permission ?
children
:
<Can
action={permission}
renderOnError={
<ServerError
title={'Access Denied'}
message={'You do not have permission to access this page.'}
/>
}
>
{children}
</Can>
}
</Route>
);

View file

@ -0,0 +1,33 @@
import React from 'react';
import { ServerContext } from '@/state/server';
import ScreenBlock from '@/components/elements/ScreenBlock';
import ServerInstallSvg from '@/assets/images/server_installing.svg';
import ServerErrorSvg from '@/assets/images/server_error.svg';
import ServerRestoreSvg from '@/assets/images/server_restore.svg';
export default () => {
const status = ServerContext.useStoreState(state => state.server.data?.status || null);
const isTransferring = ServerContext.useStoreState(state => state.server.data?.isTransferring || false);
return (
status === 'installing' || status === 'install_failed' ?
<ScreenBlock
title={'Running Installer'}
image={ServerInstallSvg}
message={'Your server should be ready soon, please try again in a few minutes.'}
/>
:
status === 'suspended' ?
<ScreenBlock
title={'Server Suspended'}
image={ServerErrorSvg}
message={'This server is suspended and cannot be accessed.'}
/>
:
<ScreenBlock
title={isTransferring ? 'Transferring' : 'Restoring from Backup'}
image={ServerRestoreSvg}
message={isTransferring ? 'Your server is being transfered to a new node, please check back later.' : 'Your server is currently being restored from a backup, please check back in a few minutes.'}
/>
);
};

View file

@ -1,4 +1,4 @@
import React, { lazy, useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import getFileContents from '@/api/server/files/getFileContents'; import getFileContents from '@/api/server/files/getFileContents';
import { httpErrorToHuman } from '@/api/http'; import { httpErrorToHuman } from '@/api/http';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
@ -19,8 +19,7 @@ import { ServerContext } from '@/state/server';
import ErrorBoundary from '@/components/elements/ErrorBoundary'; import ErrorBoundary from '@/components/elements/ErrorBoundary';
import { encodePathSegments, hashToPath } from '@/helpers'; import { encodePathSegments, hashToPath } from '@/helpers';
import { dirname } from 'path'; import { dirname } from 'path';
import CodemirrorEditor from '@/components/elements/CodemirrorEditor';
const LazyCodemirrorEditor = lazy(() => import(/* webpackChunkName: "editor" */'@/components/elements/CodemirrorEditor'));
export default () => { export default () => {
const [ error, setError ] = useState(''); const [ error, setError ] = useState('');
@ -116,7 +115,7 @@ export default () => {
/> />
<div css={tw`relative`}> <div css={tw`relative`}>
<SpinnerOverlay visible={loading}/> <SpinnerOverlay visible={loading}/>
<LazyCodemirrorEditor <CodemirrorEditor
mode={mode} mode={mode}
filename={hash.replace(/^#/, '')} filename={hash.replace(/^#/, '')}
onModeChanged={setMode} onModeChanged={setMode}

View file

@ -2,63 +2,24 @@ import TransferListener from '@/components/server/TransferListener';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { NavLink, Route, Switch, useRouteMatch } from 'react-router-dom'; import { NavLink, Route, Switch, useRouteMatch } from 'react-router-dom';
import NavigationBar from '@/components/NavigationBar'; import NavigationBar from '@/components/NavigationBar';
import ServerConsole from '@/components/server/ServerConsole';
import TransitionRouter from '@/TransitionRouter'; import TransitionRouter from '@/TransitionRouter';
import WebsocketHandler from '@/components/server/WebsocketHandler'; import WebsocketHandler from '@/components/server/WebsocketHandler';
import { ServerContext } from '@/state/server'; import { ServerContext } from '@/state/server';
import DatabasesContainer from '@/components/server/databases/DatabasesContainer';
import FileManagerContainer from '@/components/server/files/FileManagerContainer';
import { CSSTransition } from 'react-transition-group'; import { CSSTransition } from 'react-transition-group';
import FileEditContainer from '@/components/server/files/FileEditContainer';
import SettingsContainer from '@/components/server/settings/SettingsContainer';
import ScheduleContainer from '@/components/server/schedules/ScheduleContainer';
import ScheduleEditContainer from '@/components/server/schedules/ScheduleEditContainer';
import UsersContainer from '@/components/server/users/UsersContainer';
import Can from '@/components/elements/Can'; import Can from '@/components/elements/Can';
import BackupContainer from '@/components/server/backups/BackupContainer';
import Spinner from '@/components/elements/Spinner'; import Spinner from '@/components/elements/Spinner';
import ScreenBlock, { NotFound, ServerError } from '@/components/elements/ScreenBlock'; import { NotFound, ServerError } from '@/components/elements/ScreenBlock';
import { httpErrorToHuman } from '@/api/http'; import { httpErrorToHuman } from '@/api/http';
import { useStoreState } from 'easy-peasy'; import { useStoreState } from 'easy-peasy';
import SubNavigation from '@/components/elements/SubNavigation'; import SubNavigation from '@/components/elements/SubNavigation';
import NetworkContainer from '@/components/server/network/NetworkContainer';
import InstallListener from '@/components/server/InstallListener'; import InstallListener from '@/components/server/InstallListener';
import StartupContainer from '@/components/server/startup/StartupContainer';
import ErrorBoundary from '@/components/elements/ErrorBoundary'; import ErrorBoundary from '@/components/elements/ErrorBoundary';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faExternalLinkAlt } from '@fortawesome/free-solid-svg-icons'; import { faExternalLinkAlt } from '@fortawesome/free-solid-svg-icons';
import RequireServerPermission from '@/hoc/RequireServerPermission';
import ServerInstallSvg from '@/assets/images/server_installing.svg';
import ServerRestoreSvg from '@/assets/images/server_restore.svg';
import ServerErrorSvg from '@/assets/images/server_error.svg';
import { useLocation } from 'react-router'; import { useLocation } from 'react-router';
import ConflictStateRenderer from '@/components/server/ConflictStateRenderer';
const ConflictStateRenderer = () => { import PermissionRoute from '@/components/elements/PermissionRoute';
const status = ServerContext.useStoreState(state => state.server.data?.status || null); import routes from '@/routers/routes';
const isTransferring = ServerContext.useStoreState(state => state.server.data?.isTransferring || false);
return (
status === 'installing' || status === 'install_failed' ?
<ScreenBlock
title={'Running Installer'}
image={ServerInstallSvg}
message={'Your server should be ready soon, please try again in a few minutes.'}
/>
:
status === 'suspended' ?
<ScreenBlock
title={'Server Suspended'}
image={ServerErrorSvg}
message={'This server is suspended and cannot be accessed.'}
/>
:
<ScreenBlock
title={isTransferring ? 'Transferring' : 'Restoring from Backup'}
image={ServerRestoreSvg}
message={isTransferring ? 'Your server is being transfered to a new node, please check back later.' : 'Your server is currently being restored from a backup, please check back in a few minutes.'}
/>
);
};
export default () => { export default () => {
const match = useRouteMatch<{ id: string }>(); const match = useRouteMatch<{ id: string }>();
@ -74,6 +35,10 @@ export default () => {
const getServer = ServerContext.useStoreActions(actions => actions.server.getServer); const getServer = ServerContext.useStoreActions(actions => actions.server.getServer);
const clearServerState = ServerContext.useStoreActions(actions => actions.clearServerState); const clearServerState = ServerContext.useStoreActions(actions => actions.clearServerState);
const to = (value: string, url = false) => {
return `${(url ? match.url : match.path).replace(/\/*$/, '')}/${value.replace(/^\/+/, '')}`;
};
useEffect(() => () => { useEffect(() => () => {
clearServerState(); clearServerState();
}, []); }, []);
@ -105,35 +70,23 @@ export default () => {
<CSSTransition timeout={150} classNames={'fade'} appear in> <CSSTransition timeout={150} classNames={'fade'} appear in>
<SubNavigation> <SubNavigation>
<div> <div>
<NavLink to={`${match.url}`} exact>Console</NavLink> {routes.server.filter(route => !!route.name).map((route) => (
<Can action={'file.*'}> route.permission ?
<NavLink to={`${match.url}/files`}>File Manager</NavLink> <Can key={route.path} action={route.permission} matchAny>
</Can> <NavLink to={to(route.path, true)} exact={route.exact}>
<Can action={'database.*'}> {route.name}
<NavLink to={`${match.url}/databases`}>Databases</NavLink> </NavLink>
</Can> </Can>
<Can action={'schedule.*'}> :
<NavLink to={`${match.url}/schedules`}>Schedules</NavLink> <NavLink key={route.path} to={to(route.path, true)} exact={route.exact}>
</Can> {route.name}
<Can action={'user.*'}> </NavLink>
<NavLink to={`${match.url}/users`}>Users</NavLink> ))}
</Can>
<Can action={'backup.*'}>
<NavLink to={`${match.url}/backups`}>Backups</NavLink>
</Can>
<Can action={'allocation.*'}>
<NavLink to={`${match.url}/network`}>Network</NavLink>
</Can>
<Can action={'startup.*'}>
<NavLink to={`${match.url}/startup`}>Startup</NavLink>
</Can>
<Can action={[ 'settings.*', 'file.sftp' ]} matchAny>
<NavLink to={`${match.url}/settings`}>Settings</NavLink>
</Can>
{rootAdmin && {rootAdmin &&
<a href={'/admin/servers/view/' + serverId} rel="noreferrer" target={'_blank'}> // eslint-disable-next-line react/jsx-no-target-blank
<FontAwesomeIcon icon={faExternalLinkAlt}/> <a href={`/admin/servers/view/${serverId}`} target={'_blank'}>
</a> <FontAwesomeIcon icon={faExternalLinkAlt}/>
</a>
} }
</div> </div>
</SubNavigation> </SubNavigation>
@ -147,47 +100,13 @@ export default () => {
<ErrorBoundary> <ErrorBoundary>
<TransitionRouter> <TransitionRouter>
<Switch location={location}> <Switch location={location}>
<Route path={`${match.path}`} component={ServerConsole} exact/> {routes.server.map(({ path, permission, component: Component }) => (
<Route path={`${match.path}/files`} exact> <PermissionRoute key={path} permission={permission} path={to(path)} exact>
<RequireServerPermission permissions={'file.*'}> <Spinner.Suspense>
<FileManagerContainer/> <Component/>
</RequireServerPermission> </Spinner.Suspense>
</Route> </PermissionRoute>
<Route path={`${match.path}/files/:action(edit|new)`} exact> ))}
<Spinner.Suspense>
<FileEditContainer/>
</Spinner.Suspense>
</Route>
<Route path={`${match.path}/databases`} exact>
<RequireServerPermission permissions={'database.*'}>
<DatabasesContainer/>
</RequireServerPermission>
</Route>
<Route path={`${match.path}/schedules`} exact>
<RequireServerPermission permissions={'schedule.*'}>
<ScheduleContainer/>
</RequireServerPermission>
</Route>
<Route path={`${match.path}/schedules/:id`} exact>
<ScheduleEditContainer/>
</Route>
<Route path={`${match.path}/users`} exact>
<RequireServerPermission permissions={'user.*'}>
<UsersContainer/>
</RequireServerPermission>
</Route>
<Route path={`${match.path}/backups`} exact>
<RequireServerPermission permissions={'backup.*'}>
<BackupContainer/>
</RequireServerPermission>
</Route>
<Route path={`${match.path}/network`} exact>
<RequireServerPermission permissions={'allocation.*'}>
<NetworkContainer/>
</RequireServerPermission>
</Route>
<Route path={`${match.path}/startup`} component={StartupContainer} exact/>
<Route path={`${match.path}/settings`} component={SettingsContainer} exact/>
<Route path={'*'} component={NotFound}/> <Route path={'*'} component={NotFound}/>
</Switch> </Switch>
</TransitionRouter> </TransitionRouter>

View file

@ -0,0 +1,101 @@
import React, { lazy } from 'react';
import ServerConsole from '@/components/server/ServerConsole';
import DatabasesContainer from '@/components/server/databases/DatabasesContainer';
import ScheduleContainer from '@/components/server/schedules/ScheduleContainer';
import UsersContainer from '@/components/server/users/UsersContainer';
import BackupContainer from '@/components/server/backups/BackupContainer';
import NetworkContainer from '@/components/server/network/NetworkContainer';
import StartupContainer from '@/components/server/startup/StartupContainer';
const FileManagerContainer = lazy(() => import('@/components/server/files/FileManagerContainer'));
const FileEditContainer = lazy(() => import('@/components/server/files/FileEditContainer'));
const ScheduleEditContainer = lazy(() => import('@/components/server/schedules/ScheduleEditContainer'));
const SettingsContainer = lazy(() => import('@/components/server/settings/SettingsContainer'));
interface ServerRouteDefinition {
path: string;
permission: string | string[] | null;
// If undefined is passed this route is still rendered into the router itself
// but no navigation link is displayed in the sub-navigation menu.
name: string | undefined;
component: React.ComponentType;
// The default for "exact" is assumed to be "true" unless you explicitly
// pass it as false.
exact?: boolean;
}
interface Routes {
server: ServerRouteDefinition[];
}
export default {
server: [
{
path: '/',
permission: null,
name: 'Console',
component: ServerConsole,
exact: true,
},
{
path: '/files',
permission: 'file.*',
name: 'Files',
component: FileManagerContainer,
},
{
path: '/files/:action(edit|new)',
permission: 'file.*',
name: undefined,
component: FileEditContainer,
},
{
path: '/databases',
permission: 'database.*',
name: 'Databases',
component: DatabasesContainer,
},
{
path: '/schedules',
permission: 'schedule.*',
name: 'Schedules',
component: ScheduleContainer,
},
{
path: '/schedules/:id',
permission: 'schedule.*',
name: undefined,
component: ScheduleEditContainer,
},
{
path: '/users',
permission: 'user.*',
name: 'Users',
component: UsersContainer,
},
{
path: '/backups',
permission: 'backup.*',
name: 'Backups',
component: BackupContainer,
},
{
path: '/network',
permission: 'allocation.*',
name: 'Network',
component: NetworkContainer,
},
{
path: '/startup',
permission: 'startup.*',
name: 'Startup',
component: StartupContainer,
},
{
path: '/settings',
permission: [ 'settings.*', 'file.sftp' ],
name: 'Settings',
component: SettingsContainer,
},
],
} as Routes;