@visns-studio/visns-components 6.17.2 → 6.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +10 -7
- package/src/components/AssociationManager.jsx +344 -177
- package/src/components/AsyncSelect.jsx +2 -2
- package/src/components/Autocomplete.jsx +57 -12
- package/src/components/Breadcrumb.jsx +23 -4
- package/src/components/ClientAssociationManager.jsx +321 -145
- package/src/components/DataGrid.jsx +625 -20
- package/src/components/DropZone.jsx +4 -1
- package/src/components/Form.jsx +233 -6
- package/src/components/MultiSelect.jsx +2 -2
- package/src/components/Navigation.jsx +39 -23
- package/src/components/TableFilter.jsx +172 -12
- package/src/components/UniversalSearch.jsx +390 -0
- package/src/components/auth/Login.jsx +164 -8
- package/src/components/auth/Profile.jsx +209 -22
- package/src/components/auth/authEndpoints.js +4 -0
- package/src/components/auth/webauthn.js +194 -0
- package/src/components/columns/ColumnRenderers.jsx +6 -1
- package/src/components/controls/DataGridSearch.jsx +11 -5
- package/src/components/generic/GenericAuth.jsx +6 -0
- package/src/components/generic/GenericDashboard.jsx +93 -36
- package/src/components/generic/GenericDetail.jsx +146 -61
- package/src/components/generic/GenericIndex.jsx +134 -38
- package/src/components/generic/GenericSettings.jsx +220 -0
- package/src/components/generic/StandardModal.jsx +165 -8
- package/src/components/generic/dashboardChartDefaults.js +230 -0
- package/src/components/generic/shared/indexLayout.js +38 -0
- package/src/components/settings/IntegrationsManager.jsx +474 -0
- package/src/components/sms/SmsLineSettings.jsx +16 -12
- package/src/components/styles/AssociationManager.module.scss +4 -363
- package/src/components/styles/AuditLog.module.scss +6 -6
- package/src/components/styles/AuditLogs.module.scss +3 -3
- package/src/components/styles/Autocomplete.module.scss +7 -7
- package/src/components/styles/BusinessCardOcr.module.scss +6 -1
- package/src/components/styles/CallQueueSettings.module.scss +4 -1
- package/src/components/styles/CameraPlacement.module.scss +19 -19
- package/src/components/styles/CategorizedDropZone.module.scss +6 -1
- package/src/components/styles/ClientAssociationManager.module.scss +5 -289
- package/src/components/styles/ClientAuth.module.scss +1 -1
- package/src/components/styles/DataGrid.module.scss +455 -8
- package/src/components/styles/Field.module.scss +32 -24
- package/src/components/styles/Form.module.scss +123 -3
- package/src/components/styles/GenericDashboard.module.scss +612 -825
- package/src/components/styles/GenericDetail.module.scss +406 -61
- package/src/components/styles/GenericDynamic.module.scss +25 -25
- package/src/components/styles/GenericEditableTable.module.scss +33 -33
- package/src/components/styles/GenericFormBuilder.module.scss +119 -119
- package/src/components/styles/GenericIndex.module.scss +483 -38
- package/src/components/styles/GenericMain.module.scss +6 -3
- package/src/components/styles/GenericQuote.module.scss +399 -81
- package/src/components/styles/GenericReport.module.scss +8 -8
- package/src/components/styles/GenericSettings.module.scss +209 -0
- package/src/components/styles/GenericSort.module.scss +9 -9
- package/src/components/styles/Integrations.module.scss +321 -0
- package/src/components/styles/List.module.scss +4 -4
- package/src/components/styles/Login.module.scss +57 -0
- package/src/components/styles/MultiSelect.module.scss +4 -4
- package/src/components/styles/Navigation.module.scss +12 -12
- package/src/components/styles/Notification.module.scss +12 -8
- package/src/components/styles/NotificationList.module.scss +5 -5
- package/src/components/styles/Profile.module.scss +5 -5
- package/src/components/styles/QrCode.module.scss +4 -4
- package/src/components/styles/QuickAction.module.scss +20 -20
- package/src/components/styles/SortableQuoteItems.module.scss +4 -4
- package/src/components/styles/TableFilter.module.scss +128 -17
- package/src/components/styles/TicketConversation.module.scss +497 -0
- package/src/components/styles/UniversalSearch.module.scss +229 -0
- package/src/components/styles/Vault.module.scss +413 -0
- package/src/components/styles/_associationManager.scss +467 -0
- package/src/components/styles/_authBrandPanel.scss +15 -9
- package/src/components/styles/_controls.scss +43 -39
- package/src/components/styles/global-datagrid.css +196 -0
- package/src/components/styles/global.css +168 -17
- package/src/components/tickets/TicketConversation.jsx +455 -0
- package/src/components/utils/rowActionSettings.js +117 -0
- package/src/components/vault/VaultClientPicker.jsx +183 -0
- package/src/components/vault/VaultEntryForm.jsx +23 -0
- package/src/components/vault/VaultManager.jsx +382 -34
- package/src/components/vault/VaultShareModal.jsx +454 -0
- package/src/components/vault/vaultEndpoints.js +16 -2
- package/src/components/vault/vaultShares.js +247 -0
- package/src/index.js +35 -0
- package/src/utils/fetchUtil.js +38 -14
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { isFlatFilterList } from '../../TableFilter';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Should this index lay its filter menu out as a horizontal tab strip?
|
|
5
|
+
*
|
|
6
|
+
* Split out of GenericIndex rather than written inline because it is the one
|
|
7
|
+
* decision in the embedded-index work that can be wrong silently: get it
|
|
8
|
+
* wrong and the page builds a strip-shaped container around a vertical rail,
|
|
9
|
+
* which no test of the rendered page would catch either — GenericIndex pulls
|
|
10
|
+
* in the datagrid and cannot be imported by the test runner at all.
|
|
11
|
+
*
|
|
12
|
+
* Three conditions, all necessary:
|
|
13
|
+
*
|
|
14
|
+
* - the caller asked for it (`tabLayout === 'underline'`; the default,
|
|
15
|
+
* `'rail'`, is what every index page has always rendered);
|
|
16
|
+
* - the layout is not `simple`, which builds its own `tabcontent` frame that
|
|
17
|
+
* a strip has no place in;
|
|
18
|
+
* - the filters are a FLAT list. `isFlatFilterList` is TableFilter's own
|
|
19
|
+
* gate for the strip, imported rather than repeated so the container built
|
|
20
|
+
* here can never disagree with the markup TableFilter puts inside it. A
|
|
21
|
+
* rail with expandable parents is a two-level menu with no single row to
|
|
22
|
+
* lay out, and keeps the rail.
|
|
23
|
+
*
|
|
24
|
+
* @param {object} options
|
|
25
|
+
* @param {string} [options.tabLayout] 'rail' (default) or 'underline'.
|
|
26
|
+
* @param {string} [options.layout] GenericIndex's `layout` prop.
|
|
27
|
+
* @param {Array} [options.filters] The live subnav items.
|
|
28
|
+
* @returns {boolean}
|
|
29
|
+
*/
|
|
30
|
+
export function shouldUnderlineTabs({ tabLayout, layout, filters } = {}) {
|
|
31
|
+
return (
|
|
32
|
+
tabLayout === 'underline' &&
|
|
33
|
+
layout !== 'simple' &&
|
|
34
|
+
isFlatFilterList(filters)
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export default shouldUnderlineTabs;
|
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { toast } from 'react-toastify';
|
|
3
|
+
import {
|
|
4
|
+
AlertTriangle,
|
|
5
|
+
Check,
|
|
6
|
+
ExternalLink,
|
|
7
|
+
Link2,
|
|
8
|
+
Loader2,
|
|
9
|
+
Plug,
|
|
10
|
+
RefreshCw,
|
|
11
|
+
Trash2,
|
|
12
|
+
X,
|
|
13
|
+
} from 'lucide-react';
|
|
14
|
+
|
|
15
|
+
import CustomFetch from '../Fetch';
|
|
16
|
+
import styles from '../styles/Integrations.module.scss';
|
|
17
|
+
|
|
18
|
+
const DEFAULT_BASE = '/ajax/integrations';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Settings → Integrations.
|
|
22
|
+
*
|
|
23
|
+
* One card per connected system. Two kinds, and the card shape is the same for
|
|
24
|
+
* both because to the person configuring them they are the same job:
|
|
25
|
+
*
|
|
26
|
+
* oauth2 — fill in the client id/secret, then Connect, which sends the
|
|
27
|
+
* browser to the provider's consent screen.
|
|
28
|
+
* api_key — fill in the fields and save.
|
|
29
|
+
*
|
|
30
|
+
* A SECRET IS NEVER SENT BACK TO THIS PAGE. The API reports which fields hold
|
|
31
|
+
* a value, not what they hold, so a secret field renders empty with a "Set"
|
|
32
|
+
* badge beside it. Leaving it empty on save means "leave it alone" — that is
|
|
33
|
+
* the whole reason the API treats an absent key as unchanged. Clearing one
|
|
34
|
+
* takes a deliberate press of the field's clear button, which sends an
|
|
35
|
+
* explicit empty string.
|
|
36
|
+
*/
|
|
37
|
+
const IntegrationsManager = ({ base = DEFAULT_BASE, canManage = true }) => {
|
|
38
|
+
const [items, setItems] = useState([]);
|
|
39
|
+
const [loading, setLoading] = useState(true);
|
|
40
|
+
const [busy, setBusy] = useState(null);
|
|
41
|
+
const [drafts, setDrafts] = useState({});
|
|
42
|
+
const mountedRef = useRef(true);
|
|
43
|
+
|
|
44
|
+
useEffect(() => () => {
|
|
45
|
+
mountedRef.current = false;
|
|
46
|
+
}, []);
|
|
47
|
+
|
|
48
|
+
const load = useCallback(() => {
|
|
49
|
+
setLoading(true);
|
|
50
|
+
|
|
51
|
+
CustomFetch(base, 'GET', {}, (result) => {
|
|
52
|
+
if (!mountedRef.current) return;
|
|
53
|
+
|
|
54
|
+
setItems(Array.isArray(result?.data) ? result.data : []);
|
|
55
|
+
setLoading(false);
|
|
56
|
+
}, null, { cache: false }).catch(() => {
|
|
57
|
+
if (!mountedRef.current) return;
|
|
58
|
+
|
|
59
|
+
setItems([]);
|
|
60
|
+
setLoading(false);
|
|
61
|
+
});
|
|
62
|
+
}, [base]);
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
load();
|
|
66
|
+
}, [load]);
|
|
67
|
+
|
|
68
|
+
const draftFor = (provider) => drafts[provider] ?? {};
|
|
69
|
+
|
|
70
|
+
const setDraft = (provider, key, value) =>
|
|
71
|
+
setDrafts((prev) => ({
|
|
72
|
+
...prev,
|
|
73
|
+
[provider]: { ...(prev[provider] ?? {}), [key]: value },
|
|
74
|
+
}));
|
|
75
|
+
|
|
76
|
+
const save = (item) => {
|
|
77
|
+
const draft = draftFor(item.provider);
|
|
78
|
+
|
|
79
|
+
if (Object.keys(draft).length === 0) {
|
|
80
|
+
toast.info('Nothing changed.');
|
|
81
|
+
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
setBusy(item.provider);
|
|
86
|
+
|
|
87
|
+
CustomFetch(
|
|
88
|
+
`${base}/${item.provider}`,
|
|
89
|
+
'PUT',
|
|
90
|
+
{ credentials: draft },
|
|
91
|
+
(result) => {
|
|
92
|
+
if (!mountedRef.current) return;
|
|
93
|
+
|
|
94
|
+
setBusy(null);
|
|
95
|
+
setDrafts((prev) => ({ ...prev, [item.provider]: {} }));
|
|
96
|
+
toast.success(`${item.name} saved`);
|
|
97
|
+
|
|
98
|
+
if (result?.data) {
|
|
99
|
+
setItems((prev) =>
|
|
100
|
+
prev.map((i) =>
|
|
101
|
+
i.provider === item.provider ? result.data : i
|
|
102
|
+
)
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
null
|
|
107
|
+
).catch(() => {
|
|
108
|
+
if (!mountedRef.current) return;
|
|
109
|
+
|
|
110
|
+
setBusy(null);
|
|
111
|
+
toast.error(`Could not save ${item.name}`);
|
|
112
|
+
});
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const test = (item) => {
|
|
116
|
+
setBusy(item.provider);
|
|
117
|
+
|
|
118
|
+
CustomFetch(
|
|
119
|
+
`${base}/${item.provider}/test`,
|
|
120
|
+
'POST',
|
|
121
|
+
{},
|
|
122
|
+
(result) => {
|
|
123
|
+
if (!mountedRef.current) return;
|
|
124
|
+
|
|
125
|
+
setBusy(null);
|
|
126
|
+
toast.success(result?.data?.message || 'Connected');
|
|
127
|
+
load();
|
|
128
|
+
},
|
|
129
|
+
null
|
|
130
|
+
).catch((error) => {
|
|
131
|
+
if (!mountedRef.current) return;
|
|
132
|
+
|
|
133
|
+
setBusy(null);
|
|
134
|
+
// The probe answers 422 with the reason it failed, and that reason
|
|
135
|
+
// is the entire value of the button.
|
|
136
|
+
toast.error(error?.data?.data?.message || `${item.name} refused the credentials`);
|
|
137
|
+
load();
|
|
138
|
+
});
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const connect = (item) => {
|
|
142
|
+
setBusy(item.provider);
|
|
143
|
+
|
|
144
|
+
CustomFetch(
|
|
145
|
+
`${base}/${item.provider}/authorize-url`,
|
|
146
|
+
'GET',
|
|
147
|
+
{},
|
|
148
|
+
(result) => {
|
|
149
|
+
const url = result?.data?.url;
|
|
150
|
+
|
|
151
|
+
if (!url) {
|
|
152
|
+
setBusy(null);
|
|
153
|
+
toast.error('Could not build the consent URL.');
|
|
154
|
+
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// A full navigation, not a fetch: the consent screen is the
|
|
159
|
+
// provider's own page and cannot be loaded cross-origin.
|
|
160
|
+
window.location.href = url;
|
|
161
|
+
},
|
|
162
|
+
null
|
|
163
|
+
).catch((error) => {
|
|
164
|
+
if (!mountedRef.current) return;
|
|
165
|
+
|
|
166
|
+
setBusy(null);
|
|
167
|
+
toast.error(error?.data?.message || 'Could not start the connection.');
|
|
168
|
+
});
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const disconnect = (item) => {
|
|
172
|
+
setBusy(item.provider);
|
|
173
|
+
|
|
174
|
+
CustomFetch(
|
|
175
|
+
`${base}/${item.provider}`,
|
|
176
|
+
'DELETE',
|
|
177
|
+
{},
|
|
178
|
+
(result) => {
|
|
179
|
+
if (!mountedRef.current) return;
|
|
180
|
+
|
|
181
|
+
setBusy(null);
|
|
182
|
+
|
|
183
|
+
// Clearing the database does not clear the server's .env, and
|
|
184
|
+
// saying "disconnected" when a key is still resolving from
|
|
185
|
+
// there would be a lie the user cannot see through.
|
|
186
|
+
toast.success(
|
|
187
|
+
result?.still_configured_from_env
|
|
188
|
+
? `${item.name} cleared — it is still configured from the server environment.`
|
|
189
|
+
: `${item.name} disconnected`
|
|
190
|
+
);
|
|
191
|
+
load();
|
|
192
|
+
},
|
|
193
|
+
null
|
|
194
|
+
).catch(() => {
|
|
195
|
+
if (!mountedRef.current) return;
|
|
196
|
+
|
|
197
|
+
setBusy(null);
|
|
198
|
+
toast.error('Could not disconnect.');
|
|
199
|
+
});
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const grouped = useMemo(() => {
|
|
203
|
+
const out = new Map();
|
|
204
|
+
|
|
205
|
+
items.forEach((item) => {
|
|
206
|
+
const key = item.category || 'Other';
|
|
207
|
+
|
|
208
|
+
if (!out.has(key)) out.set(key, []);
|
|
209
|
+
out.get(key).push(item);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
return [...out.entries()];
|
|
213
|
+
}, [items]);
|
|
214
|
+
|
|
215
|
+
if (loading) {
|
|
216
|
+
return (
|
|
217
|
+
<div className={styles.loading}>
|
|
218
|
+
<Loader2 size={18} className={styles.spin} />
|
|
219
|
+
Loading integrations…
|
|
220
|
+
</div>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (items.length === 0) {
|
|
225
|
+
return (
|
|
226
|
+
<p className={styles.empty}>
|
|
227
|
+
No integrations are configured for this application.
|
|
228
|
+
</p>
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return (
|
|
233
|
+
<div className={styles.wrap}>
|
|
234
|
+
{grouped.map(([category, group]) => (
|
|
235
|
+
<section key={category} className={styles.group}>
|
|
236
|
+
<h3 className={styles.groupTitle}>{category}</h3>
|
|
237
|
+
|
|
238
|
+
{group.map((item) => (
|
|
239
|
+
<IntegrationCard
|
|
240
|
+
key={item.provider}
|
|
241
|
+
item={item}
|
|
242
|
+
busy={busy === item.provider}
|
|
243
|
+
canManage={canManage}
|
|
244
|
+
draft={draftFor(item.provider)}
|
|
245
|
+
onChange={(k, v) => setDraft(item.provider, k, v)}
|
|
246
|
+
onSave={() => save(item)}
|
|
247
|
+
onTest={() => test(item)}
|
|
248
|
+
onConnect={() => connect(item)}
|
|
249
|
+
onDisconnect={() => disconnect(item)}
|
|
250
|
+
/>
|
|
251
|
+
))}
|
|
252
|
+
</section>
|
|
253
|
+
))}
|
|
254
|
+
</div>
|
|
255
|
+
);
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const STATUS_TONE = {
|
|
259
|
+
connected: 'ok',
|
|
260
|
+
configured: 'ready',
|
|
261
|
+
incomplete: 'warn',
|
|
262
|
+
expired: 'bad',
|
|
263
|
+
disabled: 'muted',
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const IntegrationCard = ({
|
|
267
|
+
item,
|
|
268
|
+
busy,
|
|
269
|
+
canManage,
|
|
270
|
+
draft,
|
|
271
|
+
onChange,
|
|
272
|
+
onSave,
|
|
273
|
+
onTest,
|
|
274
|
+
onConnect,
|
|
275
|
+
onDisconnect,
|
|
276
|
+
}) => {
|
|
277
|
+
const tone = STATUS_TONE[item.status?.state] || 'muted';
|
|
278
|
+
const isOAuth = item.driver === 'oauth2';
|
|
279
|
+
const connected = item.status?.state === 'connected';
|
|
280
|
+
const dirty = Object.keys(draft).length > 0;
|
|
281
|
+
|
|
282
|
+
return (
|
|
283
|
+
<article className={styles.card}>
|
|
284
|
+
<header className={styles.cardHead}>
|
|
285
|
+
<span className={styles.cardIcon} aria-hidden="true">
|
|
286
|
+
<Plug size={16} strokeWidth={2} />
|
|
287
|
+
</span>
|
|
288
|
+
|
|
289
|
+
<div className={styles.cardTitle}>
|
|
290
|
+
<h4>{item.name}</h4>
|
|
291
|
+
{item.description && <p>{item.description}</p>}
|
|
292
|
+
{item.services?.length > 0 && (
|
|
293
|
+
<p className={styles.services}>
|
|
294
|
+
{item.services.join(' · ')}
|
|
295
|
+
</p>
|
|
296
|
+
)}
|
|
297
|
+
</div>
|
|
298
|
+
|
|
299
|
+
{/* Never colour alone: the badge carries a word as well. */}
|
|
300
|
+
<span className={`${styles.badge} ${styles[tone]}`}>
|
|
301
|
+
{tone === 'ok' && <Check size={12} strokeWidth={3} />}
|
|
302
|
+
{tone === 'warn' && <AlertTriangle size={12} strokeWidth={2.5} />}
|
|
303
|
+
{tone === 'bad' && <X size={12} strokeWidth={3} />}
|
|
304
|
+
{item.status?.label}
|
|
305
|
+
</span>
|
|
306
|
+
</header>
|
|
307
|
+
|
|
308
|
+
{item.fields?.length > 0 && (
|
|
309
|
+
<div className={styles.fields}>
|
|
310
|
+
{item.fields.map((field) => (
|
|
311
|
+
<CredentialField
|
|
312
|
+
key={field.key}
|
|
313
|
+
field={field}
|
|
314
|
+
value={draft[field.key]}
|
|
315
|
+
disabled={!canManage || busy}
|
|
316
|
+
onChange={(v) => onChange(field.key, v)}
|
|
317
|
+
/>
|
|
318
|
+
))}
|
|
319
|
+
</div>
|
|
320
|
+
)}
|
|
321
|
+
|
|
322
|
+
{isOAuth && item.redirect_uri && (
|
|
323
|
+
<p className={styles.redirect}>
|
|
324
|
+
<strong>Redirect URI</strong> — paste this into the provider’s
|
|
325
|
+
console exactly as shown:
|
|
326
|
+
<code>{item.redirect_uri}</code>
|
|
327
|
+
</p>
|
|
328
|
+
)}
|
|
329
|
+
|
|
330
|
+
{item.last_test && (
|
|
331
|
+
<p
|
|
332
|
+
className={
|
|
333
|
+
item.last_test.status === 'ok'
|
|
334
|
+
? styles.testOk
|
|
335
|
+
: styles.testBad
|
|
336
|
+
}
|
|
337
|
+
>
|
|
338
|
+
Last test {new Date(item.last_test.at).toLocaleString()} —{' '}
|
|
339
|
+
{item.last_test.message}
|
|
340
|
+
</p>
|
|
341
|
+
)}
|
|
342
|
+
|
|
343
|
+
<footer className={styles.cardFoot}>
|
|
344
|
+
{item.docs_url && (
|
|
345
|
+
<a
|
|
346
|
+
className={styles.docs}
|
|
347
|
+
href={item.docs_url}
|
|
348
|
+
target="_blank"
|
|
349
|
+
rel="noreferrer noopener"
|
|
350
|
+
>
|
|
351
|
+
Provider console <ExternalLink size={12} />
|
|
352
|
+
</a>
|
|
353
|
+
)}
|
|
354
|
+
|
|
355
|
+
<span className={styles.spacer} />
|
|
356
|
+
|
|
357
|
+
{canManage && (connected || item.fields?.some((f) => f.source === 'database')) && (
|
|
358
|
+
<button
|
|
359
|
+
type="button"
|
|
360
|
+
className={styles.ghost}
|
|
361
|
+
onClick={onDisconnect}
|
|
362
|
+
disabled={busy}
|
|
363
|
+
>
|
|
364
|
+
<Trash2 size={13} /> Clear
|
|
365
|
+
</button>
|
|
366
|
+
)}
|
|
367
|
+
|
|
368
|
+
{item.can_test && (
|
|
369
|
+
<button
|
|
370
|
+
type="button"
|
|
371
|
+
className={styles.secondary}
|
|
372
|
+
onClick={onTest}
|
|
373
|
+
disabled={busy}
|
|
374
|
+
>
|
|
375
|
+
{busy ? (
|
|
376
|
+
<Loader2 size={13} className={styles.spin} />
|
|
377
|
+
) : (
|
|
378
|
+
<RefreshCw size={13} />
|
|
379
|
+
)}
|
|
380
|
+
Test connection
|
|
381
|
+
</button>
|
|
382
|
+
)}
|
|
383
|
+
|
|
384
|
+
{canManage && (
|
|
385
|
+
<button
|
|
386
|
+
type="button"
|
|
387
|
+
className={styles.primary}
|
|
388
|
+
onClick={onSave}
|
|
389
|
+
disabled={busy || !dirty}
|
|
390
|
+
>
|
|
391
|
+
Save
|
|
392
|
+
</button>
|
|
393
|
+
)}
|
|
394
|
+
|
|
395
|
+
{canManage && isOAuth && (
|
|
396
|
+
<button
|
|
397
|
+
type="button"
|
|
398
|
+
className={styles.primary}
|
|
399
|
+
onClick={onConnect}
|
|
400
|
+
disabled={busy || item.status?.state === 'incomplete'}
|
|
401
|
+
title={
|
|
402
|
+
item.status?.state === 'incomplete'
|
|
403
|
+
? 'Fill in the required fields first'
|
|
404
|
+
: undefined
|
|
405
|
+
}
|
|
406
|
+
>
|
|
407
|
+
<Link2 size={13} />
|
|
408
|
+
{connected ? 'Reconnect' : 'Connect'}
|
|
409
|
+
</button>
|
|
410
|
+
)}
|
|
411
|
+
</footer>
|
|
412
|
+
</article>
|
|
413
|
+
);
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* One credential field.
|
|
418
|
+
*
|
|
419
|
+
* A secret renders EMPTY even when it holds a value, because the value never
|
|
420
|
+
* left the server. "Set" beside the label is the only thing said about it, and
|
|
421
|
+
* `source` says where it came from — the difference between "I still need to
|
|
422
|
+
* type this" and "this is already in the server's .env".
|
|
423
|
+
*/
|
|
424
|
+
const CredentialField = ({ field, value, disabled, onChange }) => {
|
|
425
|
+
const id = `integration-${field.key}`;
|
|
426
|
+
const isSelect = field.type === 'select' && Array.isArray(field.options);
|
|
427
|
+
|
|
428
|
+
return (
|
|
429
|
+
<div className={styles.field}>
|
|
430
|
+
<label htmlFor={id}>
|
|
431
|
+
{field.label}
|
|
432
|
+
{field.required && <span aria-hidden="true"> *</span>}
|
|
433
|
+
{field.is_set && (
|
|
434
|
+
<span className={styles.setTag}>
|
|
435
|
+
Set{field.source === 'env' ? ' · from .env' : ''}
|
|
436
|
+
</span>
|
|
437
|
+
)}
|
|
438
|
+
</label>
|
|
439
|
+
|
|
440
|
+
{isSelect ? (
|
|
441
|
+
<select
|
|
442
|
+
id={id}
|
|
443
|
+
disabled={disabled}
|
|
444
|
+
value={value ?? field.value ?? ''}
|
|
445
|
+
onChange={(e) => onChange(e.target.value)}
|
|
446
|
+
>
|
|
447
|
+
{field.options.map((option) => (
|
|
448
|
+
<option key={option} value={option}>
|
|
449
|
+
{option}
|
|
450
|
+
</option>
|
|
451
|
+
))}
|
|
452
|
+
</select>
|
|
453
|
+
) : (
|
|
454
|
+
<input
|
|
455
|
+
id={id}
|
|
456
|
+
type={field.secret ? 'password' : 'text'}
|
|
457
|
+
autoComplete={field.secret ? 'new-password' : 'off'}
|
|
458
|
+
disabled={disabled}
|
|
459
|
+
placeholder={
|
|
460
|
+
field.secret && field.is_set
|
|
461
|
+
? '•••••••• (leave blank to keep)'
|
|
462
|
+
: field.placeholder || ''
|
|
463
|
+
}
|
|
464
|
+
value={value ?? (field.secret ? '' : field.value ?? '')}
|
|
465
|
+
onChange={(e) => onChange(e.target.value)}
|
|
466
|
+
/>
|
|
467
|
+
)}
|
|
468
|
+
|
|
469
|
+
{field.help && <p className={styles.help}>{field.help}</p>}
|
|
470
|
+
</div>
|
|
471
|
+
);
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
export default IntegrationsManager;
|
|
@@ -44,6 +44,8 @@ const SmsLineSettings = ({
|
|
|
44
44
|
// Accepted for consistency with the CRM's other settings screens.
|
|
45
45
|
userProfile: _userProfile = null,
|
|
46
46
|
staffOptions = [],
|
|
47
|
+
// `null` drops the header, for a host that already printed a heading above
|
|
48
|
+
// this one — the settings hub prints the section title and description.
|
|
47
49
|
pageTitle = 'Messaging',
|
|
48
50
|
}) => {
|
|
49
51
|
const routes = useMemo(() => resolveSmsEndpoints(endpoints), [endpoints]);
|
|
@@ -259,19 +261,21 @@ const SmsLineSettings = ({
|
|
|
259
261
|
|
|
260
262
|
return (
|
|
261
263
|
<div className={styles.settings}>
|
|
262
|
-
|
|
263
|
-
<div>
|
|
264
|
-
<
|
|
265
|
-
<
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
264
|
+
{pageTitle !== null && (
|
|
265
|
+
<div className={styles.settingsHead}>
|
|
266
|
+
<div>
|
|
267
|
+
<h1 className={styles.pageTitle}>
|
|
268
|
+
<MessageSquare size={22} strokeWidth={2.2} aria-hidden="true" />
|
|
269
|
+
{pageTitle}
|
|
270
|
+
</h1>
|
|
271
|
+
<p className={styles.pageIntro}>
|
|
272
|
+
A line is one Zoom Phone number and the staff who can see its
|
|
273
|
+
conversations. Everything staff send goes out from the line they
|
|
274
|
+
are replying on.
|
|
275
|
+
</p>
|
|
276
|
+
</div>
|
|
273
277
|
</div>
|
|
274
|
-
|
|
278
|
+
)}
|
|
275
279
|
|
|
276
280
|
<div className={styles.statusCard}>
|
|
277
281
|
<div className={styles.statusItem}>
|