Merge branch 'develop' into cputhreads
This commit is contained in:
commit
78d6e59fc5
31 changed files with 1883 additions and 1323 deletions
|
@ -1,41 +1,27 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import React from 'react';
|
||||
import { usePermissions } from '@/plugins/usePermissions';
|
||||
|
||||
interface Props {
|
||||
action: string | string[];
|
||||
matchAny?: boolean;
|
||||
renderOnError?: React.ReactNode | null;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Can = ({ action, renderOnError, children }: Props) => {
|
||||
const userPermissions = ServerContext.useStoreState(state => state.server.permissions);
|
||||
const actions = Array.isArray(action) ? action : [ action ];
|
||||
const Can = ({ action, matchAny = false, renderOnError, children }: Props) => {
|
||||
const can = usePermissions(action);
|
||||
|
||||
const missingPermissionCount = useMemo(() => {
|
||||
if (userPermissions[0] === '*') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return actions.filter(permission => {
|
||||
return !(
|
||||
// Allows checking for any permission matching a name, for example files.*
|
||||
// will return if the user has any permission under the file.XYZ namespace.
|
||||
(
|
||||
permission.endsWith('.*')
|
||||
&& permission !== 'websocket.*'
|
||||
&& userPermissions.filter(p => p.startsWith(permission.split('.')[0])).length > 0
|
||||
)
|
||||
// Otherwise just check if the entire permission exists in the array or not.
|
||||
|| userPermissions.indexOf(permission) >= 0);
|
||||
}).length;
|
||||
}, [ action, userPermissions ]);
|
||||
if (matchAny) {
|
||||
console.log('Can.tsx', can);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{missingPermissionCount > 0 ?
|
||||
renderOnError
|
||||
:
|
||||
children
|
||||
{
|
||||
((matchAny && can.filter(p => p).length > 0) || (!matchAny && can.every(p => p))) ?
|
||||
children
|
||||
:
|
||||
renderOnError
|
||||
}
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -4,6 +4,9 @@ import * as TerminalFit from 'xterm/lib/addons/fit/fit';
|
|||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import styled from 'styled-components';
|
||||
import Can from '@/components/elements/Can';
|
||||
import { usePermissions } from '@/plugins/usePermissions';
|
||||
import classNames from 'classnames';
|
||||
|
||||
const theme = {
|
||||
background: 'transparent',
|
||||
|
@ -52,6 +55,7 @@ export default () => {
|
|||
const useRef = useCallback(node => setTerminalElement(node), []);
|
||||
const terminal = useMemo(() => new Terminal({ ...terminalProps }), []);
|
||||
const { connected, instance } = ServerContext.useStoreState(state => state.socket);
|
||||
const [ canSendCommands ] = usePermissions([ 'control.console']);
|
||||
|
||||
const handleConsoleOutput = (line: string, prelude = false) => terminal.writeln(
|
||||
(prelude ? TERMINAL_PRELUDE : '') + line.replace(/(?:\r\n|\r|\n)$/im, '') + '\u001b[0m',
|
||||
|
@ -121,7 +125,9 @@ export default () => {
|
|||
<div className={'text-xs font-mono relative'}>
|
||||
<SpinnerOverlay visible={!connected} size={'large'}/>
|
||||
<div
|
||||
className={'rounded-t p-2 bg-black w-full'}
|
||||
className={classNames('rounded-t p-2 bg-black w-full', {
|
||||
'rounded-b': !canSendCommands,
|
||||
})}
|
||||
style={{
|
||||
minHeight: '16rem',
|
||||
maxHeight: '32rem',
|
||||
|
@ -129,6 +135,7 @@ export default () => {
|
|||
>
|
||||
<TerminalDiv id={'terminal'} ref={useRef}/>
|
||||
</div>
|
||||
{canSendCommands &&
|
||||
<div className={'rounded-b bg-neutral-900 text-neutral-100 flex'}>
|
||||
<div className={'flex-no-shrink p-2 font-bold'}>$</div>
|
||||
<div className={'w-full'}>
|
||||
|
@ -140,6 +147,7 @@ export default () => {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -9,6 +9,7 @@ import { faMicrochip } from '@fortawesome/free-solid-svg-icons/faMicrochip';
|
|||
import { bytesToHuman } from '@/helpers';
|
||||
import SuspenseSpinner from '@/components/elements/SuspenseSpinner';
|
||||
import TitledGreyBox from '@/components/elements/TitledGreyBox';
|
||||
import Can from '@/components/elements/Can';
|
||||
|
||||
type PowerAction = 'start' | 'stop' | 'restart' | 'kill';
|
||||
|
||||
|
@ -109,28 +110,36 @@ export default () => {
|
|||
{cpu.toFixed(2)} %
|
||||
</p>
|
||||
</TitledGreyBox>
|
||||
<div className={'grey-box justify-center'}>
|
||||
<button
|
||||
className={'btn btn-secondary btn-xs mr-2'}
|
||||
disabled={status !== 'offline'}
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
sendPowerCommand('start');
|
||||
}}
|
||||
>
|
||||
Start
|
||||
</button>
|
||||
<button
|
||||
className={'btn btn-secondary btn-xs mr-2'}
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
sendPowerCommand('restart');
|
||||
}}
|
||||
>
|
||||
Restart
|
||||
</button>
|
||||
<StopOrKillButton onPress={action => sendPowerCommand(action)}/>
|
||||
</div>
|
||||
<Can action={[ 'control.start', 'control.stop', 'control.restart' ]} matchAny={true}>
|
||||
<div className={'grey-box justify-center'}>
|
||||
<Can action={'control.start'}>
|
||||
<button
|
||||
className={'btn btn-secondary btn-xs mr-2'}
|
||||
disabled={status !== 'offline'}
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
sendPowerCommand('start');
|
||||
}}
|
||||
>
|
||||
Start
|
||||
</button>
|
||||
</Can>
|
||||
<Can action={'control.restart'}>
|
||||
<button
|
||||
className={'btn btn-secondary btn-xs mr-2'}
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
sendPowerCommand('restart');
|
||||
}}
|
||||
>
|
||||
Restart
|
||||
</button>
|
||||
</Can>
|
||||
<Can action={'control.stop'}>
|
||||
<StopOrKillButton onPress={action => sendPowerCommand(action)}/>
|
||||
</Can>
|
||||
</div>
|
||||
</Can>
|
||||
</div>
|
||||
<div className={'flex-1 mx-4 mr-4'}>
|
||||
<SuspenseSpinner>
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import React, { useState } from 'react';
|
||||
import { ServerDatabase } from '@/api/server/getServerDatabases';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faDatabase } from '@fortawesome/free-solid-svg-icons/faDatabase';
|
||||
import { faTrashAlt } from '@fortawesome/free-solid-svg-icons/faTrashAlt';
|
||||
|
@ -16,6 +15,7 @@ import { ServerContext } from '@/state/server';
|
|||
import deleteServerDatabase from '@/api/server/deleteServerDatabase';
|
||||
import { httpErrorToHuman } from '@/api/http';
|
||||
import RotatePasswordButton from '@/components/server/databases/RotatePasswordButton';
|
||||
import Can from '@/components/elements/Can';
|
||||
|
||||
interface Props {
|
||||
databaseId: string | number;
|
||||
|
@ -24,10 +24,10 @@ interface Props {
|
|||
}
|
||||
|
||||
export default ({ databaseId, className, onDelete }: Props) => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [ visible, setVisible ] = useState(false);
|
||||
const database = ServerContext.useStoreState(state => state.databases.items.find(item => item.id === databaseId));
|
||||
const appendDatabase = ServerContext.useStoreActions(actions => actions.databases.appendDatabase);
|
||||
const [connectionVisible, setConnectionVisible] = useState(false);
|
||||
const [ connectionVisible, setConnectionVisible ] = useState(false);
|
||||
const { addFlash, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
|
||||
const server = ServerContext.useStoreState(state => state.server.data!);
|
||||
|
||||
|
@ -38,7 +38,7 @@ export default ({ databaseId, className, onDelete }: Props) => {
|
|||
const schema = object().shape({
|
||||
confirm: string()
|
||||
.required('The database name must be provided.')
|
||||
.oneOf([database.name.split('_', 2)[1], database.name], 'The database name must be provided.'),
|
||||
.oneOf([ database.name.split('_', 2)[1], database.name ], 'The database name must be provided.'),
|
||||
});
|
||||
|
||||
const submit = (values: { confirm: string }, { setSubmitting }: FormikHelpers<{ confirm: string }>) => {
|
||||
|
@ -73,7 +73,10 @@ export default ({ databaseId, className, onDelete }: Props) => {
|
|||
visible={visible}
|
||||
dismissable={!isSubmitting}
|
||||
showSpinnerOverlay={isSubmitting}
|
||||
onDismissed={() => { setVisible(false); resetForm(); }}
|
||||
onDismissed={() => {
|
||||
setVisible(false);
|
||||
resetForm();
|
||||
}}
|
||||
>
|
||||
<FlashMessageRender byKey={'delete-database-modal'} className={'mb-6'}/>
|
||||
<h3 className={'mb-6'}>Confirm database deletion</h3>
|
||||
|
@ -113,10 +116,12 @@ export default ({ databaseId, className, onDelete }: Props) => {
|
|||
<Modal visible={connectionVisible} onDismissed={() => setConnectionVisible(false)}>
|
||||
<FlashMessageRender byKey={'database-connection-modal'} className={'mb-6'}/>
|
||||
<h3 className={'mb-6'}>Database connection details</h3>
|
||||
<div>
|
||||
<label className={'input-dark-label'}>Password</label>
|
||||
<input type={'text'} className={'input-dark'} readOnly={true} value={database.password}/>
|
||||
</div>
|
||||
<Can action={'database.view_password'}>
|
||||
<div>
|
||||
<label className={'input-dark-label'}>Password</label>
|
||||
<input type={'text'} className={'input-dark'} readOnly={true} value={database.password}/>
|
||||
</div>
|
||||
</Can>
|
||||
<div className={'mt-6'}>
|
||||
<label className={'input-dark-label'}>JBDC Connection String</label>
|
||||
<input
|
||||
|
@ -127,7 +132,9 @@ export default ({ databaseId, className, onDelete }: Props) => {
|
|||
/>
|
||||
</div>
|
||||
<div className={'mt-6 text-right'}>
|
||||
<RotatePasswordButton databaseId={database.id} onUpdate={appendDatabase}/>
|
||||
<Can action={'database.update'}>
|
||||
<RotatePasswordButton databaseId={database.id} onUpdate={appendDatabase}/>
|
||||
</Can>
|
||||
<button className={'btn btn-sm btn-secondary'} onClick={() => setConnectionVisible(false)}>
|
||||
Close
|
||||
</button>
|
||||
|
@ -156,9 +163,11 @@ export default ({ databaseId, className, onDelete }: Props) => {
|
|||
<button className={'btn btn-sm btn-secondary mr-2'} onClick={() => setConnectionVisible(true)}>
|
||||
<FontAwesomeIcon icon={faEye} fixedWidth={true}/>
|
||||
</button>
|
||||
<button className={'btn btn-sm btn-secondary btn-red'} onClick={() => setVisible(true)}>
|
||||
<FontAwesomeIcon icon={faTrashAlt} fixedWidth={true}/>
|
||||
</button>
|
||||
<Can action={'database.delete'}>
|
||||
<button className={'btn btn-sm btn-secondary btn-red'} onClick={() => setVisible(true)}>
|
||||
<FontAwesomeIcon icon={faTrashAlt} fixedWidth={true}/>
|
||||
</button>
|
||||
</Can>
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import getServerDatabases, { ServerDatabase } from '@/api/server/getServerDatabases';
|
||||
import getServerDatabases from '@/api/server/getServerDatabases';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import { Actions, useStoreActions } from 'easy-peasy';
|
||||
import { ApplicationStore } from '@/state';
|
||||
|
@ -9,6 +9,7 @@ import DatabaseRow from '@/components/server/databases/DatabaseRow';
|
|||
import Spinner from '@/components/elements/Spinner';
|
||||
import { CSSTransition } from 'react-transition-group';
|
||||
import CreateDatabaseButton from '@/components/server/databases/CreateDatabaseButton';
|
||||
import Can from '@/components/elements/Can';
|
||||
|
||||
export default () => {
|
||||
const [ loading, setLoading ] = useState(true);
|
||||
|
@ -41,7 +42,7 @@ export default () => {
|
|||
<Spinner size={'large'} centered={true}/>
|
||||
:
|
||||
<CSSTransition classNames={'fade'} timeout={250}>
|
||||
<React.Fragment>
|
||||
<>
|
||||
{databases.length > 0 ?
|
||||
databases.map((database, index) => (
|
||||
<DatabaseRow
|
||||
|
@ -54,18 +55,20 @@ export default () => {
|
|||
:
|
||||
<p className={'text-center text-sm text-neutral-400'}>
|
||||
{server.featureLimits.databases > 0 ?
|
||||
`It looks like you have no databases. Click the button below to create one now.`
|
||||
`It looks like you have no databases.`
|
||||
:
|
||||
`Databases cannot be created for this server.`
|
||||
}
|
||||
</p>
|
||||
}
|
||||
{server.featureLimits.databases > 0 &&
|
||||
<div className={'mt-6 flex justify-end'}>
|
||||
<CreateDatabaseButton onCreated={appendDatabase}/>
|
||||
</div>
|
||||
}
|
||||
</React.Fragment>
|
||||
<Can action={'database.create'}>
|
||||
{server.featureLimits.databases > 0 &&
|
||||
<div className={'mt-6 flex justify-end'}>
|
||||
<CreateDatabaseButton onCreated={appendDatabase}/>
|
||||
</div>
|
||||
}
|
||||
</Can>
|
||||
</>
|
||||
</CSSTransition>
|
||||
}
|
||||
</div>
|
||||
|
|
|
@ -13,7 +13,8 @@ import { join } from 'path';
|
|||
import deleteFile from '@/api/server/files/deleteFile';
|
||||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||
import copyFile from '@/api/server/files/copyFile';
|
||||
import http, { httpErrorToHuman } from '@/api/http';
|
||||
import { httpErrorToHuman } from '@/api/http';
|
||||
import Can from '@/components/elements/Can';
|
||||
|
||||
type ModalType = 'rename' | 'move';
|
||||
|
||||
|
@ -118,30 +119,37 @@ export default ({ uuid }: { uuid: string }) => {
|
|||
<CSSTransition timeout={250} in={menuVisible} unmountOnExit={true} classNames={'fade'}>
|
||||
<div
|
||||
ref={menu}
|
||||
onClick={e => { e.stopPropagation(); setMenuVisible(false); }}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setMenuVisible(false);
|
||||
}}
|
||||
className={'absolute bg-white p-2 rounded border border-neutral-700 shadow-lg text-neutral-500 min-w-48'}
|
||||
>
|
||||
<div
|
||||
onClick={() => setModal('rename')}
|
||||
className={'hover:text-neutral-700 p-2 flex items-center hover:bg-neutral-100 rounded'}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPencilAlt} className={'text-xs'}/>
|
||||
<span className={'ml-2'}>Rename</span>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setModal('move')}
|
||||
className={'hover:text-neutral-700 p-2 flex items-center hover:bg-neutral-100 rounded'}
|
||||
>
|
||||
<FontAwesomeIcon icon={faLevelUpAlt} className={'text-xs'}/>
|
||||
<span className={'ml-2'}>Move</span>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => doCopy()}
|
||||
className={'hover:text-neutral-700 p-2 flex items-center hover:bg-neutral-100 rounded'}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCopy} className={'text-xs'}/>
|
||||
<span className={'ml-2'}>Copy</span>
|
||||
</div>
|
||||
<Can action={'file.update'}>
|
||||
<div
|
||||
onClick={() => setModal('rename')}
|
||||
className={'hover:text-neutral-700 p-2 flex items-center hover:bg-neutral-100 rounded'}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPencilAlt} className={'text-xs'}/>
|
||||
<span className={'ml-2'}>Rename</span>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setModal('move')}
|
||||
className={'hover:text-neutral-700 p-2 flex items-center hover:bg-neutral-100 rounded'}
|
||||
>
|
||||
<FontAwesomeIcon icon={faLevelUpAlt} className={'text-xs'}/>
|
||||
<span className={'ml-2'}>Move</span>
|
||||
</div>
|
||||
</Can>
|
||||
<Can action={'file.create'}>
|
||||
<div
|
||||
onClick={() => doCopy()}
|
||||
className={'hover:text-neutral-700 p-2 flex items-center hover:bg-neutral-100 rounded'}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCopy} className={'text-xs'}/>
|
||||
<span className={'ml-2'}>Copy</span>
|
||||
</div>
|
||||
</Can>
|
||||
<div
|
||||
className={'hover:text-neutral-700 p-2 flex items-center hover:bg-neutral-100 rounded'}
|
||||
onClick={() => doDownload()}
|
||||
|
@ -149,13 +157,15 @@ export default ({ uuid }: { uuid: string }) => {
|
|||
<FontAwesomeIcon icon={faFileDownload} className={'text-xs'}/>
|
||||
<span className={'ml-2'}>Download</span>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => doDeletion()}
|
||||
className={'hover:text-red-700 p-2 flex items-center hover:bg-red-100 rounded'}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrashAlt} className={'text-xs'}/>
|
||||
<span className={'ml-2'}>Delete</span>
|
||||
</div>
|
||||
<Can action={'file.delete'}>
|
||||
<div
|
||||
onClick={() => doDeletion()}
|
||||
className={'hover:text-red-700 p-2 flex items-center hover:bg-red-100 rounded'}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrashAlt} className={'text-xs'}/>
|
||||
<span className={'ml-2'}>Delete</span>
|
||||
</div>
|
||||
</Can>
|
||||
</div>
|
||||
</CSSTransition>
|
||||
</div>
|
||||
|
|
|
@ -2,7 +2,7 @@ import React, { lazy, useEffect, useState } from 'react';
|
|||
import { ServerContext } from '@/state/server';
|
||||
import getFileContents from '@/api/server/files/getFileContents';
|
||||
import useRouter from 'use-react-router';
|
||||
import { Actions, useStoreState } from 'easy-peasy';
|
||||
import { Actions, useStoreActions, useStoreState } from 'easy-peasy';
|
||||
import { ApplicationStore } from '@/state';
|
||||
import { httpErrorToHuman } from '@/api/http';
|
||||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||
|
@ -10,6 +10,8 @@ import saveFileContents from '@/api/server/files/saveFileContents';
|
|||
import FileManagerBreadcrumbs from '@/components/server/files/FileManagerBreadcrumbs';
|
||||
import { useParams } from 'react-router';
|
||||
import FileNameModal from '@/components/server/files/FileNameModal';
|
||||
import Can from '@/components/elements/Can';
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
|
||||
const LazyAceEditor = lazy(() => import(/* webpackChunkName: "editor" */'@/components/elements/AceEditor'));
|
||||
|
||||
|
@ -21,15 +23,20 @@ export default () => {
|
|||
const [ modalVisible, setModalVisible ] = useState(false);
|
||||
|
||||
const { id, uuid } = ServerContext.useStoreState(state => state.server.data!);
|
||||
const addError = useStoreState((state: Actions<ApplicationStore>) => state.flashes.addError);
|
||||
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
|
||||
|
||||
let fetchFileContent: null | (() => Promise<string>) = null;
|
||||
|
||||
if (action !== 'new') {
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
clearFlashes('files:view');
|
||||
getFileContents(uuid, hash.replace(/^#/, ''))
|
||||
.then(setContent)
|
||||
.catch(error => console.error(error))
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
addError({ key: 'files:view', message: httpErrorToHuman(error) });
|
||||
})
|
||||
.then(() => setLoading(false));
|
||||
}, [ uuid, hash ]);
|
||||
}
|
||||
|
@ -40,10 +47,10 @@ export default () => {
|
|||
}
|
||||
|
||||
setLoading(true);
|
||||
fetchFileContent()
|
||||
.then(content => {
|
||||
return saveFileContents(uuid, name || hash.replace(/^#/, ''), content);
|
||||
})
|
||||
clearFlashes('files:view');
|
||||
fetchFileContent().then(content => {
|
||||
return saveFileContents(uuid, name || hash.replace(/^#/, ''), content);
|
||||
})
|
||||
.then(() => {
|
||||
if (name) {
|
||||
history.push(`/server/${id}/files/edit#/${name}`);
|
||||
|
@ -54,13 +61,14 @@ export default () => {
|
|||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
addError({ message: httpErrorToHuman(error), key: 'files' });
|
||||
addError({ message: httpErrorToHuman(error), key: 'files:view' });
|
||||
})
|
||||
.then(() => setLoading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={'mt-10 mb-4'}>
|
||||
<FlashMessageRender byKey={'files:view'} className={'mb-4'}/>
|
||||
<FileManagerBreadcrumbs withinFileEditor={true} isNewFile={action !== 'edit'}/>
|
||||
<FileNameModal
|
||||
visible={modalVisible}
|
||||
|
@ -83,13 +91,17 @@ export default () => {
|
|||
</div>
|
||||
<div className={'flex justify-end mt-4'}>
|
||||
{action === 'edit' ?
|
||||
<button className={'btn btn-primary btn-sm'} onClick={() => save()}>
|
||||
Save Content
|
||||
</button>
|
||||
<Can action={'file.update'}>
|
||||
<button className={'btn btn-primary btn-sm'} onClick={() => save()}>
|
||||
Save Content
|
||||
</button>
|
||||
</Can>
|
||||
:
|
||||
<button className={'btn btn-primary btn-sm'} onClick={() => setModalVisible(true)}>
|
||||
Create File
|
||||
</button>
|
||||
<Can action={'file.create'}>
|
||||
<button className={'btn btn-primary btn-sm'} onClick={() => setModalVisible(true)}>
|
||||
Create File
|
||||
</button>
|
||||
</Can>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -11,6 +11,7 @@ import FileManagerBreadcrumbs from '@/components/server/files/FileManagerBreadcr
|
|||
import { FileObject } from '@/api/server/files/loadDirectory';
|
||||
import NewDirectoryButton from '@/components/server/files/NewDirectoryButton';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Can from '@/components/elements/Can';
|
||||
|
||||
const sortFiles = (files: FileObject[]): FileObject[] => {
|
||||
return files.sort((a, b) => a.name.localeCompare(b.name))
|
||||
|
@ -34,7 +35,6 @@ export default () => {
|
|||
console.error(error.message, { error });
|
||||
addError({ message: httpErrorToHuman(error), key: 'files' });
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ directory ]);
|
||||
|
||||
return (
|
||||
|
@ -78,12 +78,17 @@ export default () => {
|
|||
</React.Fragment>
|
||||
</CSSTransition>
|
||||
}
|
||||
<div className={'flex justify-end mt-8'}>
|
||||
<NewDirectoryButton/>
|
||||
<Link to={`/server/${id}/files/new${window.location.hash}`} className={'btn btn-sm btn-primary'}>
|
||||
New File
|
||||
</Link>
|
||||
</div>
|
||||
<Can action={'file.create'}>
|
||||
<div className={'flex justify-end mt-8'}>
|
||||
<NewDirectoryButton/>
|
||||
<Link
|
||||
to={`/server/${id}/files/new${window.location.hash}`}
|
||||
className={'btn btn-sm btn-primary'}
|
||||
>
|
||||
New File
|
||||
</Link>
|
||||
</div>
|
||||
</Can>
|
||||
</React.Fragment>
|
||||
}
|
||||
</React.Fragment>
|
||||
|
|
|
@ -9,6 +9,7 @@ import { httpErrorToHuman } from '@/api/http';
|
|||
import { Actions, useStoreActions } from 'easy-peasy';
|
||||
import { ApplicationStore } from '@/state';
|
||||
import EditScheduleModal from '@/components/server/schedules/EditScheduleModal';
|
||||
import Can from '@/components/elements/Can';
|
||||
|
||||
export default ({ match, history }: RouteComponentProps) => {
|
||||
const { uuid } = ServerContext.useStoreState(state => state.server.data!);
|
||||
|
@ -35,9 +36,8 @@ export default ({ match, history }: RouteComponentProps) => {
|
|||
<>
|
||||
{
|
||||
schedules.length === 0 ?
|
||||
<p className={'text-sm text-neutral-400'}>
|
||||
There are no schedules configured for this server. Click the button below to get
|
||||
started.
|
||||
<p className={'text-sm text-center text-neutral-400'}>
|
||||
There are no schedules configured for this server.
|
||||
</p>
|
||||
:
|
||||
schedules.map(schedule => (
|
||||
|
@ -54,21 +54,23 @@ export default ({ match, history }: RouteComponentProps) => {
|
|||
</a>
|
||||
))
|
||||
}
|
||||
<div className={'mt-8 flex justify-end'}>
|
||||
{visible && <EditScheduleModal
|
||||
appear={true}
|
||||
visible={true}
|
||||
onScheduleUpdated={schedule => setSchedules(s => [...(s || []), schedule])}
|
||||
onDismissed={() => setVisible(false)}
|
||||
/>}
|
||||
<button
|
||||
type={'button'}
|
||||
className={'btn btn-lg btn-primary'}
|
||||
onClick={() => setVisible(true)}
|
||||
>
|
||||
Create schedule
|
||||
</button>
|
||||
</div>
|
||||
<Can action={'schedule.create'}>
|
||||
<div className={'mt-8 flex justify-end'}>
|
||||
{visible && <EditScheduleModal
|
||||
appear={true}
|
||||
visible={true}
|
||||
onScheduleUpdated={schedule => setSchedules(s => [ ...(s || []), schedule ])}
|
||||
onDismissed={() => setVisible(false)}
|
||||
/>}
|
||||
<button
|
||||
type={'button'}
|
||||
className={'btn btn-sm btn-primary'}
|
||||
onClick={() => setVisible(true)}
|
||||
>
|
||||
Create schedule
|
||||
</button>
|
||||
</div>
|
||||
</Can>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
|
|
|
@ -13,6 +13,7 @@ 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';
|
||||
import Can from '@/components/elements/Can';
|
||||
|
||||
interface Params {
|
||||
id: string;
|
||||
|
@ -93,24 +94,27 @@ export default ({ match, history, location: { state } }: RouteComponentProps<Par
|
|||
</>
|
||||
:
|
||||
<p className={'text-sm text-neutral-400'}>
|
||||
There are no tasks configured for this schedule. Consider adding a new one using the
|
||||
button below.
|
||||
There are no tasks configured for this schedule.
|
||||
</p>
|
||||
}
|
||||
<div className={'mt-8 flex justify-end'}>
|
||||
<DeleteScheduleButton
|
||||
scheduleId={schedule.id}
|
||||
onDeleted={() => history.push(`/server/${id}/schedules`)}
|
||||
/>
|
||||
<button className={'btn btn-primary btn-sm mr-4'} onClick={() => setShowEditModal(true)}>
|
||||
Edit
|
||||
</button>
|
||||
<NewTaskButton
|
||||
scheduleId={schedule.id}
|
||||
onTaskAdded={task => setSchedule(s => ({
|
||||
...s!, tasks: [ ...s!.tasks, task ],
|
||||
}))}
|
||||
/>
|
||||
<Can action={'schedule.delete'}>
|
||||
<DeleteScheduleButton
|
||||
scheduleId={schedule.id}
|
||||
onDeleted={() => history.push(`/server/${id}/schedules`)}
|
||||
/>
|
||||
</Can>
|
||||
<Can action={'schedule.update'}>
|
||||
<button className={'btn btn-primary btn-sm mr-4'} onClick={() => setShowEditModal(true)}>
|
||||
Edit
|
||||
</button>
|
||||
<NewTaskButton
|
||||
scheduleId={schedule.id}
|
||||
onTaskAdded={task => setSchedule(s => ({
|
||||
...s!, tasks: [ ...s!.tasks, task ],
|
||||
}))}
|
||||
/>
|
||||
</Can>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@ import { httpErrorToHuman } from '@/api/http';
|
|||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||
import TaskDetailsModal from '@/components/server/schedules/TaskDetailsModal';
|
||||
import { faPencilAlt } from '@fortawesome/free-solid-svg-icons/faPencilAlt';
|
||||
import Can from '@/components/elements/Can';
|
||||
|
||||
interface Props {
|
||||
schedule: number;
|
||||
|
@ -75,22 +76,26 @@ export default ({ schedule, task, onTaskUpdated, onTaskRemoved }: Props) => {
|
|||
</p>
|
||||
</div>
|
||||
}
|
||||
<button
|
||||
type={'button'}
|
||||
aria-label={'Edit scheduled task'}
|
||||
className={'block text-sm p-2 text-neutral-500 hover:text-neutral-100 transition-colors duration-150 mr-4'}
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPencilAlt}/>
|
||||
</button>
|
||||
<button
|
||||
type={'button'}
|
||||
aria-label={'Delete scheduled task'}
|
||||
className={'block text-sm p-2 text-neutral-500 hover:text-red-600 transition-colors duration-150'}
|
||||
onClick={() => setVisible(true)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrashAlt}/>
|
||||
</button>
|
||||
<Can action={'schedule.update'}>
|
||||
<button
|
||||
type={'button'}
|
||||
aria-label={'Edit scheduled task'}
|
||||
className={'block text-sm p-2 text-neutral-500 hover:text-neutral-100 transition-colors duration-150 mr-4'}
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPencilAlt}/>
|
||||
</button>
|
||||
</Can>
|
||||
<Can action={'schedule.update'}>
|
||||
<button
|
||||
type={'button'}
|
||||
aria-label={'Delete scheduled task'}
|
||||
className={'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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -6,6 +6,7 @@ import { ApplicationStore } from '@/state';
|
|||
import { UserData } from '@/state/user';
|
||||
import RenameServerBox from '@/components/server/settings/RenameServerBox';
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import Can from '@/components/elements/Can';
|
||||
|
||||
export default () => {
|
||||
const user = useStoreState<ApplicationStore, UserData>(state => state.user.data!);
|
||||
|
@ -15,46 +16,50 @@ export default () => {
|
|||
<div className={'my-10 mb-6'}>
|
||||
<FlashMessageRender byKey={'settings'} className={'mb-4'}/>
|
||||
<div className={'md:flex'}>
|
||||
<TitledGreyBox title={'SFTP Details'} className={'w-full md:flex-1 md:mr-6'}>
|
||||
<div>
|
||||
<label className={'input-dark-label'}>Server Address</label>
|
||||
<input
|
||||
type={'text'}
|
||||
className={'input-dark'}
|
||||
value={`sftp://${server.sftpDetails.ip}:${server.sftpDetails.port}`}
|
||||
readOnly={true}
|
||||
/>
|
||||
</div>
|
||||
<div className={'mt-6'}>
|
||||
<label className={'input-dark-label'}>Username</label>
|
||||
<input
|
||||
type={'text'}
|
||||
className={'input-dark'}
|
||||
value={`${user.username}.${server.id}`}
|
||||
readOnly={true}
|
||||
/>
|
||||
</div>
|
||||
<div className={'mt-6 flex items-center'}>
|
||||
<div className={'flex-1'}>
|
||||
<div className={'border-l-4 border-cyan-500 p-3'}>
|
||||
<p className={'text-xs text-neutral-200'}>
|
||||
Your SFTP password is the same as the password you use to access this panel.
|
||||
</p>
|
||||
<Can action={'file.sftp'}>
|
||||
<TitledGreyBox title={'SFTP Details'} className={'w-full md:flex-1 md:max-w-1/2 md:mr-6'}>
|
||||
<div>
|
||||
<label className={'input-dark-label'}>Server Address</label>
|
||||
<input
|
||||
type={'text'}
|
||||
className={'input-dark'}
|
||||
value={`sftp://${server.sftpDetails.ip}:${server.sftpDetails.port}`}
|
||||
readOnly={true}
|
||||
/>
|
||||
</div>
|
||||
<div className={'mt-6'}>
|
||||
<label className={'input-dark-label'}>Username</label>
|
||||
<input
|
||||
type={'text'}
|
||||
className={'input-dark'}
|
||||
value={`${user.username}.${server.id}`}
|
||||
readOnly={true}
|
||||
/>
|
||||
</div>
|
||||
<div className={'mt-6 flex items-center'}>
|
||||
<div className={'flex-1'}>
|
||||
<div className={'border-l-4 border-cyan-500 p-3'}>
|
||||
<p className={'text-xs text-neutral-200'}>
|
||||
Your SFTP password is the same as the password you use to access this panel.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={'ml-4'}>
|
||||
<a
|
||||
href={`sftp://${user.username}.${server.id}@${server.sftpDetails.ip}:${server.sftpDetails.port}`}
|
||||
className={'btn btn-sm btn-secondary'}
|
||||
>
|
||||
Launch SFTP
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className={'ml-4'}>
|
||||
<a
|
||||
href={`sftp://${user.username}.${server.id}@${server.sftpDetails.ip}:${server.sftpDetails.port}`}
|
||||
className={'btn btn-sm btn-secondary'}
|
||||
>
|
||||
Launch SFTP
|
||||
</a>
|
||||
</div>
|
||||
</TitledGreyBox>
|
||||
</Can>
|
||||
<Can action={'settings.rename'}>
|
||||
<div className={'w-full mt-6 md:flex-1 md:max-w-1/2 md:mt-0'}>
|
||||
<RenameServerBox/>
|
||||
</div>
|
||||
</TitledGreyBox>
|
||||
<div className={'w-full mt-6 md:flex-1 md:mt-0'}>
|
||||
<RenameServerBox/>
|
||||
</div>
|
||||
</Can>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -14,6 +14,8 @@ import createOrUpdateSubuser from '@/api/server/users/createOrUpdateSubuser';
|
|||
import { ServerContext } from '@/state/server';
|
||||
import { httpErrorToHuman } from '@/api/http';
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import Can from '@/components/elements/Can';
|
||||
import { usePermissions } from '@/plugins/usePermissions';
|
||||
|
||||
type Props = {
|
||||
subuser?: Subuser;
|
||||
|
@ -25,21 +27,32 @@ interface Values {
|
|||
}
|
||||
|
||||
const PermissionLabel = styled.label`
|
||||
${tw`flex items-center border border-transparent rounded p-2 cursor-pointer`};
|
||||
${tw`flex items-center border border-transparent rounded p-2`};
|
||||
text-transform: none;
|
||||
|
||||
&:hover {
|
||||
${tw`border-neutral-500 bg-neutral-800`};
|
||||
&:not(.disabled) {
|
||||
${tw`cursor-pointer`};
|
||||
|
||||
&:hover {
|
||||
${tw`border-neutral-500 bg-neutral-800`};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const EditSubuserModal = forwardRef<HTMLHeadingElement, Props>(({ subuser, ...props }, ref) => {
|
||||
const { values, isSubmitting, setFieldValue } = useFormikContext<Values>();
|
||||
const [ canEditUser ] = usePermissions([ 'user.update' ]);
|
||||
const permissions = useStoreState((state: ApplicationStore) => state.permissions.data);
|
||||
|
||||
return (
|
||||
<Modal {...props} showSpinnerOverlay={isSubmitting}>
|
||||
<h3 ref={ref}>{subuser ? `Modify permissions for ${subuser.email}` : 'Create new subuser'}</h3>
|
||||
<h3 ref={ref}>
|
||||
{subuser ?
|
||||
`${canEditUser ? 'Modify' : 'View'} permissions for ${subuser.email}`
|
||||
:
|
||||
'Create new subuser'
|
||||
}
|
||||
</h3>
|
||||
<FlashMessageRender byKey={'user:edit'} className={'mt-4'}/>
|
||||
{!subuser &&
|
||||
<div className={'mt-6'}>
|
||||
|
@ -50,13 +63,14 @@ const EditSubuserModal = forwardRef<HTMLHeadingElement, Props>(({ subuser, ...pr
|
|||
/>
|
||||
</div>
|
||||
}
|
||||
<div className={'mt-6'}>
|
||||
<div className={'my-6'}>
|
||||
{Object.keys(permissions).filter(key => key !== 'websocket').map((key, index) => (
|
||||
<TitledGreyBox
|
||||
key={key}
|
||||
title={
|
||||
<div className={'flex items-center'}>
|
||||
<p className={'text-sm uppercase flex-1'}>{key}</p>
|
||||
{canEditUser &&
|
||||
<input
|
||||
type={'checkbox'}
|
||||
onClick={e => {
|
||||
|
@ -78,6 +92,7 @@ const EditSubuserModal = forwardRef<HTMLHeadingElement, Props>(({ subuser, ...pr
|
|||
}
|
||||
}}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
className={index !== 0 ? 'mt-4' : undefined}
|
||||
|
@ -87,9 +102,11 @@ const EditSubuserModal = forwardRef<HTMLHeadingElement, Props>(({ subuser, ...pr
|
|||
</p>
|
||||
{Object.keys(permissions[key].keys).map((pkey, index) => (
|
||||
<PermissionLabel
|
||||
key={`permission_${key}_${pkey}`}
|
||||
htmlFor={`permission_${key}_${pkey}`}
|
||||
className={classNames('transition-colors duration-75', {
|
||||
'mt-2': index !== 0,
|
||||
disabled: !canEditUser,
|
||||
})}
|
||||
>
|
||||
<div className={'p-2'}>
|
||||
|
@ -98,6 +115,7 @@ const EditSubuserModal = forwardRef<HTMLHeadingElement, Props>(({ subuser, ...pr
|
|||
name={'permissions'}
|
||||
value={`${key}.${pkey}`}
|
||||
className={'w-5 h-5 mr-2'}
|
||||
disabled={!canEditUser}
|
||||
/>
|
||||
</div>
|
||||
<div className={'flex-1'}>
|
||||
|
@ -115,11 +133,13 @@ const EditSubuserModal = forwardRef<HTMLHeadingElement, Props>(({ subuser, ...pr
|
|||
</TitledGreyBox>
|
||||
))}
|
||||
</div>
|
||||
<div className={'mt-6 pb-6 flex justify-end'}>
|
||||
<button className={'btn btn-primary btn-sm'} type={'submit'}>
|
||||
{subuser ? 'Save' : 'Invite User'}
|
||||
</button>
|
||||
</div>
|
||||
<Can action={subuser ? 'user.update' : 'user.delete'}>
|
||||
<div className={'pb-6 flex justify-end'}>
|
||||
<button className={'btn btn-primary btn-sm'} type={'submit'}>
|
||||
{subuser ? 'Save' : 'Invite User'}
|
||||
</button>
|
||||
</div>
|
||||
</Can>
|
||||
</Modal>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -7,6 +7,7 @@ import EditSubuserModal from '@/components/server/users/EditSubuserModal';
|
|||
import { faUnlockAlt } from '@fortawesome/free-solid-svg-icons/faUnlockAlt';
|
||||
import { faUserLock } from '@fortawesome/free-solid-svg-icons/faUserLock';
|
||||
import classNames from 'classnames';
|
||||
import Can from '@/components/elements/Can';
|
||||
|
||||
interface Props {
|
||||
subuser: Subuser;
|
||||
|
@ -58,7 +59,9 @@ export default ({ subuser }: Props) => {
|
|||
>
|
||||
<FontAwesomeIcon icon={faPencilAlt}/>
|
||||
</button>
|
||||
<RemoveSubuserButton subuser={subuser}/>
|
||||
<Can action={'user.delete'}>
|
||||
<RemoveSubuserButton subuser={subuser}/>
|
||||
</Can>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -8,6 +8,7 @@ import UserRow from '@/components/server/users/UserRow';
|
|||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import getServerSubusers from '@/api/server/users/getServerSubusers';
|
||||
import { httpErrorToHuman } from '@/api/http';
|
||||
import Can from '@/components/elements/Can';
|
||||
|
||||
export default () => {
|
||||
const [ loading, setLoading ] = useState(true);
|
||||
|
@ -42,7 +43,7 @@ export default () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={'my-10'}>
|
||||
<div className={'mt-10 mb-6'}>
|
||||
<FlashMessageRender byKey={'users'} className={'mb-4'}/>
|
||||
{!subusers.length ?
|
||||
<p className={'text-center text-sm text-neutral-400'}>
|
||||
|
@ -53,9 +54,11 @@ export default () => {
|
|||
<UserRow key={subuser.uuid} subuser={subuser}/>
|
||||
))
|
||||
}
|
||||
<div className={'flex justify-end mt-6'}>
|
||||
<AddSubuserButton/>
|
||||
</div>
|
||||
<Can action={'user.create'}>
|
||||
<div className={'flex justify-end mt-6'}>
|
||||
<AddSubuserButton/>
|
||||
</div>
|
||||
</Can>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -5,7 +5,7 @@ export function bytesToHuman (bytes: number): string {
|
|||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1000));
|
||||
// @ts-ignore
|
||||
return `${(bytes / Math.pow(1000, i)).toFixed(2) * 1} ${['Bytes', 'kB', 'MB', 'GB', 'TB'][i]}`;
|
||||
return `${(bytes / Math.pow(1000, i)).toFixed(2) * 1} ${[ 'Bytes', 'kB', 'MB', 'GB', 'TB' ][i]}`;
|
||||
}
|
||||
|
||||
export const bytesToMegabytes = (bytes: number) => Math.floor(bytes / 1000 / 1000);
|
||||
|
|
12
resources/scripts/plugins/useDeepMemo.ts
Normal file
12
resources/scripts/plugins/useDeepMemo.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { useRef } from 'react';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
|
||||
export const useDeepMemo = <T, K> (fn: () => T, key: K): T => {
|
||||
const ref = useRef<{ key: K, value: T }>();
|
||||
|
||||
if (!ref.current || !isEqual(key, ref.current.key)) {
|
||||
ref.current = { key, value: fn() };
|
||||
}
|
||||
|
||||
return ref.current.value;
|
||||
};
|
25
resources/scripts/plugins/usePermissions.ts
Normal file
25
resources/scripts/plugins/usePermissions.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { ServerContext } from '@/state/server';
|
||||
import { useDeepMemo } from '@/plugins/useDeepMemo';
|
||||
|
||||
export const usePermissions = (action: string | string[]): boolean[] => {
|
||||
const userPermissions = ServerContext.useStoreState(state => state.server.permissions);
|
||||
|
||||
return useDeepMemo(() => {
|
||||
if (userPermissions[0] === '*') {
|
||||
return Array(Array.isArray(action) ? action.length : 1).fill(true);
|
||||
}
|
||||
|
||||
return (Array.isArray(action) ? action : [ action ])
|
||||
.map(permission => (
|
||||
// Allows checking for any permission matching a name, for example files.*
|
||||
// will return if the user has any permission under the file.XYZ namespace.
|
||||
(
|
||||
permission.endsWith('.*') &&
|
||||
permission !== 'websocket.*' &&
|
||||
userPermissions.filter(p => p.startsWith(permission.split('.')[0])).length > 0
|
||||
) ||
|
||||
// Otherwise just check if the entire permission exists in the array or not.
|
||||
userPermissions.indexOf(permission) >= 0
|
||||
));
|
||||
}, [ action, userPermissions ]);
|
||||
};
|
|
@ -47,7 +47,7 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>)
|
|||
<Can action={'user.*'}>
|
||||
<NavLink to={`${match.url}/users`}>Users</NavLink>
|
||||
</Can>
|
||||
<Can action={'settings.*'}>
|
||||
<Can action={['settings.*', 'file.sftp']} matchAny={true}>
|
||||
<NavLink to={`${match.url}/settings`}>Settings</NavLink>
|
||||
</Can>
|
||||
</div>
|
||||
|
|
|
@ -2,7 +2,7 @@ import { action, Action } from 'easy-peasy';
|
|||
|
||||
export type SubuserPermission =
|
||||
'websocket.*' |
|
||||
'control.console' | 'control.start' | 'control.stop' | 'control.restart' | 'control.kill' |
|
||||
'control.console' | 'control.start' | 'control.stop' | 'control.restart' |
|
||||
'user.create' | 'user.read' | 'user.update' | 'user.delete' |
|
||||
'file.create' | 'file.read' | 'file.update' | 'file.delete' | 'file.archive' | 'file.sftp' |
|
||||
'allocation.read' | 'allocation.update' |
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
transition: opacity 250ms ease;
|
||||
|
||||
& > .modal-container {
|
||||
@apply .relative .w-full .max-w-md .m-auto .flex-col .flex;
|
||||
@apply .relative .w-full .max-w-1/2 .m-auto .flex-col .flex;
|
||||
|
||||
/*&.top {
|
||||
margin-top: 10%;
|
||||
|
|
|
@ -30,25 +30,25 @@
|
|||
<div class="form-group">
|
||||
<label for="email" class="control-label">Email</label>
|
||||
<div>
|
||||
<input readonly type="email" name="email" value="{{ $user->email }}" class="form-control form-autocomplete-stop">
|
||||
<input type="email" name="email" value="{{ $user->email }}" class="form-control form-autocomplete-stop">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="registered" class="control-label">Username</label>
|
||||
<div>
|
||||
<input readonly type="text" name="username" value="{{ $user->username }}" class="form-control form-autocomplete-stop">
|
||||
<input type="text" name="username" value="{{ $user->username }}" class="form-control form-autocomplete-stop">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="registered" class="control-label">Client First Name</label>
|
||||
<div>
|
||||
<input readonly type="text" name="name_first" value="{{ $user->name_first }}" class="form-control form-autocomplete-stop">
|
||||
<input type="text" name="name_first" value="{{ $user->name_first }}" class="form-control form-autocomplete-stop">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="registered" class="control-label">Client Last Name</label>
|
||||
<div>
|
||||
<input readonly type="text" name="name_last" value="{{ $user->name_last }}" class="form-control form-autocomplete-stop">
|
||||
<input type="text" name="name_last" value="{{ $user->name_last }}" class="form-control form-autocomplete-stop">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
|
@ -80,7 +80,7 @@
|
|||
<div class="form-group no-margin-bottom">
|
||||
<label for="password" class="control-label">Password <span class="field-optional"></span></label>
|
||||
<div>
|
||||
<input readonly type="password" id="password" name="password" class="form-control form-autocomplete-stop">
|
||||
<input type="password" id="password" name="password" class="form-control form-autocomplete-stop">
|
||||
<p class="text-muted small">Leave blank to keep this user's password the same. User will not receive any notification if password is changed.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue