• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mendersoftware / mender-server / 10423

11 Nov 2025 04:53PM UTC coverage: 74.435% (-0.1%) from 74.562%
10423

push

gitlab-ci

web-flow
Merge pull request #1071 from mendersoftware/dependabot/npm_and_yarn/frontend/main/development-dependencies-92732187be

3868 of 5393 branches covered (71.72%)

Branch coverage included in aggregate %.

5 of 5 new or added lines in 2 files covered. (100.0%)

176 existing lines in 95 files now uncovered.

64605 of 86597 relevant lines covered (74.6%)

7.74 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.22
/frontend/src/js/components/settings/Integrations.tsx
1
// Copyright 2021 Northern.tech AS
2✔
2
//
2✔
3
//    Licensed under the Apache License, Version 2.0 (the "License");
2✔
4
//    you may not use this file except in compliance with the License.
2✔
5
//    You may obtain a copy of the License at
2✔
6
//
2✔
7
//        http://www.apache.org/licenses/LICENSE-2.0
2✔
8
//
2✔
9
//    Unless required by applicable law or agreed to in writing, software
2✔
10
//    distributed under the License is distributed on an "AS IS" BASIS,
2✔
11
//    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
2✔
12
//    See the License for the specific language governing permissions and
2✔
13
//    limitations under the License.
2✔
14
import { useEffect, useState } from 'react';
2✔
15
import { useDispatch, useSelector } from 'react-redux';
2✔
16

2✔
17
import { Button, Divider, FormControl, InputLabel, MenuItem, Select, TextField } from '@mui/material';
2✔
18
import { makeStyles } from 'tss-react/mui';
2✔
19

2✔
20
import Confirm from '@northern.tech/common-ui/Confirm';
2✔
21
import InfoHint from '@northern.tech/common-ui/InfoHint';
2✔
22
import { EXTERNAL_PROVIDER, TIMEOUTS } from '@northern.tech/store/constants';
2✔
23
import { getExternalIntegrations, getIsPreview } from '@northern.tech/store/selectors';
2✔
24
import { changeIntegration, createIntegration, deleteIntegration, getIntegrations } from '@northern.tech/store/thunks';
2✔
25
import { useDebounce } from '@northern.tech/utils/debouncehook';
2✔
26
import { customSort } from '@northern.tech/utils/helpers';
2✔
27

2✔
28
import WebhookConfiguration from './webhooks/Configuration';
2✔
29
import Webhooks from './webhooks/Webhooks';
2✔
30

2✔
31
const maxWidth = 750;
7✔
32

2✔
33
const useStyles = makeStyles()(theme => ({
9✔
34
  leftButton: { marginRight: theme.spacing() },
2✔
35
  inputWrapper: { alignItems: 'flex-end' },
2✔
36
  select: { minWidth: 300 },
2✔
37
  formWrapper: { display: 'flex', flexDirection: 'column', gap: theme.spacing(2) },
2✔
38
  textInput: { minWidth: 500, wordBreak: 'break-all' },
2✔
39
  widthLimit: { maxWidth }
2✔
40
}));
2✔
41

2✔
42
const ConnectionDetailsInput = ({ connectionConfig, isEditing, setConnectionConfig }) => {
7✔
43
  const { access_key_id = '', secret_access_key = '', region = '', device_policy_name = '' } = connectionConfig.aws || {};
6✔
44
  const [keyId, setKeyId] = useState(access_key_id);
6✔
45
  const [keySecret, setKeySecret] = useState(secret_access_key);
6✔
46
  const [awsRegion, setRegion] = useState(region);
6✔
47
  const [policy, setPolicy] = useState(device_policy_name);
6✔
48

2✔
49
  const debouncedId = useDebounce(keyId, TIMEOUTS.debounceDefault);
6✔
50
  const debouncedSecret = useDebounce(keySecret, TIMEOUTS.debounceDefault);
6✔
51
  const debouncedRegion = useDebounce(awsRegion, TIMEOUTS.debounceDefault);
6✔
52
  const debounced = useDebounce(policy, TIMEOUTS.debounceDefault);
6✔
53

2✔
54
  const { classes } = useStyles();
6✔
55

2✔
56
  useEffect(() => {
6✔
57
    setConnectionConfig({
3✔
58
      aws: {
2✔
59
        access_key_id: debouncedId,
2✔
60
        secret_access_key: debouncedSecret,
2✔
61
        region: debouncedRegion,
2✔
62
        device_policy_name: debounced
2✔
63
      }
2✔
64
    });
2✔
65
  }, [debounced, debouncedRegion, debouncedId, debouncedSecret, setConnectionConfig]);
2✔
66

2✔
67
  useEffect(() => {
6✔
68
    setKeyId(access_key_id);
3✔
69
    setKeySecret(secret_access_key);
3✔
70
    setRegion(region);
3✔
71
    setPolicy(device_policy_name);
3✔
72
  }, [access_key_id, secret_access_key, region, device_policy_name]);
2✔
73

2✔
74
  const onKeyChange = ({ target: { value = '' } }) => setKeyId(value);
6!
75
  const onSecretChange = ({ target: { value = '' } }) => setKeySecret(value);
6!
76
  const onRegionChange = ({ target: { value = '' } }) => setRegion(value);
6!
77
  const onPolicyChange = ({ target: { value = '' } }) => setPolicy(value);
6!
78

2✔
79
  const commonProps = { className: classes.textInput, disabled: !isEditing, multiline: true };
6✔
80
  return (
6✔
81
    <div className={classes.formWrapper}>
2✔
82
      <TextField {...commonProps} label="Key ID" onChange={onKeyChange} value={keyId} />
2✔
83
      <TextField {...commonProps} label="Key Secret" onChange={onSecretChange} value={keySecret} />
2✔
84
      <TextField {...commonProps} label="Region" onChange={onRegionChange} value={awsRegion} />
2✔
85
      <TextField {...commonProps} label="Device Policy Name" onChange={onPolicyChange} value={policy} />
2✔
86
    </div>
2✔
87
  );
2✔
88
};
2✔
89

2✔
90
const ConnectionStringInput = ({ connectionConfig, isEditing, setConnectionConfig, title }) => {
7✔
91
  const [value, setValue] = useState(connectionConfig.connection_string);
8✔
92
  const debouncedValue = useDebounce(value, TIMEOUTS.debounceDefault);
8✔
93

2✔
94
  const { classes } = useStyles();
8✔
95

2✔
96
  useEffect(() => {
8✔
97
    setConnectionConfig({ connection_string: debouncedValue });
4✔
98
  }, [debouncedValue, setConnectionConfig]);
2✔
99

2✔
100
  useEffect(() => {
8✔
101
    setValue(connectionConfig.connection_string);
5✔
102
  }, [connectionConfig.connection_string]);
2✔
103

2✔
104
  const updateConnectionConfig = ({ target: { value = '' } }) => setValue(value);
8!
105

2✔
106
  return (
8✔
107
    <TextField
2✔
108
      className={classes.textInput}
2✔
109
      disabled={!isEditing}
2✔
110
      label={`${title} connection string`}
2✔
111
      multiline
2✔
112
      onChange={updateConnectionConfig}
2✔
113
      value={value}
2✔
114
    />
2✔
115
  );
2✔
116
};
2✔
117

2✔
118
const providerConfigMap = {
7✔
119
  'iot-core': ConnectionDetailsInput,
2✔
120
  'iot-hub': ConnectionStringInput
2✔
121
};
2✔
122

2✔
123
export const IntegrationConfiguration = ({ integration, isLast, onCancel, onDelete, onSave }) => {
7✔
124
  const { credentials = {}, provider } = integration;
14✔
125
  const [connectionConfig, setConnectionConfig] = useState(credentials);
14✔
126
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
2✔
127
  const { type, ...otherProps } = credentials;
14✔
128
  const [isEditing, setIsEditing] = useState(!Object.values(otherProps).some(i => i));
14✔
129
  const [isDeleting, setIsDeleting] = useState(false);
14✔
130

2✔
131
  const { classes } = useStyles();
14✔
132

2✔
133
  useEffect(() => {
14✔
134
    const { credentials = {} } = integration;
7✔
135
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
2✔
136
    const { type, ...otherProps } = credentials;
7✔
137
    setConnectionConfig(credentials);
7✔
138
    setIsEditing(!Object.values(otherProps).some(i => i));
7✔
139
  }, [integration]);
2✔
140

2✔
141
  const onCancelClick = () => {
14✔
142
    setIsEditing(false);
2✔
143
    setConnectionConfig(credentials);
2✔
144
    onCancel(integration);
2✔
145
  };
2✔
146
  const onDeleteClick = () => setIsDeleting(true);
14✔
147
  const onDeleteConfirm = () => onDelete(integration);
14✔
148
  const onEditClick = () => setIsEditing(true);
14✔
149
  const onSaveClick = () =>
14✔
150
    onSave({
2✔
151
      ...integration,
2✔
152
      credentials: {
2✔
153
        type: EXTERNAL_PROVIDER[provider].credentialsType,
2✔
154
        ...connectionConfig
2✔
155
      }
2✔
156
    });
2✔
157

2✔
158
  const ConfigInput = providerConfigMap[provider];
14✔
159
  const { configHint, title } = EXTERNAL_PROVIDER[provider];
14✔
160
  return (
14✔
161
    <>
2✔
162
      <h3 className="margin-bottom-none">{title}</h3>
2✔
163
      <div className={`flexbox space-between padding-top-small relative ${classes.widthLimit} ${classes.inputWrapper}`}>
2✔
164
        <ConfigInput connectionConfig={connectionConfig} isEditing={isEditing} setConnectionConfig={setConnectionConfig} title={title} />
2✔
165
        <div className="flexbox margin-bottom-x-small">
2✔
166
          {isEditing ? (
2✔
167
            <>
2✔
168
              <Button className={classes.leftButton} onClick={onCancelClick}>
2✔
169
                Cancel
2✔
170
              </Button>
2✔
171
              <Button variant="contained" onClick={onSaveClick} disabled={credentials === connectionConfig}>
2✔
172
                Save
2✔
173
              </Button>
2✔
174
            </>
2✔
175
          ) : (
2✔
176
            <>
2✔
177
              <Button className={classes.leftButton} onClick={onEditClick}>
2✔
178
                Edit
2✔
179
              </Button>
2✔
180
              <Button onClick={onDeleteClick}>Delete</Button>
2✔
181
            </>
2✔
182
          )}
2✔
183
        </div>
2✔
UNCOV
184
        {isDeleting && <Confirm type="integrationRemoval" classes="confirmation-overlay" action={onDeleteConfirm} cancel={() => setIsDeleting(false)} />}
2!
185
      </div>
2✔
186
      <InfoHint className={`margin-bottom ${classes.widthLimit}`} content={configHint} />
2✔
187
      {!isLast && <Divider className={`margin-bottom ${classes.widthLimit}`} />}
2✔
188
    </>
2✔
189
  );
2✔
190
};
2✔
191

2✔
192
const determineAvailableIntegrations = (integrations, isPreRelease) =>
7✔
193
  Object.values(EXTERNAL_PROVIDER).reduce((accu, provider) => {
4✔
194
    const hasIntegrationConfigured = integrations.some(integration => integration.provider == provider.provider);
12✔
195
    if (provider.title && (provider.enabled || isPreRelease) && !hasIntegrationConfigured) {
8!
196
      accu.push(provider);
2✔
197
    }
2✔
198
    return accu;
8✔
199
  }, []);
2✔
200

2✔
201
const IntegrationsContainer = ({ children }: { children: ReactNode }) => (
7✔
202
  <div>
6✔
203
    <h2 className="margin-top-small">Integrations</h2>
2✔
204
    {children}
2✔
205
  </div>
2✔
206
);
2✔
207

2✔
208
export const Integrations = () => {
7✔
209
  const [availableIntegrations, setAvailableIntegrations] = useState([]);
6✔
210
  const [configuredIntegrations, setConfiguredIntegrations] = useState([]);
6✔
211
  const [isConfiguringWebhook, setIsConfiguringWebhook] = useState(false);
6✔
212
  const integrations = useSelector(getExternalIntegrations);
6✔
213
  const isPreRelease = useSelector(getIsPreview);
6✔
214
  const dispatch = useDispatch();
6✔
215

2✔
216
  const { classes } = useStyles();
6✔
217

2✔
218
  useEffect(() => {
6✔
219
    const available = determineAvailableIntegrations(integrations, isPreRelease);
4✔
220
    setAvailableIntegrations(available);
4✔
221
    setConfiguredIntegrations(integrations.filter(integration => integration.provider !== EXTERNAL_PROVIDER.webhook.provider));
6✔
222
  }, [integrations, isPreRelease]);
2✔
223

2✔
224
  useEffect(() => {
6✔
225
    dispatch(getIntegrations());
3✔
226
  }, [dispatch]);
2✔
227

2✔
228
  const onConfigureIntegration = ({ target: { value: provider = '' } }) => {
6!
229
    if (provider === EXTERNAL_PROVIDER.webhook.provider) {
2!
230
      return setIsConfiguringWebhook(true);
2✔
231
    }
2✔
232
    setConfiguredIntegrations([...configuredIntegrations, { id: 'new', provider }]);
2✔
233
    setAvailableIntegrations(integrations => integrations.filter(integration => integration.provider !== provider));
2✔
234
  };
2✔
235

2✔
236
  const onCancelClick = ({ id, provider }) => {
6✔
237
    if (id === 'new') {
2!
238
      setAvailableIntegrations(current => [...current, EXTERNAL_PROVIDER[provider]].sort(customSort(true, 'provider')));
2✔
239
      setConfiguredIntegrations(current =>
2✔
240
        current.filter(
2✔
241
          integration => !(integration.id === id && integration.provider === provider && integration.provider !== EXTERNAL_PROVIDER.webhook.provider)
2!
242
        )
2✔
243
      );
2✔
244
    }
2✔
245
    setIsConfiguringWebhook(false);
2✔
246
  };
2✔
247

2✔
248
  const onSaveClick = integration => {
6✔
249
    if (integration.id === 'new') {
2!
250
      setIsConfiguringWebhook(false);
2✔
251
      return dispatch(createIntegration(integration));
2✔
252
    }
2✔
253
    dispatch(changeIntegration(integration));
2✔
254
  };
2✔
255

2✔
256
  const isConfiguring = configuredIntegrations.some(({ id }) => id === 'new');
8✔
257
  if (!!availableIntegrations.length && !integrations.length && !isConfiguring) {
6!
258
    return (
2✔
259
      <IntegrationsContainer>
2✔
260
        <FormControl>
2✔
261
          <InputLabel id="integration-select-label">Add an integration</InputLabel>
2✔
262
          <Select className={classes.select} label="Add an integration" labelId="integration-select-label" onChange={onConfigureIntegration} value="">
2✔
263
            {availableIntegrations.map(item => (
2✔
264
              <MenuItem key={item.provider} value={item.provider}>
2✔
265
                {item.title}
2✔
266
              </MenuItem>
2✔
267
            ))}
2✔
268
            <MenuItem value="webhook">Webhooks</MenuItem>
2✔
269
          </Select>
2✔
270
        </FormControl>
2✔
271
        {isConfiguringWebhook && <WebhookConfiguration onCancel={onCancelClick} onSubmit={onSaveClick} />}
2!
272
      </IntegrationsContainer>
2✔
273
    );
2✔
274
  }
2✔
275
  return (
6✔
276
    <IntegrationsContainer>
2✔
277
      {configuredIntegrations.map((integration, index) => (
2✔
278
        <IntegrationConfiguration
8✔
279
          key={integration.provider}
2✔
280
          integration={integration}
2✔
281
          isLast={configuredIntegrations.length === index + 1}
2✔
282
          onCancel={onCancelClick}
2✔
UNCOV
283
          onDelete={integration => dispatch(deleteIntegration(integration))}
2✔
284
          onSave={onSaveClick}
2✔
285
        />
2✔
286
      ))}
2✔
287
      <Webhooks />
2✔
288
      {!isConfiguring && (
2✔
289
        <InfoHint content="You can only have one active integration at a time. To use a different integration, you'll need to delete the current one first." />
2✔
290
      )}
2✔
291
    </IntegrationsContainer>
2✔
292
  );
2✔
293
};
2✔
294

2✔
295
export default Integrations;
2✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc