1
0
mirror of https://github.com/OpenNebula/one.git synced 2025-01-25 06:03:36 +03:00

F #5422: Add ssh keys to settings (#1947)

This commit is contained in:
Sergio Betanzos 2022-04-18 14:33:58 +02:00 committed by Ruben S. Montero
parent 11b6f1565e
commit 22143403b6
No known key found for this signature in database
GPG Key ID: A0CEA6FA880A1D87
10 changed files with 344 additions and 86 deletions

View File

@ -57,7 +57,7 @@ const FormWithSchema = ({
const formContext = useFormContext()
const { control, watch } = formContext
const { sx: sxRoot, restOfRootProps } = rootProps ?? {}
const { sx: sxRoot, ...restOfRootProps } = rootProps ?? {}
const getFields = useMemo(
() => (typeof fields === 'function' ? fields() : fields),

View File

@ -18,7 +18,7 @@ import PropTypes from 'prop-types'
import { styled, Typography } from '@mui/material'
import AdornmentWithTooltip from 'client/components/FormControl/Tooltip'
import { Tr, labelCanBeTranslated } from 'client/components/HOC'
import { Translate, labelCanBeTranslated } from 'client/components/HOC'
const StyledLegend = styled((props) => (
<Typography variant="subtitle1" component="legend" {...props} />
@ -35,7 +35,7 @@ const StyledLegend = styled((props) => (
const Legend = memo(
({ title, tooltip }) => (
<StyledLegend tooltip={tooltip}>
{labelCanBeTranslated(title) ? Tr(title) : title}
{labelCanBeTranslated(title) ? <Translate word={title} /> : title}
{!!tooltip && <AdornmentWithTooltip title={tooltip} />}
</StyledLegend>
),

View File

@ -86,6 +86,7 @@ module.exports = {
Pin: 'Pin',
Poweroff: 'Poweroff',
PoweroffHard: 'Poweroff hard',
PressEscapeToCancel: 'Press Escape to cancel',
Reboot: 'Reboot',
RebootHard: 'Reboot hard',
Recover: 'Recover',
@ -269,6 +270,9 @@ module.exports = {
Language: 'Language',
DisableDashboardAnimations: 'Disable dashboard animations',
ConfigurationUI: 'Configuration UI',
Authentication: 'Authentication',
SshPrivateKey: 'SSH private key',
SshPassphraseKey: 'SSH private key passphrase',
/* sections - system */
User: 'User',

View File

@ -0,0 +1,157 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2022, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { ReactElement, useMemo, memo, useState } from 'react'
import PropTypes from 'prop-types'
import {
Paper,
Stack,
IconButton,
Typography,
Skeleton,
TextField,
} from '@mui/material'
import { Edit } from 'iconoir-react'
import { useForm } from 'react-hook-form'
import { yupResolver } from '@hookform/resolvers/yup'
import { useAuth } from 'client/features/Auth'
import { useUpdateUserMutation } from 'client/features/OneApi/user'
import { useGeneralApi } from 'client/features/General'
import {
FIELDS,
SCHEMA,
} from 'client/containers/Settings/Authentication/schema'
import { Translate } from 'client/components/HOC'
import { Legend } from 'client/components/Forms'
import { jsonToXml } from 'client/models/Helper'
import { sanitize } from 'client/utils'
import { T } from 'client/constants'
/** @returns {ReactElement} Settings authentication */
const Settings = () => (
<Paper variant="outlined" sx={{ py: '1.5em' }}>
<Stack gap="1em">
{FIELDS.map((field) => (
<FieldComponent
key={'settings-authentication-field-' + field.name}
field={field}
/>
))}
</Stack>
</Paper>
)
const FieldComponent = memo(({ field }) => {
const [isEnabled, setIsEnabled] = useState(false)
const { name, label, tooltip } = field
const { user, settings } = useAuth()
const [updateUser, { isLoading }] = useUpdateUserMutation()
const { enqueueError } = useGeneralApi()
const defaultValues = useMemo(() => SCHEMA.cast(settings), [settings])
const { watch, register, reset } = useForm({
reValidateMode: 'onSubmit',
defaultValues,
resolver: yupResolver(SCHEMA),
})
const sanitizedValue = useMemo(() => sanitize`${watch(name)}`, [isEnabled])
const handleUpdateUser = async () => {
try {
if (isLoading || !isEnabled) return
const castedData = SCHEMA.cast(watch(), { isSubmit: true })
const template = jsonToXml(castedData)
updateUser({ id: user.ID, template })
setIsEnabled(false)
} catch {
enqueueError(T.SomethingWrong)
}
}
const handleBlur = () => {
handleUpdateUser()
}
const handleKeyDown = (evt) => {
if (evt.key === 'Escape') {
reset(defaultValues)
setIsEnabled(false)
evt.stopPropagation()
}
if (evt.key === 'Enter') {
handleUpdateUser()
}
}
return (
<Stack component="fieldset" sx={{ minInlineSize: 'auto' }}>
<Legend title={label} tooltip={tooltip} />
{isEnabled ? (
<>
<TextField
fullWidth
autoFocus
multiline
rows={5}
variant="outlined"
onKeyDown={handleKeyDown}
helperText={<Translate word={T.PressEscapeToCancel} />}
{...register(field.name, { onBlur: handleBlur })}
/>
</>
) : (
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
gap="1em"
paddingX={1}
>
{isLoading ? (
<>
<Skeleton variant="text" width="100%" height={36} />
<Skeleton variant="circular" width={28} height={28} />
</>
) : (
<>
<Typography noWrap title={sanitizedValue}>
{sanitizedValue}
</Typography>
<IconButton onClick={() => setIsEnabled(true)}>
<Edit />
</IconButton>
</>
)}
</Stack>
)}
</Stack>
)
})
FieldComponent.propTypes = {
field: PropTypes.object.isRequired,
}
FieldComponent.displayName = 'FieldComponent'
export default Settings

View File

@ -0,0 +1,53 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2022, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { object, string } from 'yup'
import { getValidationFromFields } from 'client/utils'
import { T, INPUT_TYPES } from 'client/constants'
const PUBLIC_KEY_FIELD = {
name: 'SSH_PUBLIC_KEY',
label: T.SshPublicKey,
tooltip: T.AddUserSshPublicKey,
type: INPUT_TYPES.TEXT,
validation: string()
.trim()
.required()
.default(() => undefined),
}
const PRIVATE_KEY_FIELD = {
name: 'SSH_PRIVATE_KEY',
label: T.SshPrivateKey,
type: INPUT_TYPES.TEXT,
validation: string()
.trim()
.required()
.default(() => undefined),
}
const PASSPHRASE_FIELD = {
name: 'SSH_PASSPHRASE',
label: T.SshPassphraseKey,
type: INPUT_TYPES.TEXT,
validation: string()
.trim()
.required()
.default(() => undefined),
}
export const FIELDS = [PUBLIC_KEY_FIELD, PRIVATE_KEY_FIELD, PASSPHRASE_FIELD]
export const SCHEMA = object(getValidationFromFields(FIELDS))

View File

@ -0,0 +1,88 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2022, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { ReactElement, useEffect, useMemo, useRef } from 'react'
import { Paper, Stack, CircularProgress } from '@mui/material'
import { useForm, FormProvider } from 'react-hook-form'
import { yupResolver } from '@hookform/resolvers/yup'
import { useAuth } from 'client/features/Auth'
import { useUpdateUserMutation } from 'client/features/OneApi/user'
import { useGeneralApi } from 'client/features/General'
import {
FIELDS,
SCHEMA,
} from 'client/containers/Settings/ConfigurationUI/schema'
import { FormWithSchema } from 'client/components/Forms'
import { Translate } from 'client/components/HOC'
import { jsonToXml } from 'client/models/Helper'
import { T } from 'client/constants'
/** @returns {ReactElement} Settings configuration UI */
const Settings = () => {
const fieldsetRef = useRef([])
const { user, settings } = useAuth()
const [updateUser, { isLoading }] = useUpdateUserMutation()
const { enqueueError } = useGeneralApi()
const { watch, ...methods } = useForm({
reValidateMode: 'onSubmit',
defaultValues: useMemo(() => SCHEMA.cast(settings), [settings]),
resolver: yupResolver(SCHEMA),
})
useEffect(() => {
watch((formData) => {
try {
if (isLoading) return
const castedData = SCHEMA.cast(formData, { isSubmit: true })
const template = jsonToXml(castedData)
updateUser({ id: user.ID, template })
} catch {
enqueueError(T.SomethingWrong)
}
})
}, [watch])
useEffect(() => {
fieldsetRef.current.disabled = isLoading
}, [isLoading])
return (
<Paper
variant="outlined"
sx={{ p: '1em', maxWidth: { sm: 'auto', md: 550 } }}
>
<FormProvider {...methods}>
<FormWithSchema
cy={'settings-ui'}
fields={FIELDS}
rootProps={{ ref: fieldsetRef }}
legend={
<Stack direction="row" alignItems="center" gap="1em">
<Translate word={T.ConfigurationUI} />
{isLoading && <CircularProgress size={20} color="secondary" />}
</Stack>
}
/>
</FormProvider>
</Paper>
)
}
export default Settings

View File

@ -13,8 +13,8 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { object, boolean, string } from 'yup'
import { arrayToOptions, getValidationFromFields } from 'client/utils'
import { boolean, string } from 'yup'
import { arrayToOptions, getObjectSchemaFromFields } from 'client/utils'
import {
T,
INPUT_TYPES,
@ -25,7 +25,7 @@ import {
} from 'client/constants'
const SCHEME_FIELD = {
name: 'SCHEME',
name: 'FIREEDGE.SCHEME',
label: T.Schema,
type: INPUT_TYPES.SELECT,
values: [
@ -41,7 +41,7 @@ const SCHEME_FIELD = {
}
const LANG_FIELD = {
name: 'LANG',
name: 'FIREEDGE.LANG',
label: T.Language,
type: INPUT_TYPES.SELECT,
values: () =>
@ -58,7 +58,7 @@ const LANG_FIELD = {
}
const DISABLE_ANIMATIONS_FIELD = {
name: 'DISABLE_ANIMATIONS',
name: 'FIREEDGE.DISABLE_ANIMATIONS',
label: T.DisableDashboardAnimations,
type: INPUT_TYPES.CHECKBOX,
validation: boolean()
@ -67,6 +67,6 @@ const DISABLE_ANIMATIONS_FIELD = {
grid: { md: 12 },
}
export const FORM_FIELDS = [SCHEME_FIELD, LANG_FIELD, DISABLE_ANIMATIONS_FIELD]
export const FIELDS = [SCHEME_FIELD, LANG_FIELD, DISABLE_ANIMATIONS_FIELD]
export const FORM_SCHEMA = object(getValidationFromFields(FORM_FIELDS))
export const SCHEMA = getObjectSchemaFromFields(FIELDS)

View File

@ -13,88 +13,29 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { ReactElement, useMemo } from 'react'
import { Container, Paper, Box, Typography, Divider } from '@mui/material'
import { useForm, FormProvider } from 'react-hook-form'
import { yupResolver } from '@hookform/resolvers/yup'
import { ReactElement } from 'react'
import { Container, Typography, Divider, Stack } from '@mui/material'
import FormWithSchema from 'client/components/Forms/FormWithSchema'
import SubmitButton from 'client/components/FormControl/SubmitButton'
import { useAuth } from 'client/features/Auth'
import { useLazyGetAuthUserQuery } from 'client/features/AuthApi'
import { useUpdateUserMutation } from 'client/features/OneApi/user'
import { useGeneralApi } from 'client/features/General'
import { Translate, Tr } from 'client/components/HOC'
import { Translate } from 'client/components/HOC'
import { T } from 'client/constants'
import { FORM_FIELDS, FORM_SCHEMA } from 'client/containers/Settings/schema'
import * as Helper from 'client/models/Helper'
import ConfigurationUISection from 'client/containers/Settings/ConfigurationUI'
import AuthenticationSection from 'client/containers/Settings/Authentication'
/** @returns {ReactElement} Settings container */
const Settings = () => {
const { user, settings } = useAuth()
const [getAuthUser] = useLazyGetAuthUserQuery()
const [updateUser] = useUpdateUserMutation()
const { enqueueError } = useGeneralApi()
const Settings = () => (
<Container disableGutters>
<Typography variant="h5">
<Translate word={T.Settings} />
</Typography>
const { handleSubmit, reset, formState, ...methods } = useForm({
reValidateMode: 'onSubmit',
defaultValues: useMemo(() => FORM_SCHEMA.cast(settings), [settings]),
resolver: yupResolver(FORM_SCHEMA),
})
<Divider sx={{ my: '1em' }} />
const onSubmit = async (formData) => {
try {
const data = FORM_SCHEMA.cast(formData, { isSubmit: true })
const template = Helper.jsonToXml({ FIREEDGE: data })
await updateUser({ id: user.ID, template })
await getAuthUser()
// Reset either the entire form state or part of the form state
reset(formData)
} catch {
enqueueError(T.SomethingWrong)
}
}
return (
<Container disableGutters>
<Typography variant="h5" pt="1em">
<Translate word={T.Settings} />
</Typography>
<Divider sx={{ my: '1em' }} />
<Paper
variant="outlined"
sx={{
p: '1em',
maxWidth: { sm: 'auto', md: 550 },
}}
>
<Box component="form" onSubmit={handleSubmit(onSubmit)}>
<FormProvider {...methods}>
<FormWithSchema
cy="settings"
fields={FORM_FIELDS}
legend={T.ConfigurationUI}
/>
</FormProvider>
<Box py="1em" textAlign="end">
<SubmitButton
color="secondary"
data-cy="settings-submit-button"
label={Tr(T.Save)}
onClick={handleSubmit}
disabled={!formState.isDirty}
isSubmitting={formState.isSubmitting}
/>
</Box>
</Box>
</Paper>
</Container>
)
}
<Stack gap="1em">
<ConfigurationUISection />
<AuthenticationSection />
</Stack>
</Container>
)
export default Settings

View File

@ -72,6 +72,7 @@ export const useAuth = () => {
SCHEME: DEFAULT_SCHEME,
LANG: DEFAULT_LANGUAGE,
DISABLE_ANIMATIONS: 'NO',
...(user?.TEMPLATE ?? {}),
...(user?.TEMPLATE?.FIREEDGE ?? {}),
},
isLogged:

View File

@ -19,6 +19,7 @@ import {
ONE_RESOURCES,
ONE_RESOURCES_POOL,
} from 'client/features/OneApi'
import { authApi } from 'client/features/AuthApi'
import { User } from 'client/constants'
const { USER } = ONE_RESOURCES
@ -109,6 +110,19 @@ const userApi = oneApi.injectEndpoints({
return { params, command }
},
invalidatesTags: (_, __, { id }) => [{ type: USER, id }],
async onQueryStarted({ id }, { queryFulfilled, dispatch, getState }) {
try {
await queryFulfilled
if (+id === +getState().auth.user.ID) {
dispatch(
authApi.endpoints.getAuthUser.initiate(undefined, {
forceRefetch: true,
})
)
}
} catch {}
},
}),
removeUser: builder.mutation({
/**