Line data Source code
1 : import * as React from 'react';
2 : import * as style from './connection_settings.module.css';
3 : import * as connection from '@ankoh/dashql-jsonschema/connection.js';
4 :
5 : import { KeyIcon, PersonIcon } from '../ui/foundations/symbol_icon.js';
6 :
7 : import { useConnectionById, useConnectionDraftForm } from './connection_registry.js';
8 : import { useConnectionSave } from './connection_persistence.js';
9 : import { useConnectionSetup } from './connection_setup.js';
10 : import { ConnectionHealth, ConnectionStatus } from './connection_state.js';
11 : import { performHealthCheck } from './health_check.js';
12 : import { useQueryExecutor } from './query_executor.js';
13 : import { useSalesforceSetup } from './salesforce/salesforce_connector.js';
14 : import type { SalesforceConnectorType } from './salesforce/salesforce_connection_setup.js';
15 : import { getSalesforceConnectionDetails } from './salesforce/salesforce_connection_state.js';
16 : import {
17 : TextField,
18 : TextFieldValidationStatus,
19 : VALIDATION_ERROR,
20 : VALIDATION_UNKNOWN,
21 : } from '../ui/foundations/text_field.js';
22 : import { IndicatorStatus } from '../ui/foundations/status_indicator.js';
23 : import { classNames } from '../utils/classnames.js';
24 : import { Logger } from '../platform/logger/logger.js';
25 : import { CONNECTOR_INFOS, ConnectorType, HYPER_CONNECTOR, SALESFORCE_DATA_CLOUD_CONNECTOR, SIDB_CONNECTOR, TRINO_CONNECTOR } from './connector_info.js';
26 : import { CONNECTOR_DESCRIPTORS, connectionParamsOf } from './connector_descriptor.js';
27 : import { ConnectionStateDetailsVariant } from './connection_state_details.js';
28 : import type { DetailedError } from '../utils/error.js';
29 : import { ConnectionInlineHeader } from './connection_inline_header.js';
30 : import { collectSalesforceAuthInfo } from './salesforce/salesforce_api_client.js';
31 : import { SIDB_DEFAULT_DATABASE_NAME } from './salesforce/salesforce_catalog_update.js';
32 :
33 1 : const LOG_CTX = "sf_connector";
34 :
35 : interface PageState {
36 : instanceUrl: string;
37 : appConsumerKey: string;
38 : login: string;
39 : databaseName: string;
40 : };
41 :
42 1 : export function getConnectionStatusText(status: ConnectionStatus | undefined, logger: Logger) {
43 1 : switch (status) {
44 1 : case ConnectionStatus.NOT_STARTED:
45 1 : return "Disconnected";
46 0 : case ConnectionStatus.AUTH_STARTED:
47 0 : return "Starting authorization";
48 0 : case ConnectionStatus.AUTH_CANCELLED:
49 0 : return "Cancelled authorization";
50 0 : case ConnectionStatus.AUTH_FAILED:
51 0 : return "Authorization failed";
52 0 : case ConnectionStatus.PKCE_GENERATION_STARTED:
53 0 : return "Generating PKCE challenge";
54 0 : case ConnectionStatus.PKCE_GENERATED:
55 0 : return "Generated PKCE challenge";
56 0 : case ConnectionStatus.WAITING_FOR_OAUTH_CODE_VIA_LINK:
57 0 : case ConnectionStatus.WAITING_FOR_OAUTH_CODE_VIA_WINDOW:
58 0 : return "Waiting for OAuth code";
59 0 : case ConnectionStatus.OAUTH_CODE_RECEIVED:
60 0 : return "Received OAuth code";
61 0 : case ConnectionStatus.ACCESS_TOKEN_REQUESTED:
62 0 : return "Requesting access token";
63 0 : case ConnectionStatus.ACCESS_TOKEN_RECEIVED:
64 0 : return "Received access token";
65 0 : case ConnectionStatus.CORE_ACCESS_TOKEN_REQUESTED:
66 0 : return "Requesting Core access token";
67 0 : case ConnectionStatus.CORE_ACCESS_TOKEN_RECEIVED:
68 0 : return "Received Core access token";
69 0 : case ConnectionStatus.DATA_CLOUD_TOKEN_REQUESTED:
70 0 : return "Requesting Data Cloud access token";
71 0 : case ConnectionStatus.DATA_CLOUD_TOKEN_RECEIVED:
72 0 : return "Received Data Cloud access token";
73 1 : case ConnectionStatus.CHANNEL_SETUP_STARTED:
74 1 : return "Creating channel";
75 0 : case ConnectionStatus.CHANNEL_SETUP_FAILED:
76 0 : return "Failed to create channel";
77 0 : case ConnectionStatus.CHANNEL_SETUP_CANCELLED:
78 0 : return "Cancelled channel setup";
79 0 : case ConnectionStatus.CHANNEL_READY:
80 0 : return "Channel is ready";
81 1 : case ConnectionStatus.HEALTH_CHECK_STARTED:
82 1 : return "Health check started";
83 0 : case ConnectionStatus.HEALTH_CHECK_FAILED:
84 0 : return "Health check failed";
85 0 : case ConnectionStatus.HEALTH_CHECK_CANCELLED:
86 0 : return "Health check cancelled";
87 1 : case ConnectionStatus.HEALTH_CHECK_SUCCEEDED:
88 1 : return "Health check succeeded";
89 1 : case undefined:
90 1 : break;
91 0 : default:
92 0 : logger.warn("unexpected connection status", { "status": status });
93 1 : }
94 1 : return "";
95 1 : }
96 :
97 1 : export function getConnectionHealthIndicator(health: ConnectionHealth | null) {
98 1 : switch (health) {
99 1 : case ConnectionHealth.NOT_STARTED:
100 1 : return IndicatorStatus.None;
101 1 : case ConnectionHealth.ONLINE:
102 1 : return IndicatorStatus.Succeeded;
103 1 : case ConnectionHealth.FAILED:
104 1 : return IndicatorStatus.Failed;
105 1 : case ConnectionHealth.CONNECTING:
106 1 : return IndicatorStatus.Running;
107 1 : default:
108 1 : return IndicatorStatus.None;
109 1 : }
110 1 : }
111 :
112 1 : export function getConnectionError(status: ConnectionStateDetailsVariant | null): (DetailedError | null) {
113 1 : switch (status?.type) {
114 1 : case TRINO_CONNECTOR:
115 1 : return (status.value.proto.channelError ?? status.value.proto.healthCheckError ?? null) as DetailedError | null;
116 0 : case SALESFORCE_DATA_CLOUD_CONNECTOR:
117 0 : case SIDB_CONNECTOR:
118 0 : return (status.value.proto.channelError ?? status.value.proto.healthCheckError ?? null) as DetailedError | null;
119 1 : case HYPER_CONNECTOR:
120 1 : return (status.value.proto.channelError ?? status.value.proto.healthCheckError ?? null) as DetailedError | null;
121 1 : default:
122 1 : return null;
123 1 : }
124 1 : }
125 :
126 : interface Props {
127 : connectionId: string | null;
128 : /// The connector of the settings
129 : connector: SalesforceConnectorType;
130 : onClose?: () => void;
131 : }
132 :
133 : export interface SalesforceConnectionAliasField {
134 : inputRef?: React.Ref<HTMLInputElement>;
135 : value: string;
136 : onChange: React.ChangeEventHandler<HTMLInputElement>;
137 : validation?: TextFieldValidationStatus;
138 : name?: string;
139 : caption?: string;
140 : }
141 :
142 : interface SalesforceConnectionSettingsPageProps {
143 : connectionState: ReturnType<typeof useConnectionById>[0];
144 : /// The connector of the settings, Salesforce Data Cloud if not set
145 : connector?: SalesforceConnectorType;
146 : wrongPlatform: boolean;
147 : freezeInput: boolean;
148 : instanceUrl: string;
149 : appConsumerKey: string;
150 : login: string;
151 : /// The Hyper Cloud database, only shown for SIDB connections
152 : databaseName?: string;
153 : coreAccessToken: string;
154 : dataCloudAccessToken: string;
155 : dataCloudInstanceUrl: string;
156 : coreTenantId: string;
157 : dataCloudTenantId: string;
158 : instanceUrlValidation: TextFieldValidationStatus;
159 : appConsumerValidation: TextFieldValidationStatus;
160 : updateInstanceUrl: React.ChangeEventHandler<HTMLInputElement>;
161 : updateAppConsumerKey: React.ChangeEventHandler<HTMLInputElement>;
162 : updateLogin: React.ChangeEventHandler<HTMLInputElement>;
163 : updateDatabaseName?: React.ChangeEventHandler<HTMLInputElement>;
164 : setupConnection: () => void;
165 : cancelSetup: () => void;
166 : resetSetup: () => void;
167 : onClose?: () => void;
168 : alias?: SalesforceConnectionAliasField;
169 : connectorNameAction?: React.ReactNode;
170 : statusText?: string;
171 : indicatorStatus?: IndicatorStatus;
172 : statusError?: string | null;
173 : connectionHealth?: ConnectionHealth;
174 : }
175 :
176 1 : export const SalesforceConnectionSettingsPage: React.FC<SalesforceConnectionSettingsPageProps> = props => {
177 1 : const connectorInfo = CONNECTOR_INFOS[props.connector ?? ConnectorType.SALESFORCE_DATA_CLOUD];
178 1 : return (
179 1 : <div className={style.layout}>
180 1 : <ConnectionInlineHeader
181 1 : connector={connectorInfo}
182 1 : connection={props.connectionState}
183 1 : wrongPlatform={props.wrongPlatform}
184 1 : setupConnection={props.setupConnection}
185 1 : cancelSetup={props.cancelSetup}
186 1 : resetSetup={props.resetSetup}
187 1 : freezeInput={props.freezeInput}
188 1 : onClose={props.onClose}
189 1 : statusText={props.statusText}
190 1 : indicatorStatus={props.indicatorStatus}
191 1 : statusError={props.statusError}
192 1 : connectionHealth={props.connectionHealth}
193 1 : connectorNameAction={props.connectorNameAction}
194 1 : />
195 1 : <div className={style.body_container}>
196 1 : {props.alias && (
197 1 : <div className={style.section}>
198 1 : <div className={classNames(style.section_layout, style.body_section_layout)}>
199 1 : <TextField
200 1 : inputRef={props.alias.inputRef}
201 1 : data-testid="salesforce-alias"
202 1 : name={props.alias.name ?? 'Attached Database Alias'}
203 1 : caption={props.alias.caption ?? 'Name saved in login history and used to address this attached database in the shell'}
204 1 : value={props.alias.value}
205 1 : onChange={props.alias.onChange}
206 1 : placeholder="Alias"
207 1 : leadingVisual={() => <div>AS</div>}
208 1 : validation={props.alias.validation}
209 1 : logContext={LOG_CTX}
210 1 : disabled={props.freezeInput}
211 1 : readOnly={props.freezeInput}
212 1 : />
213 1 : </div>
214 1 : </div>
215 : )}
216 1 : <div className={style.section}>
217 1 : <div className={classNames(style.section_layout, style.body_section_layout)}>
218 1 : <TextField
219 1 : name="Salesforce Instance URL"
220 1 : data-testid="salesforce-instance-url"
221 1 : caption="URL of the Salesforce Instance"
222 1 : value={props.instanceUrl}
223 1 : onChange={props.updateInstanceUrl}
224 1 : placeholder="Salesforce Instance"
225 1 : leadingVisual={() => <div>URL</div>}
226 1 : validation={props.instanceUrlValidation}
227 1 : logContext={LOG_CTX}
228 1 : disabled={props.freezeInput}
229 1 : readOnly={props.freezeInput}
230 1 : />
231 1 : <TextField
232 1 : name="Connected App"
233 1 : data-testid="salesforce-app-consumer-key"
234 1 : caption="Setup > App Manager > [App] > Manage Consumer Details"
235 1 : value={props.appConsumerKey}
236 1 : onChange={props.updateAppConsumerKey}
237 1 : placeholder="Consumer Key"
238 1 : leadingVisual={() => <div>ID</div>}
239 1 : validation={props.appConsumerValidation}
240 1 : logContext={LOG_CTX}
241 1 : disabled={props.freezeInput}
242 1 : readOnly={props.freezeInput}
243 1 : />
244 1 : <TextField
245 1 : name="Salesforce Login"
246 1 : data-testid="salesforce-login"
247 1 : caption="Account offered as the sign-in hint"
248 1 : value={props.login}
249 1 : placeholder=""
250 1 : leadingVisual={PersonIcon}
251 1 : onChange={props.updateLogin}
252 1 : readOnly={props.freezeInput}
253 1 : disabled={props.freezeInput}
254 1 : logContext={LOG_CTX}
255 1 : />
256 1 : {props.connector === ConnectorType.SIDB && (
257 0 : <TextField
258 0 : name="Database Name"
259 0 : data-testid="sidb-database-name"
260 0 : caption="Hyper Cloud database that is queried"
261 0 : value={props.databaseName ?? ""}
262 0 : placeholder={SIDB_DEFAULT_DATABASE_NAME}
263 0 : leadingVisual={() => <div>DB</div>}
264 0 : onChange={props.updateDatabaseName}
265 0 : readOnly={props.freezeInput}
266 0 : disabled={props.freezeInput}
267 0 : logContext={LOG_CTX}
268 0 : />
269 : )}
270 1 : </div>
271 1 : </div>
272 1 : <div className={style.section}>
273 1 : <div className={classNames(style.section_layout, style.body_section_layout)}>
274 1 : <TextField
275 1 : name="Data Cloud Instance URL"
276 1 : data-testid="salesforce-data-cloud-instance-url"
277 1 : caption="URL of the Data Cloud instance derived from the access token"
278 1 : value={props.dataCloudInstanceUrl}
279 1 : placeholder=""
280 1 : leadingVisual={() => <div>URL</div>}
281 1 : readOnly
282 1 : disabled
283 1 : logContext={LOG_CTX}
284 1 : />
285 1 : <TextField
286 1 : name="Data Cloud Tenant ID"
287 1 : data-testid="salesforce-data-cloud-tenant-id"
288 1 : caption="Tenant id for Data Cloud apis derived from the access token"
289 1 : value={props.dataCloudTenantId}
290 1 : placeholder=""
291 1 : leadingVisual={() => <div>ID</div>}
292 1 : readOnly
293 1 : disabled
294 1 : logContext={LOG_CTX}
295 1 : />
296 1 : </div>
297 1 : </div>
298 1 : <div className={style.section}>
299 1 : <div className={classNames(style.section_layout, style.body_section_layout)}>
300 1 : <TextField
301 1 : name="Core Access Token"
302 1 : data-testid="salesforce-core-access-token"
303 1 : caption="Access Token for Salesforce Core"
304 1 : value={props.coreAccessToken}
305 1 : placeholder=""
306 1 : leadingVisual={KeyIcon}
307 1 : readOnly
308 1 : disabled
309 1 : logContext={LOG_CTX}
310 1 : />
311 1 : <TextField
312 1 : name="Data Cloud Access Token"
313 1 : data-testid="salesforce-data-cloud-access-token"
314 1 : caption="Raw Data Cloud JWT"
315 1 : value={props.dataCloudAccessToken}
316 1 : placeholder=""
317 1 : leadingVisual={KeyIcon}
318 1 : readOnly
319 1 : disabled
320 1 : logContext={LOG_CTX}
321 1 : />
322 1 : <TextField
323 1 : name="Core Tenant ID"
324 1 : data-testid="salesforce-core-tenant-id"
325 1 : caption="Tenant id for core apis"
326 1 : value={props.coreTenantId}
327 1 : placeholder=""
328 1 : leadingVisual={() => <div>ID</div>}
329 1 : readOnly
330 1 : disabled
331 1 : logContext={LOG_CTX}
332 1 : />
333 1 : </div>
334 1 : </div>
335 1 : </div>
336 1 : </div>
337 : );
338 1 : };
339 :
340 1 : export const SalesforceConnectorSettings: React.FC<Props> = (props: Props) => {
341 0 : const sfSetup = useSalesforceSetup();
342 0 : const queryExecutor = useQueryExecutor();
343 :
344 : // Resolve connection state
345 0 : const [connectionState, dispatchConnectionState] = useConnectionById(props.connectionId);
346 0 : const connectionSetup = useConnectionSetup(props.connectionId);
347 0 : const { saving } = useConnectionSave(props.connectionId);
348 0 : const salesforceConnection = getSalesforceConnectionDetails(connectionState);
349 0 : const paramsKey = CONNECTOR_DESCRIPTORS[props.connector].paramsKey as 'salesforce' | 'sidb';
350 0 : const isSidb = props.connector === ConnectorType.SIDB;
351 0 : const editableParams = connectionState?.paramsDraft ?? connectionState?.params;
352 0 : const salesforceParams = editableParams && paramsKey in editableParams ? (editableParams as Record<typeof paramsKey, connection.SalesforceConnectionParams>)[paramsKey] : undefined;
353 :
354 0 : const buildPageState = (params: connection.SalesforceConnectionParams | undefined): PageState => ({
355 0 : instanceUrl: params?.instanceUrl ?? "",
356 0 : appConsumerKey: params?.appConsumerKey ?? "",
357 0 : login: params?.login ?? "",
358 0 : databaseName: params?.databaseName ?? SIDB_DEFAULT_DATABASE_NAME,
359 0 : });
360 0 : const [pageState, setPageState] = useConnectionDraftForm(props.connectionId,
361 0 : params => buildPageState(params && paramsKey in params ? (params as Record<typeof paramsKey, connection.SalesforceConnectionParams>)[paramsKey] : undefined),
362 0 : ({ databaseName, ...form }) => connectionParamsOf(CONNECTOR_DESCRIPTORS[props.connector], {
363 0 : ...salesforceParams,
364 0 : ...form,
365 0 : appConsumerSecret: salesforceParams?.appConsumerSecret ?? '',
366 0 : ...(isSidb ? { databaseName } : {}),
367 0 : }));
368 0 : const updateInstanceUrl: React.ChangeEventHandler<HTMLInputElement> = ev => setPageState(s => ({ ...s, instanceUrl: ev.target.value }));
369 0 : const updateAppConsumerKey: React.ChangeEventHandler<HTMLInputElement> = ev => setPageState(s => ({ ...s, appConsumerKey: ev.target.value }));
370 0 : const updateLogin: React.ChangeEventHandler<HTMLInputElement> = ev => setPageState(s => ({ ...s, login: ev.target.value }));
371 0 : const updateDatabaseName: React.ChangeEventHandler<HTMLInputElement> = ev => setPageState(s => ({ ...s, databaseName: ev.target.value }));
372 :
373 : // Maintain setting validations
374 0 : const [instanceUrlValidation, setInstanceUrlValidation] = React.useState<TextFieldValidationStatus>({
375 0 : type: VALIDATION_UNKNOWN,
376 0 : value: null
377 0 : });
378 0 : const [appConsumerValidation, setAppConsumerValidation] = React.useState<TextFieldValidationStatus>({
379 0 : type: VALIDATION_UNKNOWN,
380 0 : value: null
381 0 : });
382 0 : React.useEffect(() => {
383 0 : setInstanceUrlValidation({ type: VALIDATION_UNKNOWN, value: null });
384 0 : setAppConsumerValidation({ type: VALIDATION_UNKNOWN, value: null });
385 0 : }, [connectionState?.paramsDraft]);
386 :
387 0 : const setupParams = React.useMemo<connection.SalesforceConnectionParams>(() => ({
388 0 : instanceUrl: pageState.instanceUrl,
389 0 : appConsumerKey: pageState.appConsumerKey,
390 0 : appConsumerSecret: salesforceParams?.appConsumerSecret ?? '',
391 0 : login: pageState.login,
392 0 : ...(isSidb ? { databaseName: pageState.databaseName } : {}),
393 0 : }), [pageState, salesforceParams?.appConsumerSecret, isSidb]);
394 0 : const setupConnection = async () => {
395 0 : let validationSucceeded = true;
396 0 : if (pageState.instanceUrl == "") {
397 0 : validationSucceeded = false;
398 0 : setInstanceUrlValidation({ type: VALIDATION_ERROR, value: "Instance URL cannot be empty" });
399 0 : } else {
400 0 : setInstanceUrlValidation({ type: VALIDATION_UNKNOWN, value: null });
401 : }
402 0 : if (pageState.appConsumerKey === "") {
403 0 : validationSucceeded = false;
404 0 : setAppConsumerValidation({ type: VALIDATION_ERROR, value: "Connected App cannot be empty" });
405 0 : } else {
406 0 : setAppConsumerValidation({ type: VALIDATION_UNKNOWN, value: null });
407 : }
408 0 : if (!validationSucceeded || !sfSetup) return;
409 :
410 0 : await connectionSetup.run(async signal => {
411 0 : try {
412 0 : const sfChannel = await sfSetup.setup(dispatchConnectionState, props.connector, setupParams, signal);
413 0 : if (connectionState != null) {
414 0 : await performHealthCheck(queryExecutor, connectionState.connectionId, { type: 'salesforce', channel: sfChannel }, dispatchConnectionState, signal);
415 : }
416 0 : } catch {
417 : // Setup updates connection state with the failure details.
418 0 : }
419 0 : });
420 0 : };
421 0 : const cancelSetup = connectionSetup.cancel;
422 0 : const resetSetup = async () => {
423 0 : if (sfSetup) {
424 0 : await sfSetup.reset(dispatchConnectionState);
425 : }
426 0 : };
427 :
428 : // Saving writes the settings into every session that uses the connection
429 0 : let freezeInput = saving;
430 0 : switch (connectionState?.connectionHealth) {
431 0 : case ConnectionHealth.NOT_STARTED:
432 0 : case ConnectionHealth.FAILED:
433 0 : case ConnectionHealth.CANCELLED:
434 0 : break;
435 0 : case ConnectionHealth.CONNECTING:
436 0 : freezeInput = true;
437 0 : break;
438 0 : case ConnectionHealth.ONLINE:
439 : break;
440 0 : }
441 :
442 : // Read the auth info
443 0 : const coreAccessToken = salesforceConnection?.proto.oauthState?.coreAccessToken;
444 0 : const dcAccessToken = salesforceConnection?.proto.oauthState?.dataCloudAccessToken;
445 0 : const dcAuthInfo = coreAccessToken && dcAccessToken
446 0 : ? collectSalesforceAuthInfo(coreAccessToken, dcAccessToken)
447 0 : : null;
448 :
449 : // Lock any changes?
450 0 : return (
451 0 : <SalesforceConnectionSettingsPage
452 0 : connectionState={connectionState}
453 0 : connector={props.connector}
454 0 : wrongPlatform={false}
455 0 : freezeInput={freezeInput}
456 0 : instanceUrl={pageState.instanceUrl}
457 0 : appConsumerKey={pageState.appConsumerKey}
458 0 : login={pageState.login}
459 0 : databaseName={pageState.databaseName}
460 0 : coreAccessToken={coreAccessToken?.accessToken ?? ''}
461 0 : dataCloudAccessToken={dcAuthInfo?.offcoreRawJwt ?? ''}
462 0 : dataCloudInstanceUrl={dcAuthInfo?.offcoreInstanceUrl ?? ''}
463 0 : coreTenantId={dcAuthInfo?.coreTenantId ?? ''}
464 0 : dataCloudTenantId={dcAuthInfo?.offcoreTenantId ?? ''}
465 0 : instanceUrlValidation={instanceUrlValidation}
466 0 : appConsumerValidation={appConsumerValidation}
467 0 : updateInstanceUrl={updateInstanceUrl}
468 0 : updateAppConsumerKey={updateAppConsumerKey}
469 0 : updateLogin={updateLogin}
470 0 : updateDatabaseName={updateDatabaseName}
471 0 : setupConnection={setupConnection}
472 0 : cancelSetup={cancelSetup}
473 0 : resetSetup={resetSetup}
474 0 : onClose={props.onClose}
475 0 : />
476 : );
477 0 : };
|