diff --git a/pkg/webui/components/toast/index.js b/pkg/webui/components/toast/index.js index b427d58030..8b0f4e4d08 100644 --- a/pkg/webui/components/toast/index.js +++ b/pkg/webui/components/toast/index.js @@ -22,41 +22,39 @@ import createToast from './toast' import './react-toastify.styl' import style from './toast.styl' -class ToastContainer extends React.Component { - static propTypes = { - autoClose: PropTypes.oneOfType([PropTypes.bool, PropTypes.number]), - closeButton: PropTypes.oneOfType([PropTypes.bool, PropTypes.element]), - closeOnClick: PropTypes.bool, - hideProgressBar: PropTypes.bool, - limit: PropTypes.number, - pauseOnFocusLoss: PropTypes.bool, - pauseOnHover: PropTypes.bool, - position: PropTypes.oneOf([ - 'bottom-right', - 'bottom-left', - 'top-right', - 'top-left', - 'top-center', - 'bottom-center', - ]), - transition: PropTypes.func, - } - - static defaultProps = { - autoClose: undefined, - position: 'bottom-right', - closeButton: false, - hideProgressBar: true, - pauseOnHover: true, - closeOnClick: true, - pauseOnFocusLoss: true, - limit: 2, - transition: cssTransition({ enter: style.slideInRight, exit: style.slideOutRight }), - } +const ToastContainer = props => ( + +) + +ToastContainer.propTypes = { + autoClose: PropTypes.oneOfType([PropTypes.bool, PropTypes.number]), + closeButton: PropTypes.oneOfType([PropTypes.bool, PropTypes.element]), + closeOnClick: PropTypes.bool, + hideProgressBar: PropTypes.bool, + limit: PropTypes.number, + pauseOnFocusLoss: PropTypes.bool, + pauseOnHover: PropTypes.bool, + position: PropTypes.oneOf([ + 'bottom-right', + 'bottom-left', + 'top-right', + 'top-left', + 'top-center', + 'bottom-center', + ]), + transition: PropTypes.func, +} - render() { - return - } +ToastContainer.defaultProps = { + autoClose: undefined, + position: 'bottom-right', + closeButton: false, + hideProgressBar: true, + pauseOnHover: true, + closeOnClick: true, + pauseOnFocusLoss: true, + limit: 2, + transition: cssTransition({ enter: style.slideInRight, exit: style.slideOutRight }), } const toast = createToast() diff --git a/pkg/webui/console/components/device-map/index.js b/pkg/webui/console/components/device-map/index.js index 52cefcf56e..6118d7fb7e 100644 --- a/pkg/webui/console/components/device-map/index.js +++ b/pkg/webui/console/components/device-map/index.js @@ -20,24 +20,23 @@ import PropTypes from '@ttn-lw/lib/prop-types' import locationToMarkers from '@console/lib/location-to-markers' -export default class DeviceMap extends React.Component { - static propTypes = { - device: PropTypes.device.isRequired, - } - - render() { - const { device } = this.props - const { device_id } = device.ids - const { application_id } = device.ids.application_ids - - const markers = locationToMarkers(device.locations) - - return ( - - ) - } +const DeviceMap = ({ device }) => { + const { device_id } = device.ids + const { application_id } = device.ids.application_ids + + const markers = locationToMarkers(device.locations) + + return ( + + ) } + +DeviceMap.propTypes = { + device: PropTypes.device.isRequired, +} + +export default DeviceMap diff --git a/pkg/webui/console/components/gateway-map/index.js b/pkg/webui/console/components/gateway-map/index.js index 2418fd4e1b..fefa8aeac0 100644 --- a/pkg/webui/console/components/gateway-map/index.js +++ b/pkg/webui/console/components/gateway-map/index.js @@ -18,31 +18,30 @@ import MapWidget from '@ttn-lw/components/map/widget' import PropTypes from '@ttn-lw/lib/prop-types' -export default class GatewayMap extends React.Component { - static propTypes = { - gateway: PropTypes.gateway.isRequired, - } - - render() { - const { gateway } = this.props - const { gateway_id } = gateway.ids - - const markers = - gateway.antennas && gateway.antennas.length > 0 && gateway.antennas[0].location - ? gateway.antennas.map(location => ({ - position: { - latitude: location.location.latitude || 0, - longitude: location.location.longitude || 0, - }, - })) - : [] - - return ( - - ) - } +const GatewayMap = ({ gateway }) => { + const { gateway_id } = gateway.ids + + const markers = + gateway.antennas && gateway.antennas.length > 0 && gateway.antennas[0].location + ? gateway.antennas.map(location => ({ + position: { + latitude: location.location.latitude || 0, + longitude: location.location.longitude || 0, + }, + })) + : [] + + return ( + + ) } + +GatewayMap.propTypes = { + gateway: PropTypes.gateway.isRequired, +} + +export default GatewayMap diff --git a/pkg/webui/console/components/user-data-form/index.js b/pkg/webui/console/components/user-data-form/index.js deleted file mode 100644 index b6033a787d..0000000000 --- a/pkg/webui/console/components/user-data-form/index.js +++ /dev/null @@ -1,295 +0,0 @@ -// Copyright © 2019 The Things Network Foundation, The Things Industries B.V. -// -// 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 React from 'react' -import bind from 'autobind-decorator' -import { defineMessages, injectIntl } from 'react-intl' - -import Form from '@ttn-lw/components/form' -import Input from '@ttn-lw/components/input' -import Select from '@ttn-lw/components/select' -import Checkbox from '@ttn-lw/components/checkbox' -import SubmitBar from '@ttn-lw/components/submit-bar' -import SubmitButton from '@ttn-lw/components/submit-button' -import DeleteModalButton from '@ttn-lw/components/delete-modal-button' - -import Yup from '@ttn-lw/lib/yup' -import PropTypes from '@ttn-lw/lib/prop-types' -import sharedMessages from '@ttn-lw/lib/shared-messages' -import createPasswordValidationSchema from '@ttn-lw/lib/create-password-validation-schema' -import { userId as userIdRegexp } from '@ttn-lw/lib/regexp' -import capitalizeMessage from '@ttn-lw/lib/capitalize-message' - -const approvalStates = [ - 'STATE_REQUESTED', - 'STATE_APPROVED', - 'STATE_REJECTED', - 'STATE_FLAGGED', - 'STATE_SUSPENDED', -] - -const m = defineMessages({ - adminLabel: 'Grant this user admin status', - adminDescription: - 'Admin status enables overarching rights such as managing other users or modifying entities regardless of collaboration status', - userDescPlaceholder: 'Description for my new user', - userDescDescription: 'Optional user description; can also be used to save notes about the user', - userIdPlaceholder: 'jane-doe', - userNamePlaceholder: 'Jane Doe', - emailPlaceholder: 'mail@example.com', - emailAddressDescription: - 'Primary email address used for logging in; this address is not publicly visible', - emailAddressValidation: 'Treat email address as validated', - emailAddressValidationDescription: - 'Enable this option if you do not need this user to validate the email address', - deleteTitle: 'Are you sure you want to delete this account?', - deleteWarning: - "This will PERMANENTLY DELETE THIS ACCOUNT and LOCK THE USER ID AND EMAIL FOR RE-REGISTRATION. Associated entities (e.g. gateways, applications and end devices) owned by this user that do not have any other collaborators will become UNACCESSIBLE and it will NOT BE POSSIBLE TO REGISTER ENTITIES WITH THE SAME ID OR EUI's AGAIN. Make sure you assign new collaborators to such entities if you plan to continue using them.", - purgeWarning: - "This will PERMANENTLY DELETE THIS ACCOUNT. Associated entities (e.g. gateways, applications and end devices) owned by this user that do not have any other collaborators will become UNACCESSIBLE and it will NOT BE POSSIBLE TO REGISTER ENTITIES WITH THE SAME ID OR EUI's AGAIN. Make sure you assign new collaborators to such entities if you plan to continue using them.", - deleteConfirmMessage: "Please type in this user's user ID to confirm.", -}) - -const baseValidationSchema = Yup.object().shape({ - ids: Yup.object().shape({ - user_id: Yup.string() - .min(2, Yup.passValues(sharedMessages.validateTooShort)) - .max(36, Yup.passValues(sharedMessages.validateTooLong)) - .matches(userIdRegexp, Yup.passValues(sharedMessages.validateIdFormat)) - .required(sharedMessages.validateRequired), - }), - name: Yup.string() - .min(2, Yup.passValues(sharedMessages.validateTooShort)) - .max(50, Yup.passValues(sharedMessages.validateTooLong)), - primary_email_address: Yup.string() - .email(sharedMessages.validateEmail) - .required(sharedMessages.validateRequired), - state: Yup.string() - .oneOf(approvalStates, sharedMessages.validateRequired) - .required(sharedMessages.validateRequired), - description: Yup.string().max(2000, Yup.passValues(sharedMessages.validateTooLong)), -}) - -@injectIntl -class UserForm extends React.Component { - constructor(props) { - super(props) - - const { update, passwordRequirements } = props - this.validationSchema = update - ? baseValidationSchema - : baseValidationSchema.concat(createPasswordValidationSchema(passwordRequirements)) - this.state = { - error: '', - } - } - - static propTypes = { - error: PropTypes.error, - initialValues: PropTypes.shape({ - ids: PropTypes.shape({ - user_id: PropTypes.string.isRequired, - }).isRequired, - name: PropTypes.string, - description: PropTypes.string, - }), - intl: PropTypes.shape({ - formatMessage: PropTypes.func.isRequired, - }).isRequired, - onDelete: PropTypes.func, - onDeleteFailure: PropTypes.func, - onDeleteSuccess: PropTypes.func, - onSubmit: PropTypes.func.isRequired, - onSubmitFailure: PropTypes.func, - onSubmitSuccess: PropTypes.func, - passwordRequirements: PropTypes.passwordRequirements, - update: PropTypes.bool, - } - - static defaultProps = { - update: false, - error: '', - initialValues: { - ids: { user_id: '' }, - name: '', - primary_email_address: '', - state: '', - description: '', - password: '', - confirmPassword: '', - }, - onSubmitFailure: () => null, - onSubmitSuccess: () => null, - onDelete: () => null, - onDeleteFailure: () => null, - onDeleteSuccess: () => null, - passwordRequirements: {}, - } - - @bind - async handleSubmit(vals, { resetForm, setSubmitting }) { - const { onSubmit, onSubmitSuccess, onSubmitFailure } = this.props - const { _validate_email, ...values } = this.validationSchema.cast(vals) - - if (_validate_email) { - values.primary_email_address_validated_at = new Date().toISOString() - } - - await this.setState({ error: '' }) - try { - const result = await onSubmit(values) - resetForm({ values: vals }) - onSubmitSuccess(result) - } catch (error) { - setSubmitting(false) - this.setState({ error }) - onSubmitFailure(error) - } - } - - @bind - async handleDelete(shouldPurge) { - const { onDelete, onDeleteSuccess, onDeleteFailure } = this.props - try { - await onDelete(shouldPurge) - onDeleteSuccess() - } catch (error) { - await this.setState({ error }) - onDeleteFailure() - } - } - - render() { - const { - update, - error: passedError, - initialValues: values, - intl: { formatMessage }, - } = this.props - - const approvalStateOptions = approvalStates.map(state => ({ - value: state, - label: capitalizeMessage(formatMessage({ id: `enum:${state}` })), - })) - - const initialValues = { - admin: false, - ...values, - } - - const { error: submitError } = this.state - - const error = passedError || submitError - - return ( -
- - - - - - - - {!update && ( - - )} - {!update && ( - - )} - - - {update && ( - - )} - - - ) - } -} -export default UserForm diff --git a/pkg/webui/console/containers/device-importer/form.js b/pkg/webui/console/containers/device-importer/form.js new file mode 100644 index 0000000000..b65c9e942c --- /dev/null +++ b/pkg/webui/console/containers/device-importer/form.js @@ -0,0 +1,63 @@ +// Copyright © 2023 The Things Network Foundation, The Things Industries B.V. +// +// 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 React from 'react' +import { Col, Row } from 'react-grid-system' + +import DeviceImportForm from '@console/components/device-import-form' + +import PropTypes from '@ttn-lw/lib/prop-types' + +import m from './messages' + +const Form = ({ handleSubmit, jsEnabled }) => { + const initialValues = { + format_id: '', + data: '', + set_claim_auth_code: false, + _inputMethod: 'no-fallback', + frequency_plan_id: '', + lorawan_version: '', + lorawan_phy_version: '', + version_ids: { + brand_id: '', + model_id: '', + firmware_version: '', + hardware_version: '', + band_id: '', + }, + } + const largeFile = 10 * 1024 * 1024 + + return ( + + + + + + ) +} + +Form.propTypes = { + handleSubmit: PropTypes.func.isRequired, + jsEnabled: PropTypes.bool.isRequired, +} + +export default Form diff --git a/pkg/webui/console/containers/device-importer/index.js b/pkg/webui/console/containers/device-importer/index.js index c93cf50ab3..85a94377c0 100644 --- a/pkg/webui/console/containers/device-importer/index.js +++ b/pkg/webui/console/containers/device-importer/index.js @@ -12,573 +12,315 @@ // See the License for the specific language governing permissions and // limitations under the License. -import React, { Component } from 'react' -import { connect } from 'react-redux' -import bind from 'autobind-decorator' -import { defineMessages } from 'react-intl' -import { Col, Row } from 'react-grid-system' +import React, { useCallback, useEffect, useRef, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' import { isObject } from 'lodash' +import { useParams } from 'react-router-dom' import tts from '@console/api/tts' -import CodeEditor from '@ttn-lw/components/code-editor' -import ProgressBar from '@ttn-lw/components/progress-bar' -import SubmitBar from '@ttn-lw/components/submit-bar' -import Button from '@ttn-lw/components/button' -import ErrorNotification from '@ttn-lw/components/error-notification' -import Notification from '@ttn-lw/components/notification' -import Status from '@ttn-lw/components/status' -import Link from '@ttn-lw/components/link' -import ButtonGroup from '@ttn-lw/components/button/group' - -import ErrorMessage from '@ttn-lw/lib/components/error-message' -import Message from '@ttn-lw/lib/components/message' - -import DeviceImportForm from '@console/components/device-import-form' - -import PropTypes from '@ttn-lw/lib/prop-types' import { createFrontendError, isFrontend } from '@ttn-lw/lib/errors/utils' -import { selectNsConfig, selectJsConfig, selectAsConfig } from '@ttn-lw/lib/selectors/env' import { getDeviceId } from '@ttn-lw/lib/selectors/id' +import { selectAsConfig, selectJsConfig, selectNsConfig } from '@ttn-lw/lib/selectors/env' import attachPromise from '@ttn-lw/lib/store/actions/attach-promise' import randomByteString from '@console/lib/random-bytes' import { convertTemplate } from '@console/store/actions/device-template-formats' -import { selectSelectedApplicationId } from '@console/store/selectors/applications' import { selectDeviceTemplate } from '@console/store/selectors/device-repository' -import style from './device-importer.styl' - -const m = defineMessages({ - proceed: 'Proceed to end device list', - retry: 'Retry from scratch', - abort: 'Abort', - converting: 'Converting templates…', - creating: 'Creating end devices…', - operationInProgress: 'Operation in progress', - operationHalted: 'Operation halted', - operationFinished: 'Operation finished', - operationAborted: 'Operation aborted', - errorTitle: 'There was an error and the operation could not be completed', - conversionErrorTitle: 'Could not import devices', - conversionErrorMessage: - 'An error occurred while processing the provided end device template. This could be due to invalid format, syntax or file encoding. Please check the provided template file and try again. See also our documentation on Importing End Devices for more information.', - incompleteWarningTitle: 'Not all devices imported successfully', - incompleteWarningMessage: - '{count, number} {count, plural, one {end device} other {end devices}} could not be imported successfully, because {count, plural, one {its} other {their}} registration attempt resulted in an error', - incompleteStatus: - 'The registration of the following {count, plural, one {end device} other {end devices}} failed:', - noneWarningTitle: 'No end device was created', - noneWarningMessage: - 'None of your specified end devices was imported, because each registration attempt resulted in an error', - processLog: 'Process log', - progress: - 'Successfully converted {errorCount, number} of {deviceCount, number} {deviceCount, plural, one {end device} other {end devices}}', - successInfoTitle: 'All end devices imported successfully', - successInfoMessage: - 'All of the specified end devices have been converted and imported successfully', - documentationHint: - 'Please also see our documentation on Importing End Devices for more information and possible resolutions.', - abortWarningTitle: 'Device import aborted', - abortWarningMessage: - 'The end device import was aborted and the remaining {count, number} {count, plural, one {end device} other {end devices}} have not been imported', - largeFileWarningMessage: - 'Providing files larger than {warningThreshold} can cause issues during the import process. We recommend you to split such files up into multiple smaller files and importing them one by one.', -}) - -const initialState = { - log: '', - currentDeviceIndex: 0, - convertedDevices: [], - deviceErrors: [], - status: 'initial', - step: 'inital', - error: undefined, - aborted: false, -} - -const statusMap = { - processing: 'good', - error: 'bad', - finished: 'good', -} +import Form from './form' +import Processor from './processor' +import m from './messages' const conversionError = createFrontendError(m.conversionErrorTitle, m.conversionErrorMessage) -const docLinkValue = msg => ( - - {msg} - -) - -@connect( - state => { - const asConfig = selectAsConfig() - const nsConfig = selectNsConfig() - const jsConfig = selectJsConfig() - const deviceRepoTemplate = selectDeviceTemplate(state) - const availableComponents = ['is'] - if (nsConfig.enabled) availableComponents.push('ns') - if (jsConfig.enabled) availableComponents.push('js') - if (asConfig.enabled) availableComponents.push('as') - - return { - appId: selectSelectedApplicationId(state), - nsConfig, - jsConfig, - asConfig, - availableComponents, - deviceRepoTemplate, - } - }, - dispatch => ({ - convertTemplate: (format_id, data) => dispatch(attachPromise(convertTemplate(format_id, data))), - }), -) -export default class DeviceImporter extends Component { - static propTypes = { - appId: PropTypes.string.isRequired, - asConfig: PropTypes.stackComponent.isRequired, - availableComponents: PropTypes.components.isRequired, - convertTemplate: PropTypes.func.isRequired, - deviceRepoTemplate: PropTypes.deviceTemplate, - jsConfig: PropTypes.stackComponent.isRequired, - nsConfig: PropTypes.stackComponent.isRequired, - } - - static defaultProps = { - deviceRepoTemplate: undefined, - } - - constructor(props) { - super(props) - - this.state = { ...initialState } - this.editorRef = React.createRef() - this.createStream = null - } - - componentDidUpdate(prevProps, prevState) { - const { status } = this.state - if (prevState.status === 'initial' && status !== 'initial') { - // Disable undo manager of the code editor to release old logs from the heap. - // Without this fix the browser can run out of memory when importing many end devices. - this.editorRef.current.editor.session.setUndoManager(null) +const DeviceImporter = () => { + const { appId } = useParams() + const deviceRepoTemplate = useSelector(selectDeviceTemplate) + const asConfig = useSelector(selectAsConfig) + const nsConfig = useSelector(selectNsConfig) + const jsConfig = useSelector(selectJsConfig) + const availableComponents = ['is'] + if (nsConfig.enabled) availableComponents.push('ns') + if (jsConfig.enabled) availableComponents.push('js') + if (asConfig.enabled) availableComponents.push('as') + const editorRef = useRef() + const [log, setLog] = useState('') + const [currentDeviceIndex, setCurrentDeviceIndex] = useState(0) + const [convertedDevices, setConvertedDevices] = useState([]) + const [deviceErrors, setDeviceErrors] = useState([]) + const [status, setStatus] = useState('initial') + const prevStatus = useRef() + const [step, setStep] = useState('initial') + const [error, setError] = useState(undefined) + const [aborted, setAborted] = useState(false) + const createStream = useRef(null) + const dispatch = useDispatch() + + useEffect(() => { + // Disable undo manager of the code editor to release old logs from the heap. + // Without this fix the browser can run out of memory when importing many end devices. + prevStatus.current = status + if (prevStatus !== 'initial' && status !== 'initial') { + editorRef.current.editor.session.setUndoManager(null) } - } - - @bind - appendToLog(message) { - const text = typeof message !== 'string' ? JSON.stringify(message, null, 2) : message - this.setState(({ log }) => ({ log: `${log}\n${text}` })) - } - - @bind - handleCreationSuccess(device) { - this.appendToLog(device) - this.setState(({ currentDeviceIndex }) => ({ - currentDeviceIndex: currentDeviceIndex + 1, - })) - } - - @bind - handleCreationError(error) { - this.logError(error) - const { convertedDevices, currentDeviceIndex } = this.state - const currentDevice = - convertedDevices.length > currentDeviceIndex ? convertedDevices[currentDeviceIndex] : {} - const currentDeviceId = - 'end_device' in currentDevice - ? getDeviceId(currentDevice.end_device) - : `unknown device ID ${Date.now()}` - this.setState(({ currentDeviceIndex, deviceErrors }) => ({ - currentDeviceIndex: currentDeviceIndex + 1, - deviceErrors: [...deviceErrors, { deviceId: currentDeviceId, error }], - })) - } - - @bind - logError(error) { - if (isObject(error)) { - if (!isFrontend(error)) { - const json = JSON.stringify(error, null, 2) - this.setState(({ log }) => ({ log: `${log}\n${json}` })) + }, [status]) + + const appendToLog = useCallback( + message => { + const text = typeof message !== 'string' ? JSON.stringify(message, null, 2) : message + setLog(log => `${log}\n${text}`) + }, + [setLog], + ) + + const handleCreationSuccess = useCallback( + device => { + appendToLog(device) + setCurrentDeviceIndex(currentDeviceIndex => currentDeviceIndex + 1) + }, + [appendToLog], + ) + + const logError = useCallback( + error => { + if (isObject(error)) { + if (!isFrontend(error)) { + const json = JSON.stringify(error, null, 2) + setLog(log => `${log}\n${json}`) + } } + }, + [setLog], + ) + + const handleCreationError = useCallback( + error => { + logError(error) + const currentDevice = + convertedDevices.length > currentDeviceIndex ? convertedDevices[currentDeviceIndex] : {} + const currentDeviceId = + 'end_device' in currentDevice + ? getDeviceId(currentDevice.end_device) + : `unknown device ID ${Date.now()}` + setCurrentDeviceIndex(currentDeviceIndex => currentDeviceIndex + 1) + setDeviceErrors(errors => [...errors, { deviceId: currentDeviceId, error }]) + }, + [convertedDevices, currentDeviceIndex, logError], + ) + + const handleFatalError = useCallback( + error => { + logError(error) + + const logAppend = '\n\nImport process cancelled due to error.' + setStatus('error') + setError(error) + setLog(log => `${log}\n${logAppend}`) + }, + [logError], + ) + + const handleAbort = useCallback(() => { + if (createStream.current !== null) { + createStream.current.abort() + setAborted(true) } - } - - @bind - handleFatalError(error) { - this.logError(error) - - const logAppend = '\n\nImport process cancelled due to error.' - this.setState(({ log }) => ({ error, status: 'error', log: `${log}\n${logAppend}` })) - } - - @bind - async handleSubmit(values) { - const { appId, jsConfig, nsConfig, asConfig, convertTemplate, deviceRepoTemplate } = this.props - const { - format_id, - data, - set_claim_auth_code, - frequency_plan_id, - lorawan_version, - lorawan_phy_version, - version_ids, - _inputMethod, - } = values - - let devices = [] - - try { - // Start template conversion. - this.setState({ step: 'conversion', status: 'processing' }) - this.appendToLog('Converting end device templates…') - const templateStream = await convertTemplate(format_id, data) - - devices = await new Promise((resolve, reject) => { - const chunks = [] - - templateStream.on('chunk', message => { - this.appendToLog(message) - chunks.push(message) + }, [createStream]) + + const handleSubmit = useCallback( + async values => { + const { + format_id, + data, + set_claim_auth_code, + frequency_plan_id, + lorawan_version, + lorawan_phy_version, + version_ids, + _inputMethod, + } = values + + let devices = [] + + try { + // Start template conversion. + setStep('conversion') + setStatus('processing') + appendToLog('Converting end device templates…') + const templateStream = await dispatch(attachPromise(convertTemplate(format_id, data))) + + devices = await new Promise((resolve, reject) => { + const chunks = [] + + templateStream.on('chunk', message => { + appendToLog(message) + chunks.push(message) + }) + templateStream.on('error', reject) + templateStream.on('close', () => resolve(chunks)) + + templateStream.open() }) - templateStream.on('error', reject) - templateStream.on('close', () => resolve(chunks)) - templateStream.open() - }) - - if (devices.length === 0) { - throw conversionError - } - - this.setState({ convertedDevices: devices }) - // Apply default values. - for (const deviceAndFieldMask of devices) { - const { end_device: device, field_mask } = deviceAndFieldMask - - if (set_claim_auth_code && jsConfig.enabled) { - device.claim_authentication_code = { value: randomByteString(4 * 2) } - field_mask.paths.push('claim_authentication_code') - } - if (device.supports_join && !device.join_server_address && jsConfig.enabled) { - device.join_server_address = new URL(jsConfig.base_url).hostname - field_mask.paths.push('join_server_address') - } - if (!device.application_server_address && asConfig.enabled) { - device.application_server_address = new URL(asConfig.base_url).hostname - field_mask.paths.push('application_server_address') - } - if (!device.network_server_address && nsConfig.enabled) { - device.network_server_address = new URL(nsConfig.base_url).hostname - field_mask.paths.push('network_server_address') + if (devices.length === 0) { + throw conversionError } - // Fallback values - if ( - !device.frequency_plan_id && - Boolean(frequency_plan_id) && - nsConfig.enabled && - _inputMethod === 'manual' - ) { - device.frequency_plan_id = frequency_plan_id - field_mask.paths.push('frequency_plan_id') - } - if (!device.lorawan_version && Boolean(lorawan_version) && _inputMethod === 'manual') { - device.lorawan_version = lorawan_version - field_mask.paths.push('lorawan_version') - } - if ( - !device.lorawan_phy_version && - Boolean(lorawan_phy_version) && - _inputMethod === 'manual' - ) { - device.lorawan_phy_version = lorawan_phy_version - field_mask.paths.push('lorawan_phy_version') - } - if (!device.version_ids && Boolean(version_ids) && _inputMethod === 'device-repository') { - device.version_ids = version_ids - field_mask.paths.push('version_ids') + setConvertedDevices(devices) + // Apply default values. + for (const deviceAndFieldMask of devices) { + const { end_device: device, field_mask } = deviceAndFieldMask - if (!device.lorawan_version && deviceRepoTemplate) { - device.lorawan_version = deviceRepoTemplate.end_device.lorawan_version - field_mask.paths.push('lorawan_version') + if (set_claim_auth_code && jsConfig.enabled) { + device.claim_authentication_code = { value: randomByteString(4 * 2) } + field_mask.paths.push('claim_authentication_code') } - if (!device.lorawan_phy_version && deviceRepoTemplate) { - device.lorawan_phy_version = deviceRepoTemplate.end_device.lorawan_phy_version - field_mask.paths.push('lorawan_phy_version') + if (device.supports_join && !device.join_server_address && jsConfig.enabled) { + device.join_server_address = new URL(jsConfig.base_url).hostname + field_mask.paths.push('join_server_address') } - if (!device.supports_join && deviceRepoTemplate) { - device.supports_join = deviceRepoTemplate.end_device.supports_join - field_mask.paths.push('supports_join') + if (!device.application_server_address && asConfig.enabled) { + device.application_server_address = new URL(asConfig.base_url).hostname + field_mask.paths.push('application_server_address') } - if (!device.mac_settings && deviceRepoTemplate) { - device.mac_settings = deviceRepoTemplate.end_device.mac_settings - field_mask.paths.push('mac_settings') + if (!device.network_server_address && nsConfig.enabled) { + device.network_server_address = new URL(nsConfig.base_url).hostname + field_mask.paths.push('network_server_address') } - if (!device.frequency_plan_id && Boolean(frequency_plan_id)) { + + // Fallback values + if ( + !device.frequency_plan_id && + Boolean(frequency_plan_id) && + nsConfig.enabled && + _inputMethod === 'manual' + ) { device.frequency_plan_id = frequency_plan_id field_mask.paths.push('frequency_plan_id') } + if (!device.lorawan_version && Boolean(lorawan_version) && _inputMethod === 'manual') { + device.lorawan_version = lorawan_version + field_mask.paths.push('lorawan_version') + } + if ( + !device.lorawan_phy_version && + Boolean(lorawan_phy_version) && + _inputMethod === 'manual' + ) { + device.lorawan_phy_version = lorawan_phy_version + field_mask.paths.push('lorawan_phy_version') + } + if (!device.version_ids && Boolean(version_ids) && _inputMethod === 'device-repository') { + device.version_ids = version_ids + field_mask.paths.push('version_ids') + + if (!device.lorawan_version && deviceRepoTemplate) { + device.lorawan_version = deviceRepoTemplate.end_device.lorawan_version + field_mask.paths.push('lorawan_version') + } + if (!device.lorawan_phy_version && deviceRepoTemplate) { + device.lorawan_phy_version = deviceRepoTemplate.end_device.lorawan_phy_version + field_mask.paths.push('lorawan_phy_version') + } + if (!device.supports_join && deviceRepoTemplate) { + device.supports_join = deviceRepoTemplate.end_device.supports_join + field_mask.paths.push('supports_join') + } + if (!device.mac_settings && deviceRepoTemplate) { + device.mac_settings = deviceRepoTemplate.end_device.mac_settings + field_mask.paths.push('mac_settings') + } + if (!device.frequency_plan_id && Boolean(frequency_plan_id)) { + device.frequency_plan_id = frequency_plan_id + field_mask.paths.push('frequency_plan_id') + } + } } + } catch (error) { + handleFatalError(error) + return } - } catch (error) { - this.handleFatalError(error) - return - } - // Start batch device creation. - this.setState({ - step: 'creation', - }) - this.appendToLog('Creating end devices…') + // Start batch device creation. + setStep('creation') + appendToLog('Creating end devices…') - try { - this.createStream = tts.Applications.Devices.bulkCreate(appId, devices) + try { + createStream.current = tts.Applications.Devices.bulkCreate(appId, devices) - await new Promise(resolve => { - this.createStream.on('chunk', this.handleCreationSuccess) - this.createStream.on('error', this.handleCreationError) - this.createStream.on('close', resolve) + await new Promise(resolve => { + createStream.current.on('chunk', handleCreationSuccess) + createStream.current.on('error', handleCreationError) + createStream.current.on('close', resolve) - this.createStream.start() - }) + createStream.current.start() + }) - if (!this.state.aborted) { - this.appendToLog('\nImport operation complete') - } else { - this.appendToLog('\nImport operation aborted') + if (!aborted) { + appendToLog('\nImport operation complete') + } else { + appendToLog('\nImport operation aborted') + } + setStatus('finished') + } catch (error) { + handleCreationError(error) } - this.setState({ status: 'finished' }) - } catch (error) { - this.handleCreationError(error) - } - } - - @bind - handleAbort() { - if (this.createStream !== null) { - this.createStream.abort() - this.setState({ aborted: true }) - } - } - - @bind - handleReset() { - this.setState(initialState) - } - - get processor() { - const { - log, - currentDeviceIndex, - deviceErrors, - status, - step, - error, - convertedDevices, + }, + [ + appId, + asConfig, + dispatch, + handleCreationError, + handleCreationSuccess, + handleFatalError, + jsConfig, + nsConfig, + setConvertedDevices, + setStatus, + setStep, aborted, - } = this.state - const hasErrored = status === 'error' - const { appId } = this.props - const operationMessage = step === 'conversion' ? m.converting : m.creating - let statusMessage - if (!aborted) { - if (status === 'error') { - statusMessage = m.operationHalted - } else if (status === 'finished') { - statusMessage = m.operationFinished - } else if (status === 'processing') { - statusMessage = m.operationInProgress - } - } else { - statusMessage = m.operationAborted - } - - return ( -
- {!hasErrored ? ( - <> - - - - - {status === 'processing' && ( - - )} - - ) : ( - - )} - {status === 'finished' && ( - <> - {aborted && convertedDevices.length - currentDeviceIndex !== 0 && ( - - )} - {deviceErrors.length !== 0 ? ( -
- {deviceErrors.length >= currentDeviceIndex ? ( - - ) : ( - - )} - -
    - {deviceErrors.map(({ deviceId, error }) => ( -
  • -
    {deviceId}
    - -
  • - ))} -
-
- -
- ) : ( - - )} - - )} - - { + setLog('') + setCurrentDeviceIndex(0) + setConvertedDevices([]) + setDeviceErrors([]) + setStatus('initial') + setStep('initial') + setError(undefined) + setAborted(false) + }, []) + + switch (step) { + case 'conversion': + case 'creation': + return ( + - - - {status === 'finished' && ( - <> - {!hasErrored ? ( - - ) : ( -
- ) - } - - get form() { - const { availableComponents } = this.props - const initialValues = { - format_id: '', - data: '', - set_claim_auth_code: false, - _inputMethod: 'no-fallback', - frequency_plan_id: '', - lorawan_version: '', - lorawan_phy_version: '', - version_ids: { - brand_id: '', - model_id: '', - firmware_version: '', - hardware_version: '', - band_id: '', - }, - } - const largeFile = 10 * 1024 * 1024 - return ( - - - - - - ) - } - - render() { - const { step } = this.state - - switch (step) { - case 'conversion': - case 'creation': - return this.processor - case 'initial': - default: - return this.form - } + ) + case 'initial': + default: + return
} } + +export default DeviceImporter diff --git a/pkg/webui/console/containers/device-importer/messages.js b/pkg/webui/console/containers/device-importer/messages.js new file mode 100644 index 0000000000..7e631ecd56 --- /dev/null +++ b/pkg/webui/console/containers/device-importer/messages.js @@ -0,0 +1,54 @@ +// Copyright © 2023 The Things Network Foundation, The Things Industries B.V. +// +// 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 { defineMessages } from 'react-intl' + +const m = defineMessages({ + proceed: 'Proceed to end device list', + retry: 'Retry from scratch', + abort: 'Abort', + converting: 'Converting templates…', + creating: 'Creating end devices…', + operationInProgress: 'Operation in progress', + operationHalted: 'Operation halted', + operationFinished: 'Operation finished', + operationAborted: 'Operation aborted', + errorTitle: 'There was an error and the operation could not be completed', + conversionErrorTitle: 'Could not import devices', + conversionErrorMessage: + 'An error occurred while processing the provided end device template. This could be due to invalid format, syntax or file encoding. Please check the provided template file and try again. See also our documentation on Importing End Devices for more information.', + incompleteWarningTitle: 'Not all devices imported successfully', + incompleteWarningMessage: + '{count, number} {count, plural, one {end device} other {end devices}} could not be imported successfully, because {count, plural, one {its} other {their}} registration attempt resulted in an error', + incompleteStatus: + 'The registration of the following {count, plural, one {end device} other {end devices}} failed:', + noneWarningTitle: 'No end device was created', + noneWarningMessage: + 'None of your specified end devices was imported, because each registration attempt resulted in an error', + processLog: 'Process log', + progress: + 'Successfully converted {errorCount, number} of {deviceCount, number} {deviceCount, plural, one {end device} other {end devices}}', + successInfoTitle: 'All end devices imported successfully', + successInfoMessage: + 'All of the specified end devices have been converted and imported successfully', + documentationHint: + 'Please also see our documentation on Importing End Devices for more information and possible resolutions.', + abortWarningTitle: 'Device import aborted', + abortWarningMessage: + 'The end device import was aborted and the remaining {count, number} {count, plural, one {end device} other {end devices}} have not been imported', + largeFileWarningMessage: + 'Providing files larger than {warningThreshold} can cause issues during the import process. We recommend you to split such files up into multiple smaller files and importing them one by one.', +}) + +export default m diff --git a/pkg/webui/console/containers/device-importer/processor.js b/pkg/webui/console/containers/device-importer/processor.js new file mode 100644 index 0000000000..ba9f090cab --- /dev/null +++ b/pkg/webui/console/containers/device-importer/processor.js @@ -0,0 +1,245 @@ +// Copyright © 2023 The Things Network Foundation, The Things Industries B.V. +// +// 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 React from 'react' +import { useSelector } from 'react-redux' + +import CodeEditor from '@ttn-lw/components/code-editor' +import ProgressBar from '@ttn-lw/components/progress-bar' +import SubmitBar from '@ttn-lw/components/submit-bar' +import Button from '@ttn-lw/components/button' +import ErrorNotification from '@ttn-lw/components/error-notification' +import Notification from '@ttn-lw/components/notification' +import Status from '@ttn-lw/components/status' +import ButtonGroup from '@ttn-lw/components/button/group' +import Link from '@ttn-lw/components/link' + +import Message from '@ttn-lw/lib/components/message' +import ErrorMessage from '@ttn-lw/lib/components/error-message' + +import { isFrontend } from '@ttn-lw/lib/errors/utils' +import PropTypes from '@ttn-lw/lib/prop-types' + +import { selectSelectedApplicationId } from '@console/store/selectors/applications' + +import m from './messages' + +import style from './device-importer.styl' + +const statusMap = { + processing: 'good', + error: 'bad', + finished: 'good', +} + +const docLinkValue = msg => ( + + {msg} + +) + +const Processor = ({ + log, + currentDeviceIndex, + deviceErrors, + status, + step, + error, + convertedDevices, + aborted, + handleAbort, + handleReset, + editorRef, +}) => { + const appId = useSelector(selectSelectedApplicationId) + const hasErrored = status === 'error' + const operationMessage = step === 'conversion' ? m.converting : m.creating + let statusMessage + if (!aborted) { + if (status === 'error') { + statusMessage = m.operationHalted + } else if (status === 'finished') { + statusMessage = m.operationFinished + } else if (status === 'processing') { + statusMessage = m.operationInProgress + } + } else { + statusMessage = m.operationAborted + } + + return ( +
+ {!hasErrored ? ( + <> + + + + + {status === 'processing' && ( + + )} + + ) : ( + + )} + {status === 'finished' && ( + <> + {aborted && convertedDevices.length - currentDeviceIndex !== 0 && ( + + )} + {deviceErrors.length !== 0 ? ( +
+ {deviceErrors.length >= currentDeviceIndex ? ( + + ) : ( + + )} + +
    + {deviceErrors.map(({ deviceId, error }) => ( +
  • +
    {deviceId}
    + +
  • + ))} +
+
+ +
+ ) : ( + + )} + + )} + + + + + {status === 'finished' && ( + <> + {!hasErrored ? ( + + ) : ( +
+ ) +} + +Processor.propTypes = { + aborted: PropTypes.bool.isRequired, + convertedDevices: PropTypes.arrayOf( + PropTypes.shape({ + deviceId: PropTypes.string, + device: PropTypes.shape({}), + }), + ).isRequired, + currentDeviceIndex: PropTypes.number.isRequired, + deviceErrors: PropTypes.arrayOf( + PropTypes.shape({ + deviceId: PropTypes.string.isRequired, + error: PropTypes.string.isRequired, + }), + ).isRequired, + editorRef: PropTypes.shape({}).isRequired, + error: PropTypes.error, + handleAbort: PropTypes.func.isRequired, + handleReset: PropTypes.func.isRequired, + log: PropTypes.string.isRequired, + status: PropTypes.string.isRequired, + step: PropTypes.string.isRequired, +} + +Processor.defaultProps = { + error: undefined, +} + +export default Processor diff --git a/pkg/webui/console/containers/log-back-in-modal/index.js b/pkg/webui/console/containers/log-back-in-modal/index.js index 94022829ed..8804eecd36 100644 --- a/pkg/webui/console/containers/log-back-in-modal/index.js +++ b/pkg/webui/console/containers/log-back-in-modal/index.js @@ -28,19 +28,15 @@ const reload = () => { window.location.reload() } -class LogBackInModal extends React.Component { - render() { - return ( - - ) - } -} +const LogBackInModal = () => ( + +) export default LogBackInModal diff --git a/pkg/webui/console/containers/user-data-form/add.js b/pkg/webui/console/containers/user-data-form/add.js new file mode 100644 index 0000000000..46ac8e7fc8 --- /dev/null +++ b/pkg/webui/console/containers/user-data-form/add.js @@ -0,0 +1,219 @@ +// Copyright © 2023 The Things Network Foundation, The Things Industries B.V. +// +// 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 React, { useCallback, useState } from 'react' +import { useSelector, useDispatch } from 'react-redux' +import { useNavigate } from 'react-router-dom' +import { Container, Col, Row } from 'react-grid-system' +import { defineMessages, useIntl } from 'react-intl' + +import PageTitle from '@ttn-lw/components/page-title' +import Form from '@ttn-lw/components/form' +import Input from '@ttn-lw/components/input' +import Select from '@ttn-lw/components/select' +import Checkbox from '@ttn-lw/components/checkbox' +import SubmitBar from '@ttn-lw/components/submit-bar' +import SubmitButton from '@ttn-lw/components/submit-button' + +import sharedMessages from '@ttn-lw/lib/shared-messages' +import Yup from '@ttn-lw/lib/yup' +import createPasswordValidationSchema from '@ttn-lw/lib/create-password-validation-schema' +import { userId as userIdRegexp } from '@ttn-lw/lib/regexp' +import capitalizeMessage from '@ttn-lw/lib/capitalize-message' + +import { createUser } from '@console/store/actions/users' + +import { selectPasswordRequirements } from '@console/store/selectors/identity-server' + +const approvalStates = [ + 'STATE_REQUESTED', + 'STATE_APPROVED', + 'STATE_REJECTED', + 'STATE_FLAGGED', + 'STATE_SUSPENDED', +] + +const m = defineMessages({ + adminLabel: 'Grant this user admin status', + adminDescription: + 'Admin status enables overarching rights such as managing other users or modifying entities regardless of collaboration status', + userDescPlaceholder: 'Description for my new user', + userDescDescription: 'Optional user description; can also be used to save notes about the user', + userIdPlaceholder: 'jane-doe', + userNamePlaceholder: 'Jane Doe', + emailPlaceholder: 'mail@example.com', + emailAddressDescription: + 'Primary email address used for logging in; this address is not publicly visible', + emailAddressValidation: 'Treat email address as validated', + emailAddressValidationDescription: + 'Enable this option if you do not need this user to validate the email address', +}) + +const baseValidationSchema = Yup.object().shape({ + ids: Yup.object().shape({ + user_id: Yup.string() + .min(2, Yup.passValues(sharedMessages.validateTooShort)) + .max(36, Yup.passValues(sharedMessages.validateTooLong)) + .matches(userIdRegexp, Yup.passValues(sharedMessages.validateIdFormat)) + .required(sharedMessages.validateRequired), + }), + name: Yup.string() + .min(2, Yup.passValues(sharedMessages.validateTooShort)) + .max(50, Yup.passValues(sharedMessages.validateTooLong)), + primary_email_address: Yup.string() + .email(sharedMessages.validateEmail) + .required(sharedMessages.validateRequired), + state: Yup.string() + .oneOf(approvalStates, sharedMessages.validateRequired) + .required(sharedMessages.validateRequired), + description: Yup.string().max(2000, Yup.passValues(sharedMessages.validateTooLong)), +}) + +const UserDataFormAdd = () => { + const dispatch = useDispatch() + const navigate = useNavigate() + const intl = useIntl() + const [error, setError] = useState(undefined) + + const passwordRequirements = useSelector(selectPasswordRequirements) + const validationSchema = baseValidationSchema.concat( + createPasswordValidationSchema(passwordRequirements), + ) + const createUserAction = useCallback(values => dispatch(createUser(values)), [dispatch]) + + const { formatMessage } = intl + + const approvalStateOptions = approvalStates.map(state => ({ + value: state, + label: capitalizeMessage(formatMessage({ id: `enum:${state}` })), + })) + + const initialValues = { + ids: { + user_id: '', + }, + name: '', + description: '', + primary_email_address: '', + state: '', + admin: false, + } + + const handleSubmit = useCallback( + async (vals, { resetForm, setSubmitting }) => { + const { _validate_email, ...values } = validationSchema.cast(vals) + + if (_validate_email) { + values.primary_email_address_validated_at = new Date().toISOString() + } + + setError(undefined) + try { + await createUserAction(values) + resetForm({ values: vals }) + navigate('/admin-panel/user-management') + } catch (error) { + setSubmitting(false) + setError(error) + } + }, + [createUserAction, navigate, validationSchema], + ) + + return ( + + + + + + + + + + + + + + + + + + + + + + ) +} + +export default UserDataFormAdd diff --git a/pkg/webui/console/containers/user-data-form/edit.js b/pkg/webui/console/containers/user-data-form/edit.js new file mode 100644 index 0000000000..b195d06be6 --- /dev/null +++ b/pkg/webui/console/containers/user-data-form/edit.js @@ -0,0 +1,243 @@ +// Copyright © 2023 The Things Network Foundation, The Things Industries B.V. +// +// 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 React, { useCallback, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { useNavigate, useParams } from 'react-router-dom' +import { Container, Col, Row } from 'react-grid-system' +import { defineMessages, useIntl } from 'react-intl' + +import toast from '@ttn-lw/components/toast' +import PageTitle from '@ttn-lw/components/page-title' +import Form from '@ttn-lw/components/form' +import Input from '@ttn-lw/components/input' +import Select from '@ttn-lw/components/select' +import Checkbox from '@ttn-lw/components/checkbox' +import SubmitBar from '@ttn-lw/components/submit-bar' +import SubmitButton from '@ttn-lw/components/submit-button' +import DeleteModalButton from '@ttn-lw/components/delete-modal-button' + +import Yup from '@ttn-lw/lib/yup' +import { userId as userIdRegexp } from '@ttn-lw/lib/regexp' +import sharedMessages from '@ttn-lw/lib/shared-messages' +import diff from '@ttn-lw/lib/diff' +import attachPromise from '@ttn-lw/lib/store/actions/attach-promise' +import capitalizeMessage from '@ttn-lw/lib/capitalize-message' + +import { updateUser, deleteUser } from '@console/store/actions/users' + +import { selectSelectedUser } from '@console/store/selectors/users' + +const approvalStates = [ + 'STATE_REQUESTED', + 'STATE_APPROVED', + 'STATE_REJECTED', + 'STATE_FLAGGED', + 'STATE_SUSPENDED', +] + +const m = defineMessages({ + adminLabel: 'Grant this user admin status', + adminDescription: + 'Admin status enables overarching rights such as managing other users or modifying entities regardless of collaboration status', + userDescPlaceholder: 'Description for my new user', + userDescDescription: 'Optional user description; can also be used to save notes about the user', + userIdPlaceholder: 'jane-doe', + userNamePlaceholder: 'Jane Doe', + emailPlaceholder: 'mail@example.com', + emailAddressDescription: + 'Primary email address used for logging in; this address is not publicly visible', + emailAddressValidation: 'Treat email address as validated', + emailAddressValidationDescription: + 'Enable this option if you do not need this user to validate the email address', + deleteTitle: 'Are you sure you want to delete this account?', + deleteWarning: + "This will PERMANENTLY DELETE THIS ACCOUNT and LOCK THE USER ID AND EMAIL FOR RE-REGISTRATION. Associated entities (e.g. gateways, applications and end devices) owned by this user that do not have any other collaborators will become UNACCESSIBLE and it will NOT BE POSSIBLE TO REGISTER ENTITIES WITH THE SAME ID OR EUI's AGAIN. Make sure you assign new collaborators to such entities if you plan to continue using them.", + purgeWarning: + "This will PERMANENTLY DELETE THIS ACCOUNT. Associated entities (e.g. gateways, applications and end devices) owned by this user that do not have any other collaborators will become UNACCESSIBLE and it will NOT BE POSSIBLE TO REGISTER ENTITIES WITH THE SAME ID OR EUI's AGAIN. Make sure you assign new collaborators to such entities if you plan to continue using them.", + deleteConfirmMessage: "Please type in this user's user ID to confirm.", + updateSuccess: 'User updated', + deleteSuccess: 'User deleted', +}) + +const validationSchema = Yup.object().shape({ + ids: Yup.object().shape({ + user_id: Yup.string() + .min(2, Yup.passValues(sharedMessages.validateTooShort)) + .max(36, Yup.passValues(sharedMessages.validateTooLong)) + .matches(userIdRegexp, Yup.passValues(sharedMessages.validateIdFormat)) + .required(sharedMessages.validateRequired), + }), + name: Yup.string() + .min(2, Yup.passValues(sharedMessages.validateTooShort)) + .max(50, Yup.passValues(sharedMessages.validateTooLong)), + primary_email_address: Yup.string() + .email(sharedMessages.validateEmail) + .required(sharedMessages.validateRequired), + state: Yup.string() + .oneOf(approvalStates, sharedMessages.validateRequired) + .required(sharedMessages.validateRequired), + description: Yup.string().max(2000, Yup.passValues(sharedMessages.validateTooLong)), +}) + +const UserDataFormEdit = () => { + const dispatch = useDispatch() + const navigate = useNavigate() + const { userId } = useParams() + const intl = useIntl() + const user = useSelector(selectSelectedUser) + const [error, setError] = useState(undefined) + + const wrappedUpdateUser = attachPromise(updateUser) + const wrappedDeleteUser = attachPromise(deleteUser) + + const initialValues = { + admin: false, + ...user, + } + + const { formatMessage } = intl + + const approvalStateOptions = approvalStates.map(state => ({ + value: state, + label: capitalizeMessage(formatMessage({ id: `enum:${state}` })), + })) + + const handleSubmit = useCallback( + async (vals, { resetForm, setSubmitting }) => { + const { _validate_email, ...values } = validationSchema.cast(vals) + + if (_validate_email) { + values.primary_email_address_validated_at = new Date().toISOString() + } + + setError(undefined) + try { + const patch = diff(user, values) + const submitPatch = Object.keys(patch).length !== 0 ? patch : user + await dispatch(wrappedUpdateUser(userId, submitPatch)) + resetForm({ values: vals }) + toast({ + title: userId, + message: m.updateSuccess, + type: toast.types.SUCCESS, + }) + } catch (error) { + setSubmitting(false) + setError(error) + } + }, + [dispatch, user, userId, wrappedUpdateUser], + ) + + const handleDelete = useCallback( + async shouldPurge => { + try { + await dispatch(wrappedDeleteUser(userId, { purge: shouldPurge })) + toast({ + title: userId, + message: m.deleteSuccess, + type: toast.types.SUCCESS, + }) + + navigate('/admin-panel/user-management') + } catch (error) { + setError(error) + } + }, + [dispatch, navigate, userId, wrappedDeleteUser], + ) + + return ( + + + + +
+ + + + + + + + + + + + + +
+
+ ) +} + +export default UserDataFormEdit diff --git a/pkg/webui/console/views/admin-user-management-add/index.js b/pkg/webui/console/views/admin-user-management-add/index.js index 05a09c510e..4d5ba3da1c 100644 --- a/pkg/webui/console/views/admin-user-management-add/index.js +++ b/pkg/webui/console/views/admin-user-management-add/index.js @@ -12,52 +12,19 @@ // See the License for the specific language governing permissions and // limitations under the License. -import React, { useCallback } from 'react' -import { useSelector, useDispatch } from 'react-redux' -import { useNavigate } from 'react-router-dom' -import { Container, Col, Row } from 'react-grid-system' +import React from 'react' -import PageTitle from '@ttn-lw/components/page-title' import Breadcrumb from '@ttn-lw/components/breadcrumbs/breadcrumb' import { useBreadcrumbs } from '@ttn-lw/components/breadcrumbs/context' import RequireRequest from '@ttn-lw/lib/components/require-request' -import UserDataForm from '@console/components/user-data-form' +import UserDataFormAdd from '@console/containers/user-data-form/add' import sharedMessages from '@ttn-lw/lib/shared-messages' -import { createUser } from '@console/store/actions/users' import { getIsConfiguration } from '@console/store/actions/identity-server' -import { selectPasswordRequirements } from '@console/store/selectors/identity-server' - -const UserManagementAddInner = () => { - const dispatch = useDispatch() - const navigate = useNavigate() - - const passwordRequirements = useSelector(selectPasswordRequirements) - const createUserAction = useCallback(values => dispatch(createUser(values)), [dispatch]) - - const onSubmit = useCallback(values => createUserAction(values), [createUserAction]) - const onSubmitSuccess = useCallback(() => navigate('/admin-panel/user-management'), [navigate]) - - return ( - - - - - - - - - ) -} - const UserManagementAdd = () => { useBreadcrumbs( 'admin-panel.user-management.add', @@ -66,7 +33,7 @@ const UserManagementAdd = () => { return ( - + ) } diff --git a/pkg/webui/console/views/admin-user-management-edit/index.js b/pkg/webui/console/views/admin-user-management-edit/index.js index 776a757e2b..5a867d5bc7 100644 --- a/pkg/webui/console/views/admin-user-management-edit/index.js +++ b/pkg/webui/console/views/admin-user-management-edit/index.js @@ -12,104 +12,28 @@ // See the License for the specific language governing permissions and // limitations under the License. -import React, { useCallback } from 'react' -import { useDispatch, useSelector } from 'react-redux' -import { useNavigate, useParams } from 'react-router-dom' -import { Container, Col, Row } from 'react-grid-system' -import { defineMessages } from 'react-intl' +import React from 'react' +import { useParams } from 'react-router-dom' -import toast from '@ttn-lw/components/toast' -import PageTitle from '@ttn-lw/components/page-title' import { useBreadcrumbs } from '@ttn-lw/components/breadcrumbs/context' import Breadcrumb from '@ttn-lw/components/breadcrumbs/breadcrumb' import RequireRequest from '@ttn-lw/lib/components/require-request' -import UserDataForm from '@console/components/user-data-form' +import UserDataFormEdit from '@console/containers/user-data-form/edit' import sharedMessages from '@ttn-lw/lib/shared-messages' -import diff from '@ttn-lw/lib/diff' -import { getUserId } from '@ttn-lw/lib/selectors/id' -import attachPromise from '@ttn-lw/lib/store/actions/attach-promise' -import { getUser, updateUser, deleteUser } from '@console/store/actions/users' +import { getUser } from '@console/store/actions/users' -import { selectSelectedUser } from '@console/store/selectors/users' - -const m = defineMessages({ - updateSuccess: 'User updated', - deleteSuccess: 'User deleted', -}) - -const UserManagementEditInner = () => { - const dispatch = useDispatch() - const navigate = useNavigate() +const UserManagementEdit = () => { const { userId } = useParams() - const user = useSelector(selectSelectedUser) - - const wrappedUpdateUser = attachPromise(updateUser) - const wrappedDeleteUser = attachPromise(deleteUser) useBreadcrumbs( 'admin-panel.user-management.edit', , ) - const onSubmit = useCallback( - values => { - const patch = diff(user, values) - const submitPatch = Object.keys(patch).length !== 0 ? patch : user - return dispatch(wrappedUpdateUser(userId, submitPatch)) - }, - [user, userId, wrappedUpdateUser, dispatch], - ) - - const onSubmitSuccess = useCallback(response => { - const userId = getUserId(response) - toast({ - title: userId, - message: m.updateSuccess, - type: toast.types.SUCCESS, - }) - }, []) - - const onDelete = useCallback( - shouldPurge => dispatch(wrappedDeleteUser(userId, { purge: shouldPurge })), - [userId, wrappedDeleteUser, dispatch], - ) - - const onDeleteSuccess = useCallback(() => { - toast({ - title: userId, - message: m.deleteSuccess, - type: toast.types.SUCCESS, - }) - - navigate('../../') - }, [userId, navigate]) - - return ( - - - - - - - - - ) -} - -const UserManagementEdit = () => { - const { userId } = useParams() - return ( { 'description', ])} > - + ) } diff --git a/pkg/webui/locales/en.json b/pkg/webui/locales/en.json index 66d9a48804..60a8e14125 100644 --- a/pkg/webui/locales/en.json +++ b/pkg/webui/locales/en.json @@ -418,20 +418,6 @@ "console.components.uplink-form.uplink-form.simulateUplink": "Simulate uplink", "console.components.uplink-form.uplink-form.payloadDescription": "The desired payload bytes of the uplink message", "console.components.uplink-form.uplink-form.uplinkSuccess": "Uplink sent", - "console.components.user-data-form.index.adminLabel": "Grant this user admin status", - "console.components.user-data-form.index.adminDescription": "Admin status enables overarching rights such as managing other users or modifying entities regardless of collaboration status", - "console.components.user-data-form.index.userDescPlaceholder": "Description for my new user", - "console.components.user-data-form.index.userDescDescription": "Optional user description; can also be used to save notes about the user", - "console.components.user-data-form.index.userIdPlaceholder": "jane-doe", - "console.components.user-data-form.index.userNamePlaceholder": "Jane Doe", - "console.components.user-data-form.index.emailPlaceholder": "mail@example.com", - "console.components.user-data-form.index.emailAddressDescription": "Primary email address used for logging in; this address is not publicly visible", - "console.components.user-data-form.index.emailAddressValidation": "Treat email address as validated", - "console.components.user-data-form.index.emailAddressValidationDescription": "Enable this option if you do not need this user to validate the email address", - "console.components.user-data-form.index.deleteTitle": "Are you sure you want to delete this account?", - "console.components.user-data-form.index.deleteWarning": "This will PERMANENTLY DELETE THIS ACCOUNT and LOCK THE USER ID AND EMAIL FOR RE-REGISTRATION. Associated entities (e.g. gateways, applications and end devices) owned by this user that do not have any other collaborators will become UNACCESSIBLE and it will NOT BE POSSIBLE TO REGISTER ENTITIES WITH THE SAME ID OR EUI's AGAIN. Make sure you assign new collaborators to such entities if you plan to continue using them.", - "console.components.user-data-form.index.purgeWarning": "This will PERMANENTLY DELETE THIS ACCOUNT. Associated entities (e.g. gateways, applications and end devices) owned by this user that do not have any other collaborators will become UNACCESSIBLE and it will NOT BE POSSIBLE TO REGISTER ENTITIES WITH THE SAME ID OR EUI's AGAIN. Make sure you assign new collaborators to such entities if you plan to continue using them.", - "console.components.user-data-form.index.deleteConfirmMessage": "Please type in this user's user ID to confirm.", "console.components.webhook-form.index.idPlaceholder": "my-new-webhook", "console.components.webhook-form.index.messageInfo": "For each enabled event type an optional path can be defined which will be appended to the base URL", "console.components.webhook-form.index.deleteWebhook": "Delete Webhook", @@ -501,31 +487,31 @@ "console.containers.deployment-component-status.index.statusPage": "Go to status page", "console.containers.deployment-component-status.index.seeChangelog": "See changelog", "console.containers.dev-addr-input.index.devAddrFetchingFailure": "There was an error and the end device address could not be generated", - "console.containers.device-importer.index.proceed": "Proceed to end device list", - "console.containers.device-importer.index.retry": "Retry from scratch", - "console.containers.device-importer.index.abort": "Abort", - "console.containers.device-importer.index.converting": "Converting templates…", - "console.containers.device-importer.index.creating": "Creating end devices…", - "console.containers.device-importer.index.operationInProgress": "Operation in progress", - "console.containers.device-importer.index.operationHalted": "Operation halted", - "console.containers.device-importer.index.operationFinished": "Operation finished", - "console.containers.device-importer.index.operationAborted": "Operation aborted", - "console.containers.device-importer.index.errorTitle": "There was an error and the operation could not be completed", - "console.containers.device-importer.index.conversionErrorTitle": "Could not import devices", - "console.containers.device-importer.index.conversionErrorMessage": "An error occurred while processing the provided end device template. This could be due to invalid format, syntax or file encoding. Please check the provided template file and try again. See also our documentation on Importing End Devices for more information.", - "console.containers.device-importer.index.incompleteWarningTitle": "Not all devices imported successfully", - "console.containers.device-importer.index.incompleteWarningMessage": "{count, number} {count, plural, one {end device} other {end devices}} could not be imported successfully, because {count, plural, one {its} other {their}} registration attempt resulted in an error", - "console.containers.device-importer.index.incompleteStatus": "The registration of the following {count, plural, one {end device} other {end devices}} failed:", - "console.containers.device-importer.index.noneWarningTitle": "No end device was created", - "console.containers.device-importer.index.noneWarningMessage": "None of your specified end devices was imported, because each registration attempt resulted in an error", - "console.containers.device-importer.index.processLog": "Process log", - "console.containers.device-importer.index.progress": "Successfully converted {errorCount, number} of {deviceCount, number} {deviceCount, plural, one {end device} other {end devices}}", - "console.containers.device-importer.index.successInfoTitle": "All end devices imported successfully", - "console.containers.device-importer.index.successInfoMessage": "All of the specified end devices have been converted and imported successfully", - "console.containers.device-importer.index.documentationHint": "Please also see our documentation on Importing End Devices for more information and possible resolutions.", - "console.containers.device-importer.index.abortWarningTitle": "Device import aborted", - "console.containers.device-importer.index.abortWarningMessage": "The end device import was aborted and the remaining {count, number} {count, plural, one {end device} other {end devices}} have not been imported", - "console.containers.device-importer.index.largeFileWarningMessage": "Providing files larger than {warningThreshold} can cause issues during the import process. We recommend you to split such files up into multiple smaller files and importing them one by one.", + "console.containers.device-importer.messages.proceed": "Proceed to end device list", + "console.containers.device-importer.messages.retry": "Retry from scratch", + "console.containers.device-importer.messages.abort": "Abort", + "console.containers.device-importer.messages.converting": "Converting templates…", + "console.containers.device-importer.messages.creating": "Creating end devices…", + "console.containers.device-importer.messages.operationInProgress": "Operation in progress", + "console.containers.device-importer.messages.operationHalted": "Operation halted", + "console.containers.device-importer.messages.operationFinished": "Operation finished", + "console.containers.device-importer.messages.operationAborted": "Operation aborted", + "console.containers.device-importer.messages.errorTitle": "There was an error and the operation could not be completed", + "console.containers.device-importer.messages.conversionErrorTitle": "Could not import devices", + "console.containers.device-importer.messages.conversionErrorMessage": "An error occurred while processing the provided end device template. This could be due to invalid format, syntax or file encoding. Please check the provided template file and try again. See also our documentation on Importing End Devices for more information.", + "console.containers.device-importer.messages.incompleteWarningTitle": "Not all devices imported successfully", + "console.containers.device-importer.messages.incompleteWarningMessage": "{count, number} {count, plural, one {end device} other {end devices}} could not be imported successfully, because {count, plural, one {its} other {their}} registration attempt resulted in an error", + "console.containers.device-importer.messages.incompleteStatus": "The registration of the following {count, plural, one {end device} other {end devices}} failed:", + "console.containers.device-importer.messages.noneWarningTitle": "No end device was created", + "console.containers.device-importer.messages.noneWarningMessage": "None of your specified end devices was imported, because each registration attempt resulted in an error", + "console.containers.device-importer.messages.processLog": "Process log", + "console.containers.device-importer.messages.progress": "Successfully converted {errorCount, number} of {deviceCount, number} {deviceCount, plural, one {end device} other {end devices}}", + "console.containers.device-importer.messages.successInfoTitle": "All end devices imported successfully", + "console.containers.device-importer.messages.successInfoMessage": "All of the specified end devices have been converted and imported successfully", + "console.containers.device-importer.messages.documentationHint": "Please also see our documentation on Importing End Devices for more information and possible resolutions.", + "console.containers.device-importer.messages.abortWarningTitle": "Device import aborted", + "console.containers.device-importer.messages.abortWarningMessage": "The end device import was aborted and the remaining {count, number} {count, plural, one {end device} other {end devices}} have not been imported", + "console.containers.device-importer.messages.largeFileWarningMessage": "Providing files larger than {warningThreshold} can cause issues during the import process. We recommend you to split such files up into multiple smaller files and importing them one by one.", "console.containers.device-onboarding-form.messages.endDeviceType": "End device type", "console.containers.device-onboarding-form.messages.provisioningTitle": "Provisioning information", "console.containers.device-onboarding-form.messages.inputMethod": "Input Method", @@ -689,6 +675,32 @@ "console.containers.pubsub-formats-select.index.warning": "Pub/Sub formats unavailable", "console.containers.pubsubs-table.index.format": "Format", "console.containers.pubsubs-table.index.host": "Server host", + "console.containers.user-data-form.add.adminLabel": "Grant this user admin status", + "console.containers.user-data-form.add.adminDescription": "Admin status enables overarching rights such as managing other users or modifying entities regardless of collaboration status", + "console.containers.user-data-form.add.userDescPlaceholder": "Description for my new user", + "console.containers.user-data-form.add.userDescDescription": "Optional user description; can also be used to save notes about the user", + "console.containers.user-data-form.add.userIdPlaceholder": "jane-doe", + "console.containers.user-data-form.add.userNamePlaceholder": "Jane Doe", + "console.containers.user-data-form.add.emailPlaceholder": "mail@example.com", + "console.containers.user-data-form.add.emailAddressDescription": "Primary email address used for logging in; this address is not publicly visible", + "console.containers.user-data-form.add.emailAddressValidation": "Treat email address as validated", + "console.containers.user-data-form.add.emailAddressValidationDescription": "Enable this option if you do not need this user to validate the email address", + "console.containers.user-data-form.edit.adminLabel": "Grant this user admin status", + "console.containers.user-data-form.edit.adminDescription": "Admin status enables overarching rights such as managing other users or modifying entities regardless of collaboration status", + "console.containers.user-data-form.edit.userDescPlaceholder": "Description for my new user", + "console.containers.user-data-form.edit.userDescDescription": "Optional user description; can also be used to save notes about the user", + "console.containers.user-data-form.edit.userIdPlaceholder": "jane-doe", + "console.containers.user-data-form.edit.userNamePlaceholder": "Jane Doe", + "console.containers.user-data-form.edit.emailPlaceholder": "mail@example.com", + "console.containers.user-data-form.edit.emailAddressDescription": "Primary email address used for logging in; this address is not publicly visible", + "console.containers.user-data-form.edit.emailAddressValidation": "Treat email address as validated", + "console.containers.user-data-form.edit.emailAddressValidationDescription": "Enable this option if you do not need this user to validate the email address", + "console.containers.user-data-form.edit.deleteTitle": "Are you sure you want to delete this account?", + "console.containers.user-data-form.edit.deleteWarning": "This will PERMANENTLY DELETE THIS ACCOUNT and LOCK THE USER ID AND EMAIL FOR RE-REGISTRATION. Associated entities (e.g. gateways, applications and end devices) owned by this user that do not have any other collaborators will become UNACCESSIBLE and it will NOT BE POSSIBLE TO REGISTER ENTITIES WITH THE SAME ID OR EUI's AGAIN. Make sure you assign new collaborators to such entities if you plan to continue using them.", + "console.containers.user-data-form.edit.purgeWarning": "This will PERMANENTLY DELETE THIS ACCOUNT. Associated entities (e.g. gateways, applications and end devices) owned by this user that do not have any other collaborators will become UNACCESSIBLE and it will NOT BE POSSIBLE TO REGISTER ENTITIES WITH THE SAME ID OR EUI's AGAIN. Make sure you assign new collaborators to such entities if you plan to continue using them.", + "console.containers.user-data-form.edit.deleteConfirmMessage": "Please type in this user's user ID to confirm.", + "console.containers.user-data-form.edit.updateSuccess": "User updated", + "console.containers.user-data-form.edit.deleteSuccess": "User deleted", "console.containers.users-table.index.invite": "Invite user", "console.containers.users-table.index.revokeInvitation": "Revoke this invitation", "console.containers.users-table.index.sentAt": "Sent", @@ -826,8 +838,6 @@ "console.views.admin-packet-broker.messages.defaultGatewayVisibilitySet": "Default gateway visibility set", "console.views.admin-packet-broker.messages.packetBrokerStatusPage": "Packet Broker Status page", "console.views.admin-panel-network-information.index.title": "Network information", - "console.views.admin-user-management-edit.index.updateSuccess": "User updated", - "console.views.admin-user-management-edit.index.deleteSuccess": "User deleted", "console.views.application-add.index.appDescription": "Within applications, you can register and manage end devices and their network data. After setting up your device fleet, use one of our many integration options to pass relevant data to your external services.{break}Learn more in our guide on Adding Applications.", "console.views.application-data.index.appData": "Application data", "console.views.application-integrations-lora-cloud.index.loraCloudInfoText": "Lora Cloud provides value added APIs that enable simple solutions for common tasks related to LoRaWAN networks and LoRa-based devices. You can setup our LoRaCloud integrations below.",