diff --git a/src/app/api/services/dataplaneServiceHelper.ts b/src/app/api/services/dataplaneServiceHelper.ts index d1df89b20..547c2946c 100644 --- a/src/app/api/services/dataplaneServiceHelper.ts +++ b/src/app/api/services/dataplaneServiceHelper.ts @@ -6,6 +6,7 @@ import { CONTROLLER_API_ENDPOINT, DATAPLANE, DataPlaneStatusCode, HTTP_OPERATION import { getConnectionInfoFromConnectionString, generateSasToken } from '../shared/utils'; import { PortIsInUseError } from '../models/portIsInUseError'; import { CONNECTION_STRING_NAME_LIST } from '../../constants/browserStorage'; +import { getActiveConnectionString } from '../../shared/utils/hubConnectionStringHelper'; export const DATAPLANE_CONTROLLER_ENDPOINT = `${CONTROLLER_API_ENDPOINT}${DATAPLANE}`; @@ -39,7 +40,7 @@ export const request = async (endpoint: string, parameters: any) => { // tslint: export const dataPlaneConnectionHelper = async () => { const connectionStrings = await localStorage.getItem(CONNECTION_STRING_NAME_LIST); - const connectionString = connectionStrings && connectionStrings.split(',')[0]; + const connectionString = getActiveConnectionString(connectionStrings); const connectionInfo = getConnectionInfoFromConnectionString(connectionString); if (!(connectionInfo && connectionInfo.hostName)) { return; diff --git a/src/app/connectionStrings/actions.spec.ts b/src/app/connectionStrings/actions.spec.ts index f49f41e68..fb1c9a6af 100644 --- a/src/app/connectionStrings/actions.spec.ts +++ b/src/app/connectionStrings/actions.spec.ts @@ -3,15 +3,15 @@ * Licensed under the MIT License **********************************************************/ import { - getConnectionStringAction, + getConnectionStringsAction, deleteConnectionStringAction, setConnectionStringsAction, upsertConnectionStringAction } from './actions'; -describe('getConnectionStringAction', () => { +describe('getConnectionStringsAction', () => { it('returns CONNECTION_STRINGS/DELETE action object', () => { - expect(getConnectionStringAction.started()).toEqual({ + expect(getConnectionStringsAction.started()).toEqual({ payload: undefined, type: 'CONNECTION_STRINGS/GET_STARTED' }); @@ -38,8 +38,12 @@ describe('setConnectionStringAction', () => { describe('upsertConnectionStringAction', () => { it('returns CONNECTION_STRINGS/UPSERT action object', () => { - expect(upsertConnectionStringAction.started({ newConnectionString: 'new', connectionString: 'old'})).toEqual({ - payload: { newConnectionString: 'new', connectionString: 'old'}, + const stringWithExpiry = { + connectionString: 'connectionString1', + expiration: (new Date(0)).toUTCString() + }; + expect(upsertConnectionStringAction.started(stringWithExpiry)).toEqual({ + payload: stringWithExpiry, type: 'CONNECTION_STRINGS/UPSERT_STARTED' }); }); diff --git a/src/app/connectionStrings/actions.ts b/src/app/connectionStrings/actions.ts index 2bd525a52..51b4ce19c 100644 --- a/src/app/connectionStrings/actions.ts +++ b/src/app/connectionStrings/actions.ts @@ -4,16 +4,11 @@ **********************************************************/ import actionCreatorFactory from 'typescript-fsa'; import { DELETE, SET, UPSERT, GET } from '../constants/actionTypes'; +import { ConnectionStringWithExpiry } from './state'; -export const CONNECTION_STRINGS = 'CONNECTION_STRINGS'; -export interface UpsertConnectionStringActionPayload { - newConnectionString: string; - connectionString?: string; -} +const actionCreator = actionCreatorFactory('CONNECTION_STRINGS'); -const actionCreator = actionCreatorFactory(CONNECTION_STRINGS); - -export const getConnectionStringAction = actionCreator.async(GET); -export const setConnectionStringsAction = actionCreator.async(SET); -export const upsertConnectionStringAction = actionCreator.async(UPSERT); -export const deleteConnectionStringAction = actionCreator.async(DELETE); +export const getConnectionStringsAction = actionCreator.async(GET); +export const setConnectionStringsAction = actionCreator.async(SET); +export const upsertConnectionStringAction = actionCreator.async(UPSERT); +export const deleteConnectionStringAction = actionCreator.async(DELETE); diff --git a/src/app/connectionStrings/components/__snapshots__/connectionString.spec.tsx.snap b/src/app/connectionStrings/components/__snapshots__/connectionString.spec.tsx.snap index 0ec979b0b..82b46d3ea 100644 --- a/src/app/connectionStrings/components/__snapshots__/connectionString.spec.tsx.snap +++ b/src/app/connectionStrings/components/__snapshots__/connectionString.spec.tsx.snap @@ -59,17 +59,21 @@ exports[`connectionString matches snapshot 1`] = ` readOnly={true} value="HostName=test.azure-devices-int.net;SharedAccessKeyName=iothubowner;SharedAccessKey=key" /> - + connectionStrings.expirationWarning + + connectionStrings.visitConnectionCommand.label - + -
- -
-
-
-
-
-`; - exports[`ConnectionStringsView matches snapshot when connection strings present 1`] = `
{ const connectionString = 'HostName=test.azure-devices-int.net;SharedAccessKeyName=iothubowner;SharedAccessKey=key'; + const connectionStringWithExpiry = {connectionString, expiration: (new Date(0)).toUTCString()}; it('matches snapshot', () => { const props: ConnectionStringProps = { - connectionString, + connectionStringWithExpiry, onDeleteConnectionString: jest.fn(), onEditConnectionString: jest.fn(), onSelectConnectionString: jest.fn() @@ -26,7 +27,7 @@ describe('connectionString', () => { it('calls onSelectConnectionString when link clicked', () => { const onSelectConnectionString = jest.fn(); const props: ConnectionStringProps = { - connectionString, + connectionStringWithExpiry, onDeleteConnectionString: jest.fn(), onEditConnectionString: jest.fn(), onSelectConnectionString @@ -41,7 +42,7 @@ describe('connectionString', () => { it('calls onSelectConnectionString when convenience link clicked', () => { const onSelectConnectionString = jest.fn(); const props: ConnectionStringProps = { - connectionString, + connectionStringWithExpiry, onDeleteConnectionString: jest.fn(), onEditConnectionString: jest.fn(), onSelectConnectionString @@ -56,7 +57,7 @@ describe('connectionString', () => { it('calls onEditConnectionString when edit button clicked', () => { const onEditConnectionString = jest.fn(); const props: ConnectionStringProps = { - connectionString, + connectionStringWithExpiry, onDeleteConnectionString: jest.fn(), onEditConnectionString, onSelectConnectionString: jest.fn() @@ -71,7 +72,7 @@ describe('connectionString', () => { describe('delete scenario', () => { it('launches delete confirmation when delete clicked', () => { const props: ConnectionStringProps = { - connectionString, + connectionStringWithExpiry, onDeleteConnectionString: jest.fn(), onEditConnectionString: jest.fn(), onSelectConnectionString: jest.fn() @@ -88,7 +89,7 @@ describe('connectionString', () => { it('calls onDeleteConnectionString when ConnectionStringDelete confirmed', () => { const onDeleteConnectionString = jest.fn(); const props: ConnectionStringProps = { - connectionString, + connectionStringWithExpiry, onDeleteConnectionString, onEditConnectionString: jest.fn(), onSelectConnectionString: jest.fn() @@ -106,7 +107,7 @@ describe('connectionString', () => { it('hides delete confirmation when ConnnectionStringDelete canceled', () => { const props: ConnectionStringProps = { - connectionString, + connectionStringWithExpiry, onDeleteConnectionString: jest.fn(), onEditConnectionString: jest.fn(), onSelectConnectionString: jest.fn() diff --git a/src/app/connectionStrings/components/connectionString.tsx b/src/app/connectionStrings/components/connectionString.tsx index b4de7709d..30a07c319 100644 --- a/src/app/connectionStrings/components/connectionString.tsx +++ b/src/app/connectionStrings/components/connectionString.tsx @@ -4,7 +4,7 @@ **********************************************************/ import * as React from 'react'; import { useTranslation } from 'react-i18next'; -import { IconButton } from 'office-ui-fabric-react/lib/components/Button'; +import { IconButton, ActionButton } from 'office-ui-fabric-react/lib/components/Button'; import { Link } from 'office-ui-fabric-react/lib/components/Link'; import { getConnectionInfoFromConnectionString } from '../../api/shared/utils'; import { getResourceNameFromHostName } from '../../api/shared/hostNameUtils'; @@ -13,17 +13,22 @@ import { ResourceKeys } from '../../../localization/resourceKeys'; import { ConnectionStringDelete } from './connectionStringDelete'; import { MaskedCopyableTextField } from '../../shared/components/maskedCopyableTextField'; import { EDIT, REMOVE } from '../../constants/iconNames'; +import { ConnectionStringWithExpiry } from '../state'; +import { CONNECTION_STRING_EXPIRATION_WARNING_IN_DAYS } from '../../constants/browserStorage'; +import { getDaysBeforeHubConnectionStringExpires } from '../../shared/utils/hubConnectionStringHelper'; import './connectionString.scss'; export interface ConnectionStringProps { - connectionString: string; + connectionStringWithExpiry: ConnectionStringWithExpiry; onEditConnectionString(connectionString: string): void; onDeleteConnectionString(connectionString: string): void; onSelectConnectionString(connectionString: string): void; } export const ConnectionString: React.FC = props => { - const { connectionString, onEditConnectionString, onDeleteConnectionString, onSelectConnectionString } = props; + const { connectionStringWithExpiry, onEditConnectionString, onDeleteConnectionString, onSelectConnectionString } = props; + const connectionString = connectionStringWithExpiry.connectionString; + const daysTillExpire = getDaysBeforeHubConnectionStringExpires(connectionStringWithExpiry); const connectionSettings = getConnectionInfoFromConnectionString(connectionString); const { hostName, sharedAccessKey, sharedAccessKeyName } = connectionSettings; const resourceName = getResourceNameFromHostName(hostName); @@ -97,13 +102,16 @@ export const ConnectionString: React.FC = props => { value={connectionString} readOnly={true} /> - + {t(ResourceKeys.connectionStrings.expirationWarning, { numberOfDays: daysTillExpire})} +
} + {t(ResourceKeys.connectionStrings.visitConnectionCommand.label)} - + { it('disables commit when duplicate validation', () => { const props: ConnectionStringEditViewProps = { connectionStringUnderEdit: '', - connectionStrings: [connectionString], + connectionStrings: [{connectionString, expiration: (new Date(0)).toUTCString()}], onCommit: jest.fn(), onDismiss: jest.fn() }; diff --git a/src/app/connectionStrings/components/connectionStringEditView.tsx b/src/app/connectionStrings/components/connectionStringEditView.tsx index 22a03cf4c..74d2120f6 100644 --- a/src/app/connectionStrings/components/connectionStringEditView.tsx +++ b/src/app/connectionStrings/components/connectionStringEditView.tsx @@ -13,13 +13,14 @@ import { getConnectionInfoFromConnectionString } from '../../api/shared/utils'; import { generateConnectionStringValidationError } from '../../shared/utils/hubConnectionStringHelper'; import { IoTHubConnectionSettings } from '../../api/services/devicesService'; import { ResourceKeys } from '../../../localization/resourceKeys'; +import { ConnectionStringWithExpiry } from '../state'; import './connectionStringEditView.scss'; const LINES_FOR_CONNECTION = 8; export interface ConnectionStringEditViewProps { - connectionStringUnderEdit?: string; - connectionStrings: string[]; + connectionStringUnderEdit: string; + connectionStrings: ConnectionStringWithExpiry[]; onDismiss(): void; onCommit(newConnectionString: string): void; } @@ -59,9 +60,10 @@ export const ConnectionStringEditView: React.FC = } // check for duplicates and validate || after setting values (so properties display) - validationKey = (connectionStrings.indexOf(updatedConnectionString) >= 0 && updatedConnectionString !== connectionStringUnderEdit) ? - ResourceKeys.connectionStrings.editConnection.validations.duplicate : - validationKey; + validationKey = (connectionStrings.filter(s => s.connectionString === updatedConnectionString).length > 0 && + updatedConnectionString !== connectionStringUnderEdit) ? + ResourceKeys.connectionStrings.editConnection.validations.duplicate : + validationKey; setConnectionStringValidationKey(validationKey); }; diff --git a/src/app/connectionStrings/components/connectionStringProperties.tsx b/src/app/connectionStrings/components/connectionStringProperties.tsx index b6594a884..698ff1943 100644 --- a/src/app/connectionStrings/components/connectionStringProperties.tsx +++ b/src/app/connectionStrings/components/connectionStringProperties.tsx @@ -42,7 +42,6 @@ export const ConnectionStringProperties: React.FC ); diff --git a/src/app/connectionStrings/components/connectionStringsView.spec.tsx b/src/app/connectionStrings/components/connectionStringsView.spec.tsx index c67fe9fe3..39b569c24 100644 --- a/src/app/connectionStrings/components/connectionStringsView.spec.tsx +++ b/src/app/connectionStrings/components/connectionStringsView.spec.tsx @@ -9,16 +9,18 @@ import { CommandBar } from 'office-ui-fabric-react/lib/components/CommandBar'; import { ConnectionStringsView } from './connectionStringsView'; import { ConnectionString } from './connectionString'; import { ConnectionStringEditView } from './connectionStringEditView'; -import { CONNECTION_STRING_LIST_MAX_LENGTH } from '../../constants/browserStorage'; import * as AsyncSagaReducer from '../../shared/hooks/useAsyncSagaReducer'; import { connectionStringsStateInitial } from '../state'; -import { upsertConnectionStringAction } from '../actions'; +import { deleteConnectionStringAction, upsertConnectionStringAction } from '../actions'; +import * as HubConnectionStringHelper from '../../shared/utils/hubConnectionStringHelper'; jest.mock('react-router-dom', () => ({ useHistory: () => ({ push: jest.fn() }) })); describe('ConnectionStringsView', () => { + const connectionStringWithExpiry = {connectionString: 'connectionString1', expiration: (new Date(0)).toUTCString()}; + it('matches snapshot when no connection strings', () => { jest.spyOn(AsyncSagaReducer, 'useAsyncSagaReducer').mockReturnValue([connectionStringsStateInitial(), jest.fn()]); const wrapper = shallow(); @@ -26,7 +28,7 @@ describe('ConnectionStringsView', () => { }); it('matches snapshot when connection strings present', () => { - const state = connectionStringsStateInitial().merge({ payload: ['connectionString1']}); + const state = connectionStringsStateInitial().merge({ payload: [connectionStringWithExpiry]}); jest.spyOn(AsyncSagaReducer, 'useAsyncSagaReducer').mockReturnValue([state, jest.fn()]); const wrapper = shallow(); @@ -34,15 +36,6 @@ describe('ConnectionStringsView', () => { }); - it('matches snapshot when connection string count exceeds max', () => { - const connectionStrings = new Array(CONNECTION_STRING_LIST_MAX_LENGTH + 1).map((s, i) => `connectionString${i}`); - const state = connectionStringsStateInitial().merge({ payload: connectionStrings }); - jest.spyOn(AsyncSagaReducer, 'useAsyncSagaReducer').mockReturnValue([state, jest.fn()]); - - const wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); - }); - describe('add scenario', () => { it('mounts edit view when add command clicked', () => { const state = connectionStringsStateInitial().merge({ payload: []}); @@ -67,6 +60,8 @@ describe('ConnectionStringsView', () => { const upsertConnectionStringActionSpy = jest.spyOn(upsertConnectionStringAction, 'started'); const state = connectionStringsStateInitial().merge({ payload: []}); jest.spyOn(AsyncSagaReducer, 'useAsyncSagaReducer').mockReturnValue([state, jest.fn()]); + jest.spyOn(HubConnectionStringHelper, 'getExpiryDateInUtcString').mockReturnValue((new Date(0)).toUTCString()); + const wrapper = shallow(); act(() => { wrapper.find(CommandBar).props().items[0].onClick(undefined); @@ -78,7 +73,7 @@ describe('ConnectionStringsView', () => { act(() => connectionStringEditView.props().onCommit('newConnectionString')); wrapper.update(); - expect(upsertConnectionStringActionSpy).toHaveBeenCalledWith({ newConnectionString: 'newConnectionString', connectionString: '' }); + expect(upsertConnectionStringActionSpy).toHaveBeenCalledWith({ connectionString: 'newConnectionString', expiration: (new Date(0)).toUTCString() }); expect(wrapper.find(ConnectionStringEditView).length).toEqual(0); }); }); @@ -86,7 +81,7 @@ describe('ConnectionStringsView', () => { describe('edit scenario', () => { const connectionString = 'HostName=test.azure-devices-int.net;SharedAccessKeyName=iothubowner;SharedAccessKey=key'; it('mounts edit view when add command clicked', () => { - const state = connectionStringsStateInitial().merge({ payload: [connectionString] }); + const state = connectionStringsStateInitial().merge({ payload: [connectionStringWithExpiry] }); jest.spyOn(AsyncSagaReducer, 'useAsyncSagaReducer').mockReturnValue([state, jest.fn()]); const wrapper = mount(); @@ -98,8 +93,10 @@ describe('ConnectionStringsView', () => { it('upserts when edit view applied', () => { const upsertConnectionStringActionSpy = jest.spyOn(upsertConnectionStringAction, 'started'); - const state = connectionStringsStateInitial().merge({ payload: [connectionString] }); + const deleteConnectionStringActionSpy = jest.spyOn(deleteConnectionStringAction, 'started'); + const state = connectionStringsStateInitial().merge({ payload: [connectionStringWithExpiry] }); jest.spyOn(AsyncSagaReducer, 'useAsyncSagaReducer').mockReturnValue([state, jest.fn()]); + jest.spyOn(HubConnectionStringHelper, 'getExpiryDateInUtcString').mockReturnValue((new Date(0)).toUTCString()); const wrapper = mount(); act(() => wrapper.find(ConnectionString).first().props().onEditConnectionString(connectionString)); @@ -109,7 +106,8 @@ describe('ConnectionStringsView', () => { act(() => connectionStringEditView.props().onCommit('newConnectionString')); wrapper.update(); - expect(upsertConnectionStringActionSpy).toHaveBeenCalledWith({ newConnectionString: 'newConnectionString', connectionString }); + expect(deleteConnectionStringActionSpy).toHaveBeenCalledWith(connectionString); + expect(upsertConnectionStringActionSpy).toHaveBeenCalledWith({ connectionString: 'newConnectionString', expiration: (new Date(0)).toUTCString() }); expect(wrapper.find(ConnectionStringEditView).length).toEqual(0); }); }); diff --git a/src/app/connectionStrings/components/connectionStringsView.tsx b/src/app/connectionStrings/components/connectionStringsView.tsx index ccb335321..80f01b7c6 100644 --- a/src/app/connectionStrings/components/connectionStringsView.tsx +++ b/src/app/connectionStrings/components/connectionStringsView.tsx @@ -12,18 +12,18 @@ import { ResourceKeys } from '../../../localization/resourceKeys'; import { CONNECTION_STRING_LIST_MAX_LENGTH } from '../../constants/browserStorage'; import { upsertConnectionStringAction, deleteConnectionStringAction, setConnectionStringsAction } from '../actions'; import { ROUTE_PARTS } from '../../constants/routes'; -import { formatConnectionStrings } from '../../shared/utils/hubConnectionStringHelper'; +import { formatConnectionStrings, getExpiryDateInUtcString } from '../../shared/utils/hubConnectionStringHelper'; import { ConnectionStringsEmpty } from './connectionStringsEmpty'; import { useAsyncSagaReducer } from '../../shared/hooks/useAsyncSagaReducer'; import { connectionStringsReducer } from '../reducer'; import { connectionStringsSaga } from '../sagas'; -import { connectionStringsStateInitial } from '../state'; +import { connectionStringsStateInitial, ConnectionStringWithExpiry } from '../state'; import { SynchronizationStatus } from '../../api/models/synchronizationStatus'; import { MultiLineShimmer } from '../../shared/components/multiLineShimmer'; import { getConnectionInfoFromConnectionString } from '../../api/shared/utils'; +import { getConnectionStringsAction } from './../actions'; import '../../css/_layouts.scss'; import './connectionStringsView.scss'; -import { getConnectionStringAction } from './../actions'; // tslint:disable-next-line: cyclomatic-complexity export const ConnectionStringsView: React.FC = () => { @@ -33,11 +33,20 @@ export const ConnectionStringsView: React.FC = () => { const [ localState, dispatch ] = useAsyncSagaReducer(connectionStringsReducer, connectionStringsSaga, connectionStringsStateInitial(), 'connectionStringsState'); const [ connectionStringUnderEdit, setConnectionStringUnderEdit ] = React.useState(undefined); - const connectionStrings = localState.payload; + const connectionStringsWithExpiry = localState.payload; const synchronizationStatus = localState.synchronizationStatus; - const onUpsertConnectionString = (newConnectionString: string, connectionString?: string) => { - dispatch(upsertConnectionStringAction.started({newConnectionString, connectionString})); + const onUpsertConnectionString = (newConnectionString: string, connectionString: string) => { + if (newConnectionString !== connectionString) { + // replacing a connection string with new connection string + dispatch(deleteConnectionStringAction.started(connectionString)); + } + + const stringWithExpiry: ConnectionStringWithExpiry = { + connectionString: newConnectionString, + expiration: getExpiryDateInUtcString() + }; + dispatch(upsertConnectionStringAction.started(stringWithExpiry)); }; const onDeleteConnectionString = (connectionString: string) => { @@ -45,7 +54,7 @@ export const ConnectionStringsView: React.FC = () => { }; const onSelectConnectionString = (connectionString: string) => { - const updatedConnectionStrings = formatConnectionStrings(connectionStrings, connectionString); + const updatedConnectionStrings = formatConnectionStrings(connectionStringsWithExpiry, connectionString); dispatch(setConnectionStringsAction.started(updatedConnectionStrings)); }; @@ -67,12 +76,12 @@ export const ConnectionStringsView: React.FC = () => { }; React.useEffect(() => { - dispatch(getConnectionStringAction.started()); + dispatch(getConnectionStringsAction.started()); }, []); React.useEffect(() => { if (synchronizationStatus === SynchronizationStatus.upserted) { // only when connection string updated successfully would navigate to device list view - const hostName = getConnectionInfoFromConnectionString(connectionStrings[0]).hostName; + const hostName = getConnectionInfoFromConnectionString(connectionStringsWithExpiry[0] && connectionStringsWithExpiry[0].connectionString).hostName; history.push(`/${ROUTE_PARTS.RESOURCE}/${hostName}/${ROUTE_PARTS.DEVICES}`); } }, [synchronizationStatus]); @@ -90,7 +99,7 @@ export const ConnectionStringsView: React.FC = () => { items={[ { ariaLabel: t(ResourceKeys.connectionStrings.addConnectionCommand.ariaLabel), - disabled: connectionStrings.length >= CONNECTION_STRING_LIST_MAX_LENGTH, + disabled: connectionStringsWithExpiry.length >= CONNECTION_STRING_LIST_MAX_LENGTH, iconProps: { iconName: 'Add' }, key: 'add', onClick: onAddConnectionStringClick, @@ -101,10 +110,10 @@ export const ConnectionStringsView: React.FC = () => {
- {connectionStrings.map(connectionString => + {connectionStringsWithExpiry.map(connectionStringWithExpiry => { )}
- {(!connectionStrings || connectionStrings.length === 0) && + {(!connectionStringsWithExpiry || connectionStringsWithExpiry.length === 0) && } @@ -120,7 +129,7 @@ export const ConnectionStringsView: React.FC = () => { {connectionStringUnderEdit !== undefined && diff --git a/src/app/connectionStrings/reducer.spec.ts b/src/app/connectionStrings/reducer.spec.ts index 9660c074f..6d31ef391 100644 --- a/src/app/connectionStrings/reducer.spec.ts +++ b/src/app/connectionStrings/reducer.spec.ts @@ -3,28 +3,32 @@ * Licensed under the MIT License **********************************************************/ import { ConnectionStringsStateInterface, connectionStringsStateInitial } from './state'; -import { getConnectionStringAction, deleteConnectionStringAction, setConnectionStringsAction, upsertConnectionStringAction } from './actions'; +import { getConnectionStringsAction, deleteConnectionStringAction, setConnectionStringsAction, upsertConnectionStringAction } from './actions'; import { connectionStringsReducer } from './reducer'; import { SynchronizationStatus } from '../api/models/synchronizationStatus'; import { SET, UPSERT, DELETE, GET } from '../constants/actionTypes'; -describe('getConnectionStringAction', () => { +const stringWithExpiry = { + connectionString: 'connectionString1', + expiration: (new Date()).toUTCString() +}; +describe('getConnectionStringsAction', () => { it (`handles ${GET}/ACTION_START action`, () => { - const action = getConnectionStringAction.started(); + const action = getConnectionStringsAction.started(); expect(connectionStringsReducer(connectionStringsStateInitial(), action).synchronizationStatus).toEqual(SynchronizationStatus.working); }); it (`handles ${GET}/ACTION_DONE action`, () => { - const action = getConnectionStringAction.done({ result: ['connectionString1'] }); + const action = getConnectionStringsAction.done({ result: [stringWithExpiry] }); expect(connectionStringsReducer(connectionStringsStateInitial(), action).synchronizationStatus).toEqual(SynchronizationStatus.fetched); - expect(connectionStringsReducer(connectionStringsStateInitial(), action).payload).toEqual(['connectionString1']); + expect(connectionStringsReducer(connectionStringsStateInitial(), action).payload).toEqual([stringWithExpiry]); }); }); describe('deleteConnectionStringAction', () => { const initialState = connectionStringsStateInitial().merge({ - payload: ['connectionString1'] + payload: [stringWithExpiry] }); it (`handles ${DELETE}/ACTION_START action`, () => { @@ -41,32 +45,60 @@ describe('deleteConnectionStringAction', () => { describe('setConnectionStringsAction', () => { const initialState = connectionStringsStateInitial().merge({ - payload: ['connectionString1', 'connectionString2'] + payload: [{ + connectionString: 'connectionString1', + expiration: (new Date()).toUTCString() + }, + { + connectionString: 'connectionString2', + expiration: (new Date()).toUTCString() + }] }); + const newStringWithExpiry = { + connectionString: 'connectionString3', + expiration: (new Date()).toUTCString() + }; + it (`handles ${SET}/ACTION_START action`, () => { - const action = setConnectionStringsAction.started(['connectionString3']); + const action = setConnectionStringsAction.started([newStringWithExpiry]); expect(connectionStringsReducer(initialState, action).synchronizationStatus).toEqual(SynchronizationStatus.updating); }); it (`handles ${SET}/ACTION_DONE action`, () => { - const action = setConnectionStringsAction.done({ params: ['connectionString3'], result: ['connectionString3'] }); + const action = setConnectionStringsAction.done({ params: [newStringWithExpiry], result: [newStringWithExpiry] }); expect(connectionStringsReducer(initialState, action).synchronizationStatus).toEqual(SynchronizationStatus.upserted); }); }); describe('upsertConnectionStringAction', () => { + const initialPayload = [{ + connectionString: 'connectionString1', + expiration: (new Date()).toUTCString() + }, + { + connectionString: 'connectionString2', + expiration: (new Date()).toUTCString() + }]; const initialState = connectionStringsStateInitial().merge({ - payload: ['connectionString1', 'connectionString2'] + payload: initialPayload }); + const newStringWithExpiry = { + connectionString: 'connectionString3', + expiration: (new Date()).toUTCString() + }; + it (`handles ${UPSERT}/ACTION_START action`, () => { - const action = upsertConnectionStringAction.started({newConnectionString: 'connectionString3'}); + const action = upsertConnectionStringAction.started(newStringWithExpiry); expect(connectionStringsReducer(initialState, action).synchronizationStatus).toEqual(SynchronizationStatus.updating); }); it (`handles ${UPSERT}/ACTION_DONE action`, () => { - const action = upsertConnectionStringAction.done({ params: {newConnectionString: 'connectionString3'}, result: ['connectionString1', 'connectionString2', 'connectionString3'] }); + const action = upsertConnectionStringAction.done({ params: newStringWithExpiry, result: [ + newStringWithExpiry, + ...initialPayload] + }); expect(connectionStringsReducer(initialState, action).synchronizationStatus).toEqual(SynchronizationStatus.upserted); }); }); diff --git a/src/app/connectionStrings/reducer.ts b/src/app/connectionStrings/reducer.ts index 4caae6bcf..41ee3fc0d 100644 --- a/src/app/connectionStrings/reducer.ts +++ b/src/app/connectionStrings/reducer.ts @@ -3,18 +3,18 @@ * Licensed under the MIT License **********************************************************/ import { reducerWithInitialState } from 'typescript-fsa-reducers'; -import { deleteConnectionStringAction, setConnectionStringsAction, upsertConnectionStringAction, UpsertConnectionStringActionPayload, getConnectionStringAction } from './actions'; -import { connectionStringsStateInitial, ConnectionStringsStateType } from './state'; +import { deleteConnectionStringAction, setConnectionStringsAction, upsertConnectionStringAction, getConnectionStringsAction } from './actions'; +import { connectionStringsStateInitial, ConnectionStringsStateType, ConnectionStringWithExpiry } from './state'; import { SynchronizationStatus } from '../api/models/synchronizationStatus'; export const connectionStringsReducer = reducerWithInitialState(connectionStringsStateInitial()) - .case(getConnectionStringAction.started, (state: ConnectionStringsStateType) => { + .case(getConnectionStringsAction.started, (state: ConnectionStringsStateType) => { return state.merge({ synchronizationStatus: SynchronizationStatus.working }); }) - .case(getConnectionStringAction.done, (state: ConnectionStringsStateType, payload: {params: void, result: string[]}) => { + .case(getConnectionStringsAction.done, (state: ConnectionStringsStateType, payload: {params: void, result: ConnectionStringWithExpiry[]}) => { return state.merge({ payload: payload.result, synchronizationStatus: SynchronizationStatus.fetched @@ -27,7 +27,7 @@ export const connectionStringsReducer = reducerWithInitialState { + .case(deleteConnectionStringAction.done, (state: ConnectionStringsStateType, payload: {params: string, result: ConnectionStringWithExpiry[]}) => { return state.merge({ payload: payload.result, synchronizationStatus: SynchronizationStatus.deleted @@ -40,7 +40,7 @@ export const connectionStringsReducer = reducerWithInitialState { + .case(setConnectionStringsAction.done, (state: ConnectionStringsStateType, payload: {params: ConnectionStringWithExpiry[], result: ConnectionStringWithExpiry[]}) => { return state.merge({ payload: payload.result, synchronizationStatus: SynchronizationStatus.upserted @@ -53,7 +53,7 @@ export const connectionStringsReducer = reducerWithInitialState { + .case(upsertConnectionStringAction.done, (state: ConnectionStringsStateType, payload: {params: ConnectionStringWithExpiry, result: ConnectionStringWithExpiry[]}) => { return state.merge({ payload: payload.result, synchronizationStatus: SynchronizationStatus.upserted diff --git a/src/app/connectionStrings/sagas.spec.ts b/src/app/connectionStrings/sagas.spec.ts index a6c1b19a4..91764af2f 100644 --- a/src/app/connectionStrings/sagas.spec.ts +++ b/src/app/connectionStrings/sagas.spec.ts @@ -4,7 +4,7 @@ **********************************************************/ import { takeEvery, takeLatest, all } from 'redux-saga/effects'; import { connectionStringsSaga } from './sagas'; -import { deleteConnectionStringAction, setConnectionStringsAction, upsertConnectionStringAction, getConnectionStringAction } from './actions'; +import { deleteConnectionStringAction, setConnectionStringsAction, upsertConnectionStringAction, getConnectionStringsAction } from './actions'; import { deleteConnectionStringSaga } from './sagas/deleteConnectionStringSaga'; import { setConnectionStringsSaga } from './sagas/setConnectionStringsSaga'; import { upsertConnectionStringSaga } from './sagas/upsertConnectionStringSaga'; @@ -13,7 +13,7 @@ import { getConnectionStringsSaga } from './sagas/getConnectionStringsSaga'; describe('connectionStrings/saga/rootSaga', () => { it('returns specified sagas', () => { expect(connectionStringsSaga().next().value).toEqual(all([ - takeLatest(getConnectionStringAction.started.type, getConnectionStringsSaga), + takeLatest(getConnectionStringsAction.started.type, getConnectionStringsSaga), takeEvery(deleteConnectionStringAction.started.type, deleteConnectionStringSaga), takeLatest(setConnectionStringsAction.started.type, setConnectionStringsSaga), takeEvery(upsertConnectionStringAction.started.type, upsertConnectionStringSaga) diff --git a/src/app/connectionStrings/sagas.ts b/src/app/connectionStrings/sagas.ts index bd32e805a..4ca15cc84 100644 --- a/src/app/connectionStrings/sagas.ts +++ b/src/app/connectionStrings/sagas.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License **********************************************************/ import { takeEvery, takeLatest, all } from 'redux-saga/effects'; -import { getConnectionStringAction, deleteConnectionStringAction, setConnectionStringsAction, upsertConnectionStringAction } from './actions'; +import { getConnectionStringsAction, deleteConnectionStringAction, setConnectionStringsAction, upsertConnectionStringAction } from './actions'; import { getConnectionStringsSaga } from './sagas/getConnectionStringsSaga'; import { deleteConnectionStringSaga } from './sagas/deleteConnectionStringSaga'; import { setConnectionStringsSaga } from './sagas/setConnectionStringsSaga'; @@ -11,7 +11,7 @@ import { upsertConnectionStringSaga } from './sagas/upsertConnectionStringSaga'; export function* connectionStringsSaga() { yield all([ - takeLatest(getConnectionStringAction.started.type, getConnectionStringsSaga), + takeLatest(getConnectionStringsAction.started.type, getConnectionStringsSaga), takeEvery(deleteConnectionStringAction.started.type, deleteConnectionStringSaga), takeLatest(setConnectionStringsAction.started.type, setConnectionStringsSaga), takeEvery(upsertConnectionStringAction.started.type, upsertConnectionStringSaga) diff --git a/src/app/connectionStrings/sagas/deleteConnectionStringSaga.spec.ts b/src/app/connectionStrings/sagas/deleteConnectionStringSaga.spec.ts index 764fb9ebb..afdbca24e 100644 --- a/src/app/connectionStrings/sagas/deleteConnectionStringSaga.spec.ts +++ b/src/app/connectionStrings/sagas/deleteConnectionStringSaga.spec.ts @@ -11,6 +11,11 @@ import { setConnectionStrings } from './setConnectionStringsSaga'; import { getConnectionStrings } from './getConnectionStringsSaga'; describe('deleteConnectionStringSaga', () => { + const savedStringsWithExpiry = [{ + connectionString: 'connectionString1', + expiration: (new Date()).toUTCString() + }]; + describe('removing unlisted connection string', () => { const deleteConnectionStringSagaGenerator = cloneableGenerator(deleteConnectionStringSaga)(deleteConnectionStringAction.started('connectionString2')); it('returns call effect to get connection strings', () => { @@ -21,16 +26,16 @@ describe('deleteConnectionStringSaga', () => { }); it('returns call effect to set connection strings', () => { - expect(deleteConnectionStringSagaGenerator.next(['connectionString1'])).toEqual({ + expect(deleteConnectionStringSagaGenerator.next(savedStringsWithExpiry)).toEqual({ done: false, - value: call(setConnectionStrings, 'connectionString1') + value: call(setConnectionStrings, savedStringsWithExpiry) }); }); it('puts the done action', () => { - expect(deleteConnectionStringSagaGenerator.next(['connectionString1'])).toEqual({ + expect(deleteConnectionStringSagaGenerator.next([savedStringsWithExpiry])).toEqual({ done: false, - value: put(deleteConnectionStringAction.done({params: 'connectionString2', result: ['connectionString1']})) + value: put(deleteConnectionStringAction.done({params: 'connectionString2', result: savedStringsWithExpiry})) }); expect(deleteConnectionStringSagaGenerator.next().done).toEqual(true); @@ -47,9 +52,9 @@ describe('deleteConnectionStringSaga', () => { }); it('returns call effect to set connection strings', () => { - expect(deleteConnectionStringSagaGenerator.next(['connectionString1'])).toEqual({ + expect(deleteConnectionStringSagaGenerator.next(savedStringsWithExpiry)).toEqual({ done: false, - value: call(setConnectionStrings, '') + value: call(setConnectionStrings, []) }); }); diff --git a/src/app/connectionStrings/sagas/deleteConnectionStringSaga.ts b/src/app/connectionStrings/sagas/deleteConnectionStringSaga.ts index 233133f0f..3a7b69c4e 100644 --- a/src/app/connectionStrings/sagas/deleteConnectionStringSaga.ts +++ b/src/app/connectionStrings/sagas/deleteConnectionStringSaga.ts @@ -7,14 +7,14 @@ import { Action } from 'typescript-fsa'; import { setConnectionStrings } from './setConnectionStringsSaga'; import { getConnectionStrings } from './getConnectionStringsSaga'; import { deleteConnectionStringAction } from '../actions'; +import { ConnectionStringWithExpiry } from '../state'; export function* deleteConnectionStringSaga(action: Action) { - const savedStrings: string[] = yield call(getConnectionStrings); + const savedStrings: ConnectionStringWithExpiry[] = yield call(getConnectionStrings); if (savedStrings && savedStrings.length > 0) { - const nonDuplicateStrings = savedStrings.filter(name => name !== action.payload); // remove duplicates - const updatedStrings = nonDuplicateStrings.filter(s => s !== action.payload); - yield call(setConnectionStrings, updatedStrings.join(',')); + const updatedStrings = savedStrings.filter(s => s.connectionString !== action.payload); + yield call(setConnectionStrings, updatedStrings); yield put(deleteConnectionStringAction.done({params: action.payload, result: updatedStrings})); } else { diff --git a/src/app/connectionStrings/sagas/getConnectionStringsSaga.spec.ts b/src/app/connectionStrings/sagas/getConnectionStringsSaga.spec.ts index 0b0cdb0dd..1367078f8 100644 --- a/src/app/connectionStrings/sagas/getConnectionStringsSaga.spec.ts +++ b/src/app/connectionStrings/sagas/getConnectionStringsSaga.spec.ts @@ -2,13 +2,78 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License **********************************************************/ +import { call, put } from 'redux-saga/effects'; +// tslint:disable-next-line: no-implicit-dependencies +import { cloneableGenerator } from '@redux-saga/testing-utils'; import { CONNECTION_STRING_NAME_LIST } from '../../constants/browserStorage'; -import { getConnectionStrings } from './getConnectionStringsSaga'; +import { getConnectionStringsAction } from '../actions'; +import { getConnectionStrings, getConnectionStringsSaga } from './getConnectionStringsSaga'; +import { raiseNotificationToast } from '../../notifications/components/notificationToast'; +import { ResourceKeys } from '../../../localization/resourceKeys'; +import { NotificationType } from '../../api/models/notification'; +import { setConnectionStrings } from './setConnectionStringsSaga'; + +describe('getConnectionStringsSaga', () => { + const getConnectionStringsSagaGenerator = cloneableGenerator(getConnectionStringsSaga)(); + it('returns call effect to get connection strings', () => { + expect(getConnectionStringsSagaGenerator.next()).toEqual({ + done: false, + value: call(getConnectionStrings) + }); + }); + + it('puts the done action', () => { + const stringsWithExpiry = [{ + connectionString: 'connectionString1', + expiration: (new Date()).toUTCString() + }]; + expect(getConnectionStringsSagaGenerator.next(stringsWithExpiry)).toEqual({ + done: false, + value: put(getConnectionStringsAction.done({result: stringsWithExpiry})) + }); + + expect(getConnectionStringsSagaGenerator.next().done).toEqual(true); + }); +}); describe('getConnectionString', () => { - it('returns expected value', () => { - const setValue = 'helloworld'; - localStorage.setItem(CONNECTION_STRING_NAME_LIST, setValue); - expect(getConnectionStrings()).toEqual([setValue]); + it('notifies users and removed strings with no expiry', () => { + const getConnectionStringsGenerator = cloneableGenerator(getConnectionStrings)(); + localStorage.setItem(CONNECTION_STRING_NAME_LIST, 'hellowWorld'); + expect(getConnectionStringsGenerator.next('hellowWorld')).toEqual({ + done: false, + value: call(raiseNotificationToast, { + text: { + translationKey: ResourceKeys.notifications.connectionStringsWithoutExpiryRemovalWarning + }, + type: NotificationType.warning + }) + }); + + getConnectionStringsGenerator.next(); + expect(localStorage.getItem(CONNECTION_STRING_NAME_LIST)).toEqual(''); + }); + + it('notifies users and removed strings expired', () => { + const getConnectionStringsGenerator = cloneableGenerator(getConnectionStrings)(); + const stringsWithExpiry = [{ + connectionString: 'connectionString1', + expiration: (new Date(0)).toUTCString() + }]; + localStorage.setItem(CONNECTION_STRING_NAME_LIST, JSON.stringify(stringsWithExpiry)); + expect(getConnectionStringsGenerator.next(JSON.stringify(stringsWithExpiry))).toEqual({ + done: false, + value: call(raiseNotificationToast, { + text: { + translationKey: ResourceKeys.notifications.connectionStringsWithExpiryRemovalWarning + }, + type: NotificationType.warning + }) + }); + + expect(getConnectionStringsGenerator.next(JSON.stringify(stringsWithExpiry))).toEqual({ + done: false, + value: call(setConnectionStrings, []) + }); }); }); diff --git a/src/app/connectionStrings/sagas/getConnectionStringsSaga.ts b/src/app/connectionStrings/sagas/getConnectionStringsSaga.ts index fc0f9bc8b..b93d71c91 100644 --- a/src/app/connectionStrings/sagas/getConnectionStringsSaga.ts +++ b/src/app/connectionStrings/sagas/getConnectionStringsSaga.ts @@ -3,15 +3,50 @@ * Licensed under the MIT License **********************************************************/ import { call, put } from 'redux-saga/effects'; +import { NotificationType } from '../../api/models/notification'; +import { raiseNotificationToast } from '../../notifications/components/notificationToast'; import { CONNECTION_STRING_NAME_LIST } from '../../constants/browserStorage'; -import { getConnectionStringAction } from './../actions'; +import { getConnectionStringsAction } from './../actions'; +import { ResourceKeys } from '../../../localization/resourceKeys'; +import { ConnectionStringWithExpiry } from '../state'; +import { setConnectionStrings } from './setConnectionStringsSaga'; export function* getConnectionStringsSaga() { const connectionStrings = yield call(getConnectionStrings); - yield put(getConnectionStringAction.done({result: connectionStrings})); + yield put(getConnectionStringsAction.done({result: connectionStrings})); } -export const getConnectionStrings = (): string[] => { +// tslint:disable-next-line: cyclomatic-complexity +export function* getConnectionStrings() { const connectionStrings = localStorage.getItem(CONNECTION_STRING_NAME_LIST); - return connectionStrings && connectionStrings.split(',') || []; -}; + if (connectionStrings) { + try { + // check expiration, delete and notify + const result: ConnectionStringWithExpiry[] = connectionStrings && JSON.parse(connectionStrings) || []; + const filteredResult = result.filter(s => new Date() < new Date(s.expiration)) || []; + if (filteredResult.length < result.length) { + yield call(raiseNotificationToast, { + text: { + translationKey: ResourceKeys.notifications.connectionStringsWithExpiryRemovalWarning + }, + type: NotificationType.warning + }); + yield call(setConnectionStrings, filteredResult); + } + return filteredResult; + } + catch + { + // connection strings stored can not be parsed to ConnectionStringWithExpiry Array + // Currently it is possible that user has old format connection strings stored + yield call(raiseNotificationToast, { + text: { + translationKey: ResourceKeys.notifications.connectionStringsWithoutExpiryRemovalWarning + }, + type: NotificationType.warning + }); + localStorage.setItem(CONNECTION_STRING_NAME_LIST, ''); + return []; + } + } +} diff --git a/src/app/connectionStrings/sagas/setConnectionStringsSaga.spec.ts b/src/app/connectionStrings/sagas/setConnectionStringsSaga.spec.ts index 52989674b..041700d9c 100644 --- a/src/app/connectionStrings/sagas/setConnectionStringsSaga.spec.ts +++ b/src/app/connectionStrings/sagas/setConnectionStringsSaga.spec.ts @@ -9,28 +9,32 @@ import { setConnectionStringsAction } from '../actions'; import { setConnectionStringsSaga, setConnectionStrings } from './setConnectionStringsSaga'; import { CONNECTION_STRING_NAME_LIST } from '../../constants/browserStorage'; +const stringsWithExpiry = [{ + connectionString: 'connectionString1', + expiration: (new Date()).toUTCString() +}]; + describe('setConnectionString', () => { it('sets expected value', () => { - const setValue = 'cruelworld'; - setConnectionStrings(setValue); + setConnectionStrings(stringsWithExpiry); - expect(localStorage.getItem(CONNECTION_STRING_NAME_LIST)).toEqual(setValue); + expect(localStorage.getItem(CONNECTION_STRING_NAME_LIST)).toEqual(JSON.stringify(stringsWithExpiry)); }); }); describe('setConnectionStringsSaga', () => { - const setConnectionStringsSagaGenerator = cloneableGenerator(setConnectionStringsSaga)(setConnectionStringsAction.started(['connectionString2'])); + const setConnectionStringsSagaGenerator = cloneableGenerator(setConnectionStringsSaga)(setConnectionStringsAction.started(stringsWithExpiry)); it('returns call effect to set connection strings', () => { expect(setConnectionStringsSagaGenerator.next()).toEqual({ done: false, - value: call(setConnectionStrings, 'connectionString2') + value: call(setConnectionStrings, stringsWithExpiry) }); }); it('puts the done action', () => { - expect(setConnectionStringsSagaGenerator.next(['connectionString2'])).toEqual({ + expect(setConnectionStringsSagaGenerator.next(stringsWithExpiry)).toEqual({ done: false, - value: put(setConnectionStringsAction.done({params: ['connectionString2'], result: ['connectionString2']})) + value: put(setConnectionStringsAction.done({params: stringsWithExpiry, result: stringsWithExpiry})) }); expect(setConnectionStringsSagaGenerator.next().done).toEqual(true); diff --git a/src/app/connectionStrings/sagas/setConnectionStringsSaga.ts b/src/app/connectionStrings/sagas/setConnectionStringsSaga.ts index 49b9d7b9e..abff8e3d2 100644 --- a/src/app/connectionStrings/sagas/setConnectionStringsSaga.ts +++ b/src/app/connectionStrings/sagas/setConnectionStringsSaga.ts @@ -6,12 +6,13 @@ import { call, put } from 'redux-saga/effects'; import { Action } from 'typescript-fsa'; import { CONNECTION_STRING_NAME_LIST } from '../../constants/browserStorage'; import { setConnectionStringsAction } from '../actions'; +import { ConnectionStringWithExpiry } from '../state'; -export function* setConnectionStringsSaga(action: Action) { - yield call(setConnectionStrings, action.payload.join(',')); +export function* setConnectionStringsSaga(action: Action) { + yield call(setConnectionStrings, action.payload); yield put(setConnectionStringsAction.done({params: action.payload, result: action.payload})); } -export const setConnectionStrings = (value: string): void => { - return localStorage.setItem(CONNECTION_STRING_NAME_LIST, value); +export const setConnectionStrings = (value: ConnectionStringWithExpiry[]): void => { + return localStorage.setItem(CONNECTION_STRING_NAME_LIST, JSON.stringify(value)); }; diff --git a/src/app/connectionStrings/sagas/upsertConnectionStringSaga.spec.ts b/src/app/connectionStrings/sagas/upsertConnectionStringSaga.spec.ts index f4c3b438e..d205fd1d8 100644 --- a/src/app/connectionStrings/sagas/upsertConnectionStringSaga.spec.ts +++ b/src/app/connectionStrings/sagas/upsertConnectionStringSaga.spec.ts @@ -12,8 +12,17 @@ import { CONNECTION_STRING_LIST_MAX_LENGTH } from '../../constants/browserStorag import { getConnectionStrings } from './getConnectionStringsSaga'; describe('upsertConnectionStringSaga', () => { + const saveStringsWithExpiry = [{ + connectionString: 'connectionString1', + expiration: (new Date(0)).toUTCString() + }]; + describe('adding unlisted connection string', () => { - const upsertConnectionStringSagaGenerator = cloneableGenerator(upsertConnectionStringSaga)(upsertConnectionStringAction.started({ newConnectionString: 'connectionString2'})); + const stringWithExpiry = { + connectionString: 'connectionString2', + expiration: (new Date()).toUTCString() + }; + const upsertConnectionStringSagaGenerator = cloneableGenerator(upsertConnectionStringSaga)(upsertConnectionStringAction.started(stringWithExpiry)); it('returns call effect to get connection strings', () => { expect(upsertConnectionStringSagaGenerator.next()).toEqual({ done: false, @@ -22,9 +31,9 @@ describe('upsertConnectionStringSaga', () => { }); it('returns call effect to set connection strings', () => { - expect(upsertConnectionStringSagaGenerator.next(['connectionString1'])).toEqual({ + expect(upsertConnectionStringSagaGenerator.next(saveStringsWithExpiry)).toEqual({ done: false, - value: call(setConnectionStrings, 'connectionString2,connectionString1') + value: call(setConnectionStrings, [stringWithExpiry, ...saveStringsWithExpiry]) }); }); @@ -36,9 +45,9 @@ describe('upsertConnectionStringSaga', () => { }); it('puts the done action', () => { - expect(upsertConnectionStringSagaGenerator.next(['connectionString2', 'connectionString1'])).toEqual({ + expect(upsertConnectionStringSagaGenerator.next([stringWithExpiry, ...saveStringsWithExpiry])).toEqual({ done: false, - value: put(upsertConnectionStringAction.done({params: {newConnectionString: 'connectionString2'}, result: ['connectionString2', 'connectionString1']})) + value: put(upsertConnectionStringAction.done({params: stringWithExpiry, result: [stringWithExpiry, ...saveStringsWithExpiry]})) }); expect(upsertConnectionStringSagaGenerator.next().done).toEqual(true); @@ -46,8 +55,12 @@ describe('upsertConnectionStringSaga', () => { }); describe('overwriting listed connection string', () => { + const stringWithExpiry = { + connectionString: 'connectionString1', + expiration: (new Date()).toUTCString() + }; const upsertConnectionStringSagaGenerator = cloneableGenerator(upsertConnectionStringSaga) - (upsertConnectionStringAction.started({ newConnectionString: 'newConnectionString1', connectionString: 'connectionString1'})); + (upsertConnectionStringAction.started(stringWithExpiry)); it('returns call effect to get connection strings', () => { expect(upsertConnectionStringSagaGenerator.next()).toEqual({ done: false, @@ -56,9 +69,9 @@ describe('upsertConnectionStringSaga', () => { }); it('returns call effect to set connection strings', () => { - expect(upsertConnectionStringSagaGenerator.next(['connectionString1'])).toEqual({ + expect(upsertConnectionStringSagaGenerator.next(saveStringsWithExpiry)).toEqual({ done: false, - value: call(setConnectionStrings, 'newConnectionString1') + value: call(setConnectionStrings, [stringWithExpiry]) }); }); @@ -70,9 +83,9 @@ describe('upsertConnectionStringSaga', () => { }); it('puts the done action', () => { - expect(upsertConnectionStringSagaGenerator.next(['newConnectionString1'])).toEqual({ + expect(upsertConnectionStringSagaGenerator.next([stringWithExpiry])).toEqual({ done: false, - value: put(upsertConnectionStringAction.done({params: { newConnectionString: 'newConnectionString1', connectionString: 'connectionString1'}, result: ['newConnectionString1']})) + value: put(upsertConnectionStringAction.done({params: stringWithExpiry, result: [stringWithExpiry]})) }); expect(upsertConnectionStringSagaGenerator.next().done).toEqual(true); @@ -80,7 +93,11 @@ describe('upsertConnectionStringSaga', () => { }); describe('creating new list', () => { - const upsertConnectionStringSagaGenerator = cloneableGenerator(upsertConnectionStringSaga)(upsertConnectionStringAction.started({ newConnectionString: 'connectionString1'})); + const stringWithExpiry = { + connectionString: 'connectionString1', + expiration: (new Date()).toUTCString() + }; + const upsertConnectionStringSagaGenerator = cloneableGenerator(upsertConnectionStringSaga)(upsertConnectionStringAction.started(stringWithExpiry)); it('returns call effect to get connection strings', () => { expect(upsertConnectionStringSagaGenerator.next()).toEqual({ done: false, @@ -91,7 +108,7 @@ describe('upsertConnectionStringSaga', () => { it('returns call effect to set connection strings', () => { expect(upsertConnectionStringSagaGenerator.next([])).toEqual({ done: false, - value: call(setConnectionStrings, 'connectionString1') + value: call(setConnectionStrings, [stringWithExpiry]) }); }); @@ -103,9 +120,9 @@ describe('upsertConnectionStringSaga', () => { }); it('puts the done action', () => { - expect(upsertConnectionStringSagaGenerator.next(['connectionString1'])).toEqual({ + expect(upsertConnectionStringSagaGenerator.next([stringWithExpiry])).toEqual({ done: false, - value: put(upsertConnectionStringAction.done({params: { newConnectionString: 'connectionString1'}, result: ['connectionString1']})) + value: put(upsertConnectionStringAction.done({params: stringWithExpiry, result: [stringWithExpiry]})) }); expect(upsertConnectionStringSagaGenerator.next().done).toEqual(true); @@ -113,15 +130,20 @@ describe('upsertConnectionStringSaga', () => { }); describe('slice of last connection string when max length exceeded', () => { - const connectionStrings: string[] = []; + const connectionStrings = []; for (let i = CONNECTION_STRING_LIST_MAX_LENGTH; i > 0; i--) { - connectionStrings.push(`connectionString${i}`); + connectionStrings.push({ + connectionString: `connectionString${i}`, + expiration: (new Date()).toUTCString() + }); } + const stringWithExpiry = { + connectionString: `connectionString${CONNECTION_STRING_LIST_MAX_LENGTH + 1}`, + expiration: (new Date()).toUTCString() + }; - const connectionStringsSerialized = connectionStrings.join(','); - const newConnectionString = `connectionString${CONNECTION_STRING_LIST_MAX_LENGTH + 1}`; - const upsertConnectionStringSagaGenerator = cloneableGenerator(upsertConnectionStringSaga)(upsertConnectionStringAction.started({newConnectionString})); - const updatedConnectionString = [newConnectionString, ...connectionStrings.splice(0, CONNECTION_STRING_LIST_MAX_LENGTH - 1)]; + const upsertConnectionStringSagaGenerator = cloneableGenerator(upsertConnectionStringSaga)(upsertConnectionStringAction.started(stringWithExpiry)); + const updatedConnectionString = [stringWithExpiry, ...connectionStrings].splice(0, CONNECTION_STRING_LIST_MAX_LENGTH); it('returns call effect to get connection strings', () => { expect(upsertConnectionStringSagaGenerator.next()).toEqual({ @@ -131,9 +153,9 @@ describe('upsertConnectionStringSaga', () => { }); it('returns call effect to set connection strings', () => { - expect(upsertConnectionStringSagaGenerator.next(connectionStringsSerialized.split(','))).toEqual({ + expect(upsertConnectionStringSagaGenerator.next(connectionStrings)).toEqual({ done: false, - value: call(setConnectionStrings, updatedConnectionString.join(',')) + value: call(setConnectionStrings, updatedConnectionString) }); }); @@ -147,7 +169,7 @@ describe('upsertConnectionStringSaga', () => { it('puts the done action', () => { expect(upsertConnectionStringSagaGenerator.next(updatedConnectionString)).toEqual({ done: false, - value: put(upsertConnectionStringAction.done({params: {newConnectionString}, result: updatedConnectionString})) + value: put(upsertConnectionStringAction.done({params: stringWithExpiry, result: updatedConnectionString})) }); expect(upsertConnectionStringSagaGenerator.next().done).toEqual(true); diff --git a/src/app/connectionStrings/sagas/upsertConnectionStringSaga.ts b/src/app/connectionStrings/sagas/upsertConnectionStringSaga.ts index d83036a9a..52f0a078e 100644 --- a/src/app/connectionStrings/sagas/upsertConnectionStringSaga.ts +++ b/src/app/connectionStrings/sagas/upsertConnectionStringSaga.ts @@ -5,24 +5,24 @@ import { call, put } from 'redux-saga/effects'; import { Action } from 'typescript-fsa'; import { CONNECTION_STRING_LIST_MAX_LENGTH } from '../../constants/browserStorage'; -import { UpsertConnectionStringActionPayload, upsertConnectionStringAction } from '../actions'; +import { upsertConnectionStringAction } from '../actions'; import { setConnectionStrings } from './setConnectionStringsSaga'; import { getConnectionStrings } from './getConnectionStringsSaga'; +import { ConnectionStringWithExpiry } from '../state'; -export function* upsertConnectionStringSaga(action: Action) { - const savedStrings: string[] = yield call(getConnectionStrings); - let updatedValue: string; +export function* upsertConnectionStringSaga(action: Action) { + const savedStrings: ConnectionStringWithExpiry[] = yield call(getConnectionStrings); + let updatedValues: ConnectionStringWithExpiry[]; if (savedStrings) { - const nonDuplicateStrings = savedStrings.filter(name => name !== action.payload.connectionString); // remove duplicates - const updatedStrings = [action.payload.newConnectionString, ...nonDuplicateStrings].slice(0, CONNECTION_STRING_LIST_MAX_LENGTH); - updatedValue = updatedStrings.join(','); + const nonDuplicateStrings = savedStrings.filter(s => s.connectionString !== action.payload.connectionString); // remove duplicates + updatedValues = [action.payload, ...nonDuplicateStrings].slice(0, CONNECTION_STRING_LIST_MAX_LENGTH); } else { - updatedValue = action.payload.newConnectionString; + updatedValues = [action.payload]; } - yield call(setConnectionStrings, updatedValue); + yield call(setConnectionStrings, updatedValues); const updatedConnectionString = yield call(getConnectionStrings); yield put(upsertConnectionStringAction.done({params: action.payload, result: updatedConnectionString})); } diff --git a/src/app/connectionStrings/state.ts b/src/app/connectionStrings/state.ts index 8fdec92a8..3cced674c 100644 --- a/src/app/connectionStrings/state.ts +++ b/src/app/connectionStrings/state.ts @@ -7,10 +7,15 @@ import { IM } from '../shared/types/types'; import { SynchronizationWrapper } from '../api/models/synchronizationWrapper'; import { SynchronizationStatus } from '../api/models/synchronizationStatus'; -export interface ConnectionStringsStateInterface extends SynchronizationWrapper{} +export interface ConnectionStringsStateInterface extends SynchronizationWrapper{} export type ConnectionStringsStateType = IM; +export interface ConnectionStringWithExpiry { + connectionString: string; + expiration: string; +} + export const connectionStringsStateInitial = Record({ payload: [], synchronizationStatus: SynchronizationStatus.initialized diff --git a/src/app/constants/browserStorage.ts b/src/app/constants/browserStorage.ts index 0023e348f..975e3806e 100644 --- a/src/app/constants/browserStorage.ts +++ b/src/app/constants/browserStorage.ts @@ -3,7 +3,9 @@ * Licensed under the MIT License **********************************************************/ export const CONNECTION_STRING_LIST_MAX_LENGTH = 10; -export const CONNECTION_STRING_NAME_LIST = 'CONNECTION_STRING_NAME_LIST'; // store hub connection strings in localStorage separated by comma +export const CONNECTION_STRING_EXPIRATION_IN_YEAR = 1; +export const CONNECTION_STRING_EXPIRATION_WARNING_IN_DAYS = 3; +export const CONNECTION_STRING_NAME_LIST = 'CONNECTION_STRING_NAME_LIST'; // store stringified array of ConnectionStringWithExpiry objects export const REPO_LOCATIONS = 'REPO_LOCATIONS'; // store repo locations in localStorage separated by comma export const THEME_SELECTION = 'THEME_SELECTION'; export const HIGH_CONTRAST = 'HIGH_CONTRAST'; diff --git a/src/app/constants/iconNames.ts b/src/app/constants/iconNames.ts index 4e6443d39..be8469483 100644 --- a/src/app/constants/iconNames.ts +++ b/src/app/constants/iconNames.ts @@ -55,8 +55,6 @@ export enum ArrayOperation { } export enum Heading { - SETTINGS_OPEN = 'PlugDisconnected', - SETTINGS_CLOSED = 'PlugConnected', MESSAGE = 'SkypeMessage', HELP = 'Help' } diff --git a/src/app/devices/deviceEvents/components/deviceSimulationPanel.tsx b/src/app/devices/deviceEvents/components/deviceSimulationPanel.tsx index 82006921b..d9d66f72e 100644 --- a/src/app/devices/deviceEvents/components/deviceSimulationPanel.tsx +++ b/src/app/devices/deviceEvents/components/deviceSimulationPanel.tsx @@ -39,7 +39,7 @@ export const DeviceSimulationPanel: React.FC = props const deviceId = getDeviceIdFromQueryString(search); - const [ hubConnectionString, ] = getHubInformationFromLocalStorage(); + const hubConnectionString = getHubInformationFromLocalStorage().hubConnectionString; const [ simulationBody, setSimulationBody ] = React.useState(''); const [ propertyIndex, setPropertyIndex ] = React.useState(0); const [ selectedIndices, setSelectedIndices ] = React.useState>(new Set()); diff --git a/src/app/devices/deviceList/components/__snapshots__/deviceList.spec.tsx.snap b/src/app/devices/deviceList/components/__snapshots__/deviceList.spec.tsx.snap index d0b2c5f91..91f1059ce 100644 --- a/src/app/devices/deviceList/components/__snapshots__/deviceList.spec.tsx.snap +++ b/src/app/devices/deviceList/components/__snapshots__/deviceList.spec.tsx.snap @@ -61,6 +61,7 @@ exports[`DeviceList matches snapshot 1`] = ` "maxWidth": 400, "minWidth": 100, "name": "deviceLists.columns.deviceId.label", + "onRender": [Function], }, Object { "fieldName": "status", @@ -69,6 +70,7 @@ exports[`DeviceList matches snapshot 1`] = ` "maxWidth": 100, "minWidth": 100, "name": "deviceLists.columns.status.label", + "onRender": [Function], }, Object { "fieldName": "connection", @@ -77,6 +79,7 @@ exports[`DeviceList matches snapshot 1`] = ` "maxWidth": 200, "minWidth": 100, "name": "deviceLists.columns.connection", + "onRender": [Function], }, Object { "fieldName": "authenticationType", @@ -86,6 +89,7 @@ exports[`DeviceList matches snapshot 1`] = ` "maxWidth": 200, "minWidth": 100, "name": "deviceLists.columns.authenticationType", + "onRender": [Function], }, Object { "fieldName": "statusUpdatedTime", @@ -95,6 +99,7 @@ exports[`DeviceList matches snapshot 1`] = ` "maxWidth": 300, "minWidth": 100, "name": "deviceLists.columns.statusUpdatedTime", + "onRender": [Function], }, Object { "fieldName": "modelId", @@ -104,6 +109,7 @@ exports[`DeviceList matches snapshot 1`] = ` "maxWidth": 400, "minWidth": 100, "name": "deviceLists.columns.isPnpDevice", + "onRender": [Function], }, Object { "fieldName": "edge", @@ -111,6 +117,7 @@ exports[`DeviceList matches snapshot 1`] = ` "key": "edge", "minWidth": 100, "name": "deviceLists.columns.isEdgeDevice.label", + "onRender": [Function], }, ] } @@ -129,7 +136,6 @@ exports[`DeviceList matches snapshot 1`] = ` ] } layoutMode={1} - onRenderItemColumn={[Function]} selection={ Selection { "_anchoredIndex": 0, diff --git a/src/app/devices/deviceList/components/deviceList.tsx b/src/app/devices/deviceList/components/deviceList.tsx index 8c56abc10..176c0d15b 100644 --- a/src/app/devices/deviceList/components/deviceList.tsx +++ b/src/app/devices/deviceList/components/deviceList.tsx @@ -96,7 +96,6 @@ export const DeviceList: React.FC = () => { (devices && devices.length !== 0 ? { const getColumns = (): IColumn[] => { return [ - { fieldName: 'id', isMultiline: true, isResizable: true, key: 'id', - maxWidth: LARGE_COLUMN_WIDTH, minWidth: 100, name: t(ResourceKeys.deviceLists.columns.deviceId.label) }, - { fieldName: 'status', isResizable: true, key: 'status', - maxWidth: EXTRA_SMALL_COLUMN_WIDTH, minWidth: 100, name: t(ResourceKeys.deviceLists.columns.status.label)}, - { fieldName: 'connection', isResizable: true, key: 'connection', - maxWidth: SMALL_COLUMN_WIDTH, minWidth: 100, name: t(ResourceKeys.deviceLists.columns.connection) }, - { fieldName: 'authenticationType', isMultiline: true, isResizable: true, key: 'authenticationType', - maxWidth: SMALL_COLUMN_WIDTH, minWidth: 100, name: t(ResourceKeys.deviceLists.columns.authenticationType)}, - { fieldName: 'statusUpdatedTime', isMultiline: true, isResizable: true, key: 'statusUpdatedTime', - maxWidth: MEDIUM_COLUMN_WIDTH, minWidth: 100, name: t(ResourceKeys.deviceLists.columns.statusUpdatedTime)}, - { fieldName: 'modelId', isMultiline: true, isResizable: true, key: 'modelId', - maxWidth: LARGE_COLUMN_WIDTH, minWidth: 100, name: t(ResourceKeys.deviceLists.columns.isPnpDevice)}, - { fieldName: 'edge', isResizable: true, key: 'edge', - minWidth: 100, name: t(ResourceKeys.deviceLists.columns.isEdgeDevice.label)}, + { + fieldName: 'id', + isMultiline: true, + isResizable: true, + key: 'id', + maxWidth: LARGE_COLUMN_WIDTH, + minWidth: 100, + name: t(ResourceKeys.deviceLists.columns.deviceId.label), + onRender: (item: DeviceSummary, index: number, column: IColumn) => { + const path = pathname.replace(/\/devices\/.*/, `/${ROUTE_PARTS.DEVICES}`); + return ( + + {item.deviceId} + + ); + } + }, + { + fieldName: 'status', + isResizable: true, + key: 'status', + maxWidth: EXTRA_SMALL_COLUMN_WIDTH, + minWidth: 100, + name: t(ResourceKeys.deviceLists.columns.status.label), + onRender: (item: DeviceSummary, index: number, column: IColumn) => { + return ( + + ); + } + }, + { + fieldName: 'connection', + isResizable: true, + key: 'connection', + maxWidth: SMALL_COLUMN_WIDTH, + minWidth: 100, + name: t(ResourceKeys.deviceLists.columns.connection), + onRender: (item: DeviceSummary, index: number, column: IColumn) => { + return ( + + ); + } + }, + { + fieldName: 'authenticationType', + isMultiline: true, + isResizable: true, + key: 'authenticationType', + maxWidth: SMALL_COLUMN_WIDTH, + minWidth: 100, + name: t(ResourceKeys.deviceLists.columns.authenticationType), + onRender: (item: DeviceSummary, index: number, column: IColumn) => { + return ( + + ); + } + }, + { + fieldName: 'statusUpdatedTime', + isMultiline: true, + isResizable: true, + key: 'statusUpdatedTime', + maxWidth: MEDIUM_COLUMN_WIDTH, + minWidth: 100, + name: t(ResourceKeys.deviceLists.columns.statusUpdatedTime), + onRender: (item: DeviceSummary, index: number, column: IColumn) => { + return ( + + ); + } + }, + { + fieldName: 'modelId', + isMultiline: true, + isResizable: true, + key: 'modelId', + maxWidth: LARGE_COLUMN_WIDTH, + minWidth: 100, + name: t(ResourceKeys.deviceLists.columns.isPnpDevice), + onRender: (item: DeviceSummary, index: number, column: IColumn) => { + return ( + + ); + } + }, + { + fieldName: 'edge', + isResizable: true, + key: 'edge', + minWidth: 100, + name: t(ResourceKeys.deviceLists.columns.isEdgeDevice.label), + onRender: (item: DeviceSummary, index: number, column: IColumn) => { + const isEdge = item.iotEdge; + return ( + + ); + } + }, ]; }; - // tslint:disable-next-line:cyclomatic-complexity - const renderItemColumn = () => (item: DeviceSummary, index: number, column: IColumn) => { - switch (column.key) { - case 'id': - const path = pathname.replace(/\/devices\/.*/, `/${ROUTE_PARTS.DEVICES}`); - return ( - - {item.deviceId} - - ); - case 'status': - return ( - - ); - case 'connection': - return ( - - ); - case 'authenticationType': - return ( - - ); - case 'statusUpdatedTime': - return ( - - ); - case 'edge': - const isEdge = item.iotEdge; - return ( - - ); - case 'modelId': - return ( - - ); - default: - return; - } - }; - const fetchPage = (pageNumber: number) => { dispatch(listDevicesAction.started({ clauses: deviceQuery.clauses, diff --git a/src/app/shared/components/breadcrumb.tsx b/src/app/shared/components/breadcrumb.tsx index f21bfc4bc..4e0c4fc92 100644 --- a/src/app/shared/components/breadcrumb.tsx +++ b/src/app/shared/components/breadcrumb.tsx @@ -12,7 +12,7 @@ import { getHubInformationFromLocalStorage } from '../hooks/localStorageInformat import '../../css/_breadcrumb.scss'; export const Breadcrumb: React.FC = () => { - const [ , hostName] = getHubInformationFromLocalStorage(); + const hostName = getHubInformationFromLocalStorage().hostName; const renderBreadcrumbItem = () => ; diff --git a/src/app/shared/hooks/localStorageInformationRetriever.ts b/src/app/shared/hooks/localStorageInformationRetriever.ts index 69bbc4ba1..8dcb75abb 100644 --- a/src/app/shared/hooks/localStorageInformationRetriever.ts +++ b/src/app/shared/hooks/localStorageInformationRetriever.ts @@ -1,15 +1,23 @@ import * as React from 'react'; import { CONNECTION_STRING_NAME_LIST } from '../../constants/browserStorage'; import { getConnectionInfoFromConnectionString } from '../../api/shared/utils'; +import { getActiveConnectionString } from '../utils/hubConnectionStringHelper'; -export const getHubInformationFromLocalStorage = () => { +interface LocalStorageInformation { + hubConnectionString: string; + hostName: string; +} + +export const getHubInformationFromLocalStorage = (): LocalStorageInformation => { const [ hubConnectionString, setHubConnectionString ] = React.useState(''); const [ hostName, setHostName ] = React.useState(''); const connectionStrings = localStorage.getItem(CONNECTION_STRING_NAME_LIST); - const connectionString = connectionStrings && connectionStrings.split(',')[0]; + const connectionString = getActiveConnectionString(connectionStrings); + React.useEffect(() => { setHubConnectionString(connectionString); setHostName(getConnectionInfoFromConnectionString(connectionString).hostName); }, [connectionString]); - return [hubConnectionString, hostName]; + + return {hubConnectionString, hostName}; }; diff --git a/src/app/shared/utils/hubConnectionStringHelper.spec.ts b/src/app/shared/utils/hubConnectionStringHelper.spec.ts index b521130cf..5ccf37a35 100644 --- a/src/app/shared/utils/hubConnectionStringHelper.spec.ts +++ b/src/app/shared/utils/hubConnectionStringHelper.spec.ts @@ -3,8 +3,9 @@ * Licensed under the MIT License **********************************************************/ import 'jest'; -import { generateConnectionStringValidationError, isValidEventHubConnectionString } from './hubConnectionStringHelper'; +import { formatConnectionStrings, generateConnectionStringValidationError, getActiveConnectionString, isValidEventHubConnectionString } from './hubConnectionStringHelper'; import { ResourceKeys } from '../../../localization/resourceKeys'; +import { CONNECTION_STRING_LIST_MAX_LENGTH } from '../../constants/browserStorage'; describe('hubConnectionStringHelper', () => { @@ -22,9 +23,37 @@ describe('hubConnectionStringHelper', () => { expect(generateConnectionStringValidationError('HostName=testhub.azure-devices.net;SharedAccessKeyName=123;SharedAccessKey=456')).toEqual(null); }); - it('validate event hub connection string', () => { + it('validates event hub connection string', () => { expect(isValidEventHubConnectionString(null)).toEqual(true); expect(isValidEventHubConnectionString('Endpoint=sb://123/;SharedAccessKeyName=456;SharedAccessKey=789')).toEqual(true); expect(isValidEventHubConnectionString('Endpoint=sb://123/;SharedAccessKeyName=456;SharedAccess=789')).toEqual(false); }); + + it('formats connection strings', () => { + const connectionStrings = []; + for (let i = CONNECTION_STRING_LIST_MAX_LENGTH; i > 0; i--) { + connectionStrings.push({ + connectionString: `connectionString${i}`, + expiration: (new Date(0)).toUTCString() + }); + } + expect(formatConnectionStrings(connectionStrings, 'connectionString1')).toEqual([ + { + connectionString: 'connectionString1', + expiration: (new Date(0)).toUTCString() + }, + ...connectionStrings.slice(0, CONNECTION_STRING_LIST_MAX_LENGTH - 1) + ]); + }); + + it('gets active connection string', () => { + const connectionStrings = []; + for (let i = CONNECTION_STRING_LIST_MAX_LENGTH; i > 0; i--) { + connectionStrings.push({ + connectionString: `connectionString${i}`, + expiration: (new Date(0)).toUTCString() + }); + } + expect(getActiveConnectionString(JSON.stringify(connectionStrings))).toEqual('connectionString10'); + }); }); diff --git a/src/app/shared/utils/hubConnectionStringHelper.ts b/src/app/shared/utils/hubConnectionStringHelper.ts index 12f8cb491..c21179d8d 100644 --- a/src/app/shared/utils/hubConnectionStringHelper.ts +++ b/src/app/shared/utils/hubConnectionStringHelper.ts @@ -2,8 +2,9 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License **********************************************************/ +import { ConnectionStringWithExpiry } from '../../connectionStrings/state'; import { ResourceKeys } from '../../../localization/resourceKeys'; -import { CONNECTION_STRING_LIST_MAX_LENGTH } from '../../constants/browserStorage'; +import { CONNECTION_STRING_EXPIRATION_IN_YEAR, CONNECTION_STRING_LIST_MAX_LENGTH } from '../../constants/browserStorage'; import { getConnectionInfoFromConnectionString } from './../../api/shared/utils'; export const generateConnectionStringValidationError = (value: string): string => { @@ -25,12 +26,12 @@ const isHubConnectionString = (value: string) => { return false; }; -export const formatConnectionStrings = (connectionStrings: string[], activeConnectionString: string): string[] => { - const trimmedList = connectionStrings.slice(0, CONNECTION_STRING_LIST_MAX_LENGTH); - const formattedList = trimmedList.filter(s => s !== activeConnectionString); - formattedList.unshift(activeConnectionString); - - return formattedList; +export const formatConnectionStrings = (connectionStrings: ConnectionStringWithExpiry[], activeConnectionString: string): ConnectionStringWithExpiry[] => { + const connectionStringWithExpiry = connectionStrings.find(s => s.connectionString === activeConnectionString); + const filteredList = connectionStrings.filter(s => s.connectionString !== activeConnectionString); + filteredList.unshift(connectionStringWithExpiry); + const trimmedList = filteredList.slice(0, CONNECTION_STRING_LIST_MAX_LENGTH); + return trimmedList; }; export const isValidEventHubConnectionString = (connectionString: string): boolean => { @@ -40,3 +41,28 @@ export const isValidEventHubConnectionString = (connectionString: string): boole const pattern = new RegExp('^Endpoint=sb://.*;SharedAccessKeyName=.*;SharedAccessKey=.*$'); return pattern.test(connectionString); }; + +export const getDaysBeforeHubConnectionStringExpires = (connectionStringWithExpiry: ConnectionStringWithExpiry) => { + const millisecondsPerDay = 86400000; + return Math.floor((Date.parse(connectionStringWithExpiry.expiration) - Date.parse((new Date()).toUTCString()) ) / millisecondsPerDay); +}; + +export const getExpiryDateInUtcString = () => { + const oneYearFromNow = new Date(); + oneYearFromNow.setFullYear(oneYearFromNow.getFullYear() + CONNECTION_STRING_EXPIRATION_IN_YEAR); + return oneYearFromNow.toUTCString(); +}; + +export const getActiveConnectionString = (connectionStrings: string): string => { + if (!connectionStrings) { + return; + } + + try { + const parsedStrings = JSON.parse(connectionStrings); + return parsedStrings && parsedStrings[0] && parsedStrings[0].connectionString; + } + catch { + return; + } +}; diff --git a/src/localization/locales/en.json b/src/localization/locales/en.json index 54b6ed92a..52c3f0a7f 100644 --- a/src/localization/locales/en.json +++ b/src/localization/locales/en.json @@ -214,7 +214,8 @@ "label": "Connection String", "ariaLabel": "Connection String" } - } + }, + "expirationWarning": "Your hub connection string is expiring in less than {{numberOfDays}} day(s). Please edit the connection string (by copying and pasting again) to extend, or you will have to re-add it once it expires." }, "connectivityPane": { "header": "App configurations", @@ -854,7 +855,9 @@ "portIsInUseError": "The port {{portNumber}} is in use. To get around with the issue, configure a custom port by setting the system environment variable 'AZURE_IOT_EXPLORER_PORT' to an available port number. To learn more, please visit https://github.com/Azure/azure-iot-explorer/wiki/FAQ", "modelRepoistorySettingsUpdated": "Model repository locations successfully updated.", "startEventMonitoringOnError": "Failed to start monitoring device telemetry: {{error}}", - "stopEventMonitoringOnError": "Failed to stop monitoring device telemetry: {{error}}" + "stopEventMonitoringOnError": "Failed to stop monitoring device telemetry: {{error}}", + "connectionStringsWithoutExpiryRemovalWarning": "Due to security requirments, previously stored hub connection strings have been removed. The new connection string being added would automatically have an expiration date one year from now.", + "connectionStringsWithExpiryRemovalWarning": "Due to security requirments, one or more hub connection strings stored more than a year ago have been removed." }, "errorBoundary": { "text": "Something went wrong" diff --git a/src/localization/resourceKeys.ts b/src/localization/resourceKeys.ts index 0c3a3fdaa..e9ba54139 100644 --- a/src/localization/resourceKeys.ts +++ b/src/localization/resourceKeys.ts @@ -174,6 +174,7 @@ export class ResourceKeys { description : "connectionStrings.empty.description", header : "connectionStrings.empty.header", }, + expirationWarning : "connectionStrings.expirationWarning", properties : { connectionString : { ariaLabel : "connectionStrings.properties.connectionString.ariaLabel", @@ -860,6 +861,8 @@ export class ResourceKeys { addModuleIdentityOnSucceed : "notifications.addModuleIdentityOnSucceed", cloudToDeviceMessageOnError : "notifications.cloudToDeviceMessageOnError", cloudToDeviceMessageOnSuccess : "notifications.cloudToDeviceMessageOnSuccess", + connectionStringsWithExpiryRemovalWarning : "notifications.connectionStringsWithExpiryRemovalWarning", + connectionStringsWithoutExpiryRemovalWarning : "notifications.connectionStringsWithoutExpiryRemovalWarning", copiedToClipboard : "notifications.copiedToClipboard", deleteDeviceOnError : "notifications.deleteDeviceOnError", deleteDeviceOnSucceed : "notifications.deleteDeviceOnSucceed",