diff --git a/cypress/e2e/console/gateways/create.spec.js b/cypress/e2e/console/gateways/create.spec.js index b1fd349c80..b00762cb6c 100644 --- a/cypress/e2e/console/gateways/create.spec.js +++ b/cypress/e2e/console/gateways/create.spec.js @@ -190,7 +190,7 @@ describe('Gateway create', () => { cy.findByLabelText('Gateway EUI').type(gateway.eui) cy.findByRole('button', { name: 'Confirm' }).click() cy.findByTestId('notification').should('be.visible') - cy.findByLabelText('Owner token').type('12345') + cy.get('input[name="authenticated_identifiers.authentication_code"]').type('12345') cy.findByLabelText('Gateway ID').type(`eui-${gateway.eui}`) cy.findByText('Frequency plan') .parents('div[data-test-id="form-field"]') @@ -239,7 +239,7 @@ describe('Gateway create', () => { cy.findByTestId('notification').should('be.visible') cy.findByLabelText('Frequency plan').selectOption(gateway.frequency_plan) cy.findByLabelText('Gateway ID').type(`eui-${gateway.eui}`) - cy.findByLabelText('Owner token').type('12345') + cy.get('input[name="authenticated_identifiers.authentication_code"]').type('12345') cy.findByRole('button', { name: 'Claim gateway' }).click() cy.wait('@claim-request').its('request.body').should('deep.equal', expectedRequest) cy.findByTestId('error-notification').should('not.exist') diff --git a/pkg/webui/components/form/field/index.js b/pkg/webui/components/form/field/index.js index 897449efbe..b6e920d5eb 100644 --- a/pkg/webui/components/form/field/index.js +++ b/pkg/webui/components/form/field/index.js @@ -77,6 +77,7 @@ const FormField = props => { component: Component, decode, description, + descriptionValues, disabled: inputDisabled, encode, fieldWidth, @@ -92,6 +93,7 @@ const FormField = props => { valueSetter, onChange, onBlur, + showTitle, } = props const { @@ -222,7 +224,12 @@ const FormField = props => { ) : showDescription ? ( - + ) : null const fieldComponentProps = { @@ -253,7 +260,7 @@ const FormField = props => { return (
- {hasTitle && ( + {hasTitle && showTitle && (
{ - const { message, onApprove, onCancel, onRead, qrData, invalidMessage } = props + const { message, onApprove, onCancel, onRead, qrData, invalidMessage, modalDataChildren } = props const handleRead = useCallback( val => { @@ -53,7 +53,10 @@ const QRModalButton = props => {
{qrData.data ? ( qrData.valid ? ( - + <> + + {modalDataChildren} + ) : ( ) @@ -97,17 +100,20 @@ const QRModalButton = props => { QRModalButton.propTypes = { invalidMessage: PropTypes.message.isRequired, message: PropTypes.message.isRequired, + modalDataChildren: PropTypes.node, onApprove: PropTypes.func.isRequired, onCancel: PropTypes.func.isRequired, onRead: PropTypes.func.isRequired, qrData: PropTypes.shape({ valid: PropTypes.bool, data: PropTypes.arrayOf(PropTypes.shape()), + gateway: PropTypes.shape({}), }), } QRModalButton.defaultProps = { qrData: undefined, + modalDataChildren: null, } export default QRModalButton diff --git a/pkg/webui/components/tabs/index.js b/pkg/webui/components/tabs/index.js index 40352ed86d..a302d06e66 100644 --- a/pkg/webui/components/tabs/index.js +++ b/pkg/webui/components/tabs/index.js @@ -36,6 +36,7 @@ const Tabs = ({ narrow, toggleStyle, disabled, + small, }) => { const handleClick = tabName => { if (!disabled && onTabChange) { @@ -67,6 +68,7 @@ const Tabs = ({ className={tabItemClassName} toggleStyle={toggleStyle} tooltip={description} + small={small} > {icon && } @@ -92,6 +94,7 @@ Tabs.propTypes = { narrow: PropTypes.bool, /** A list of tabs. */ onTabChange: PropTypes.func, + small: PropTypes.bool, tabItemClassName: PropTypes.string, tabs: PropTypes.arrayOf( PropTypes.shape({ @@ -115,6 +118,7 @@ Tabs.defaultProps = { narrow: false, toggleStyle: false, disabled: false, + small: false, } export default Tabs diff --git a/pkg/webui/components/tabs/tab/index.js b/pkg/webui/components/tabs/tab/index.js index 9b475548eb..34a222df58 100644 --- a/pkg/webui/components/tabs/tab/index.js +++ b/pkg/webui/components/tabs/tab/index.js @@ -38,6 +38,7 @@ const Tab = props => { tabClassName, toggleStyle, tooltip, + small, ...rest } = props @@ -56,6 +57,7 @@ const Tab = props => { [style.tabItemDisabled]: disabled, [style.tabItemToggleStyle]: toggleStyle, [style.tabItemToggleStyleActive]: toggleStyle && !disabled && active, + [style.small]: small, }) // There is no support for disabled on anchors in html and hence in @@ -113,6 +115,7 @@ Tab.propTypes = { * name of the new active tab as an argument. */ onClick: PropTypes.func, + small: PropTypes.bool, tabClassName: PropTypes.string, /** A flag specifying whether the tab should render a toggle style. */ toggleStyle: PropTypes.bool, @@ -132,6 +135,7 @@ Tab.defaultProps = { exact: true, toggleStyle: false, tooltip: undefined, + small: false, } export default Tab diff --git a/pkg/webui/components/tabs/tab/tab.styl b/pkg/webui/components/tabs/tab/tab.styl index bd26b83f95..90e4a41e9c 100644 --- a/pkg/webui/components/tabs/tab/tab.styl +++ b/pkg/webui/components/tabs/tab/tab.styl @@ -68,3 +68,7 @@ &-active pseudo-border(3px, var(--c-text-brand-normal)) color: var(--c-text-neutral-heavy) + + &.small + height: 29px + font-weight: $fw.bold diff --git a/pkg/webui/console/containers/gateway-onboarding-form/gateway-provisioning-form/gateway-claim-form-section/index.js b/pkg/webui/console/containers/gateway-onboarding-form/gateway-provisioning-form/gateway-claim-form-section/index.js index bc1723e9c9..d74b312b67 100644 --- a/pkg/webui/console/containers/gateway-onboarding-form/gateway-provisioning-form/gateway-claim-form-section/index.js +++ b/pkg/webui/console/containers/gateway-onboarding-form/gateway-provisioning-form/gateway-claim-form-section/index.js @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import React, { useEffect } from 'react' +import React, { useCallback, useEffect } from 'react' import { defineMessages } from 'react-intl' import { useFormikContext } from 'formik' @@ -24,6 +24,10 @@ import Notification from '@ttn-lw/components/notification' import SubmitBar from '@ttn-lw/components/submit-bar' import FormSubmit from '@ttn-lw/components/form/submit' import SubmitButton from '@ttn-lw/components/submit-button' +import Link from '@ttn-lw/components/link' +import Tabs from '@ttn-lw/components/tabs' + +import Message from '@ttn-lw/lib/components/message' import { GsFrequencyPlansSelect as FrequencyPlansSelect } from '@console/containers/freq-plans-select' @@ -33,10 +37,12 @@ import tooltipIds from '@ttn-lw/lib/constants/tooltip-ids' import getHostFromUrl from '@ttn-lw/lib/host-from-url' const { enabled: gsEnabled, base_url: gsBaseURL } = selectGsConfig() +const smUrl = 'https://accounts.thethingsindustries.com' const m = defineMessages({ claimWarning: - 'We detected that your gateway is a Managed Gateway. To claim this gateway, please use the owner token printed on the inside of the mounting lid or scan the QR code to claim instantly.', + 'We detected a Managed gateway. To claim this gateway with a subscription, use the owner token printed on the gateway, or add it to your Gateway Fleet using your fleet owner token.', + fleet: 'Fleet', }) const initialValues = { @@ -49,10 +55,38 @@ const initialValues = { target_gateway_server_address: gsEnabled ? getHostFromUrl(gsBaseURL) : '', } +const ownerTokenTypes = [ + { name: 'gateway', title: sharedMessages.gateway }, + { name: 'fleet', title: m.fleet }, +] + const GatewayClaimFormSection = () => { - const { values, addToFieldRegistry, removeFromFieldRegistry } = useFormikContext() + const { values, addToFieldRegistry, removeFromFieldRegistry, setValues } = useFormikContext() const isManaged = values._inputMethod === 'managed' - const withQRdata = values._withQRdata + const isFleet = values._isFleet + + const [activeOwnerTokenType, setActiveOwnerTokenType] = React.useState( + isFleet ? 'fleet' : 'gateway', + ) + + const onOwnerTokenTypeChange = useCallback( + value => { + setActiveOwnerTokenType(value) + setValues(values => ({ + ...values, + authenticated_identifiers: { + ...values.authenticated_identifiers, + authentication_code: + value === 'fleet' && values._fleet_owner_token + ? btoa(values._fleet_owner_token) + : value === 'gateway' && values._gtw_owner_token + ? btoa(values._gtw_owner_token) + : '', + }, + })) + }, + [setValues], + ) // Register hidden fields so they don't get cleaned. useEffect(() => { @@ -64,28 +98,76 @@ const GatewayClaimFormSection = () => { return ( <> {isManaged && ( - - + + ( + + {val} + + ), + }} + className="mb-0" + /> + + + {txt}, - }} - className="mb-0" + className="w-content p-0 mb-cs-xs mt-cs-xxs border-none br-m gap-0 fs-s" /> - + {activeOwnerTokenType === 'fleet' && ( + ( + + {val} + + ), + }} + className="mb-cs-xs c-text-neutral-light" + component="div" + /> + )} + )} ( + + {val} + + ), + }} encode={btoa} decode={atob} - disabled={withQRdata} sensitive + data-1p-ignore + data-lpignore autoFocus /> schema.concat(registerValidationSchema), diff --git a/pkg/webui/console/containers/gateway-onboarding-form/qr-scan-section/fleets-scan.js b/pkg/webui/console/containers/gateway-onboarding-form/qr-scan-section/fleets-scan.js new file mode 100644 index 0000000000..024cc2bcff --- /dev/null +++ b/pkg/webui/console/containers/gateway-onboarding-form/qr-scan-section/fleets-scan.js @@ -0,0 +1,143 @@ +// Copyright © 2025 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 } from 'react' +import { defineMessages } from 'react-intl' + +import { IconPlus } from '@ttn-lw/components/icon' +import Link from '@ttn-lw/components/link' +import Button from '@ttn-lw/components/button' +import Input from '@ttn-lw/components/input' + +import Message from '@ttn-lw/lib/components/message' + +import sharedMessages from '@ttn-lw/lib/shared-messages' +import PropTypes from '@ttn-lw/lib/prop-types' + +const smUrl = 'https://accounts.thethingsindustries.com' + +const m = defineMessages({ + addFleet: 'Add to Fleet', + fleetToken: 'Fleet owner token', + addToFleetTooltip: + 'You are registering a Managed gateway. If you want to add it to an existing fleet, click here.', +}) + +const FleetsScan = ({ qrData, setQrData }) => { + const [isAddToFleet, setIsAddToFleet] = React.useState(false) + const [fleetOwnerToken, setFleetOwnerToken] = React.useState('') + + const handleAddToFleet = useCallback(() => { + setIsAddToFleet(true) + }, []) + + const handleRemoveFleet = useCallback(() => { + setIsAddToFleet(false) + setFleetOwnerToken('') + setQrData(prevQrData => ({ + ...prevQrData, + gateway: { ...prevQrData.gateway, _fleet_owner_token: undefined }, + })) + }, [setQrData]) + + const handleFleetTokenChange = useCallback( + value => { + setFleetOwnerToken(value) + setQrData(prevQrData => ({ + ...prevQrData, + gateway: { ...prevQrData.gateway, _fleet_owner_token: value }, + })) + }, + [setQrData], + ) + + return qrData?.gateway?.is_managed ? ( + <> + {isAddToFleet ? ( +
+
+ +
+ ( + + {val} + + ), + }} + className="mb-cs-xs mt-cs-xxs c-text-neutral-light fs-s" + component="div" + /> + + ( + + {val} + + ), + }} + className="mt-cs-xs c-text-neutral-light fs-s" + component="div" + /> +
+ ) : ( +