Mirrored from GitHub
github.com/roostorg/coop
15 kB
500 lines
1import { gql } from '@apollo/client';
2import { useEffect, useState } from 'react';
3import { Helmet } from 'react-helmet-async';
4import { useNavigate, useParams } from 'react-router-dom';
5
6import FullScreenLoading from '../../../components/common/FullScreenLoading';
7import CoopButton from '../components/CoopButton';
8import CoopModal from '../components/CoopModal';
9
10import {
11 GQLIntegrationApiCredential,
12 GQLIntegrationConfigDocument,
13 GQLUserPermission,
14 namedOperations,
15 useGQLIntegrationConfigQuery,
16 useGQLPermissionGatedRouteLoggedInUserQuery,
17 useGQLSetIntegrationConfigMutation,
18 useGQLSetPluginIntegrationConfigMutation,
19 type GQLGoogleContentSafetyApiIntegrationApiCredential,
20 type GQLOpenAiIntegrationApiCredential,
21 type GQLZentropiIntegrationApiCredential,
22} from '../../../graphql/generated';
23import {
24 stripTypename,
25 taggedUnionToOneOfInput,
26} from '../../../graphql/inputHelpers';
27import { userHasPermissions } from '../../../routing/permissions';
28import IntegrationConfigApiCredentialsSection from './IntegrationConfigApiCredentialsSection';
29import { INTEGRATION_LOGO_FALLBACKS } from './integrationLogos';
30import ModelCardView from './ModelCardView';
31
32gql`
33 mutation SetIntegrationConfig($input: SetIntegrationConfigInput!) {
34 setIntegrationConfig(input: $input) {
35 ... on SetIntegrationConfigSuccessResponse {
36 config {
37 name
38 }
39 }
40 ... on IntegrationConfigTooManyCredentialsError {
41 title
42 }
43 ... on IntegrationNoInputCredentialsError {
44 title
45 }
46 ... on IntegrationEmptyInputCredentialsError {
47 title
48 }
49 }
50 }
51
52 mutation SetPluginIntegrationConfig(
53 $input: SetPluginIntegrationConfigInput!
54 ) {
55 setPluginIntegrationConfig(input: $input) {
56 ... on SetIntegrationConfigSuccessResponse {
57 config {
58 name
59 }
60 }
61 ... on IntegrationConfigTooManyCredentialsError {
62 title
63 }
64 ... on IntegrationNoInputCredentialsError {
65 title
66 }
67 ... on IntegrationEmptyInputCredentialsError {
68 title
69 }
70 }
71 }
72
73 query IntegrationConfig($name: String!) {
74 integrationConfig(name: $name) {
75 ... on IntegrationConfigSuccessResult {
76 config {
77 name
78 title
79 docsUrl
80 requiresConfig
81 logoUrl
82 logoWithBackgroundUrl
83 modelCard {
84 modelName
85 version
86 releaseDate
87 sections {
88 id
89 title
90 subsections {
91 title
92 fields {
93 label
94 value
95 }
96 }
97 fields {
98 label
99 value
100 }
101 }
102 }
103 modelCardLearnMoreUrl
104 apiCredential {
105 ... on GoogleContentSafetyApiIntegrationApiCredential {
106 apiKey
107 }
108 ... on OpenAiIntegrationApiCredential {
109 apiKey
110 }
111 ... on ZentropiIntegrationApiCredential {
112 apiKey
113 labelerVersions {
114 id
115 label
116 }
117 }
118 ... on PluginIntegrationApiCredential {
119 credential
120 }
121 }
122 }
123 }
124 ... on IntegrationConfigUnsupportedIntegrationError {
125 title
126 }
127 ... on IntegrationConfigUnsupportedIntegrationError {
128 title
129 }
130 }
131 }
132`;
133
134/**
135 * Each 3rd party integration has different API credential requirements.
136 * Hive requires one API key per model (aka 'project'). Others require
137 * an API Key and a separate API User string. Etc...
138 * This function returns an empty API credential config (type is
139 * IntegrationConfigApiCredential), so the UI can display the proper empty inputs.
140 */
141export function getNewEmptyApiKey(name: string): GQLIntegrationApiCredential {
142 switch (name) {
143 case 'GOOGLE_CONTENT_SAFETY_API': {
144 return {
145 __typename: 'GoogleContentSafetyApiIntegrationApiCredential',
146 apiKey: '',
147 };
148 }
149 case 'OPEN_AI': {
150 return { __typename: 'OpenAiIntegrationApiCredential', apiKey: '' };
151 }
152 case 'ZENTROPI': {
153 return {
154 __typename: 'ZentropiIntegrationApiCredential',
155 apiKey: '',
156 labelerVersions: [],
157 };
158 }
159 default: {
160 return {
161 __typename: 'PluginIntegrationApiCredential',
162 credential: {},
163 };
164 }
165 }
166}
167
168export default function IntegrationConfigForm() {
169 const { name } = useParams<{ name: string | undefined }>();
170 if (name == null) {
171 throw Error('Integration name not provided');
172 }
173 // Cast back to upper case (see lowercase cast in IntegrationCard.tsx)
174 const integrationName = name.toUpperCase();
175 const navigate = useNavigate();
176
177 const [modalVisible, setModalVisible] = useState(false);
178 const [apiCredential, setApiCredential] = useState(
179 getNewEmptyApiKey(integrationName),
180 );
181
182 const showModal = () => setModalVisible(true);
183 const hideModal = () => setModalVisible(false);
184
185 const [setConfig, setConfigMutationParams] =
186 useGQLSetIntegrationConfigMutation({
187 onError: () => {
188 showModal();
189 },
190 onCompleted: () => showModal(),
191 });
192 const [setPluginConfig, setPluginConfigMutationParams] =
193 useGQLSetPluginIntegrationConfigMutation({
194 onError: () => {
195 showModal();
196 },
197 onCompleted: () => showModal(),
198 });
199 const mutationError =
200 setConfigMutationParams.error ?? setPluginConfigMutationParams.error;
201 const mutationLoading =
202 setConfigMutationParams.loading || setPluginConfigMutationParams.loading;
203
204 const {
205 loading,
206 error: configQueryError,
207 data,
208 } = useGQLIntegrationConfigQuery({
209 variables: { name: integrationName },
210 });
211 const response = data?.integrationConfig;
212
213 switch (response?.__typename) {
214 case 'IntegrationConfigSuccessResult': {
215 break;
216 }
217 case 'IntegrationConfigUnsupportedIntegrationError': {
218 throw new Error('This config is not supported yet');
219 }
220 case undefined: {
221 // Case where nothing has been returned from the query
222 // yet (as in it's loading, etc), so just continue
223 break;
224 }
225 }
226
227 const userQueryParams = useGQLPermissionGatedRouteLoggedInUserQuery();
228 const userQueryLoading = userQueryParams.loading;
229 const userQueryError = userQueryParams.error;
230 const permissions = userQueryParams.data?.me?.permissions;
231
232 /**
233 * If editing an existing config and the INTEGRATION_CONFIG_QUERY
234 * has finished, reset the state values to whatever the query returned
235 */
236 useEffect(() => {
237 if (response?.config != null) {
238 const cred = response.config.apiCredential;
239 if (cred.__typename === 'PluginIntegrationApiCredential') {
240 setApiCredential({
241 __typename: 'PluginIntegrationApiCredential',
242 credential: cred.credential ?? {},
243 });
244 } else {
245 setApiCredential(cred);
246 }
247 }
248 }, [response]);
249
250 if (configQueryError || userQueryError) {
251 return <div />;
252 }
253 if (loading || userQueryLoading) {
254 return <FullScreenLoading />;
255 }
256
257 const apiConfig =
258 response?.__typename === 'IntegrationConfigSuccessResult'
259 ? response.config
260 : undefined;
261 const formattedName = apiConfig?.title ?? integrationName.replace(/_/g, ' ');
262 const logo = apiConfig
263 ? (apiConfig.logoUrl ??
264 INTEGRATION_LOGO_FALLBACKS[apiConfig.name]?.logo ??
265 '')
266 : '';
267
268 const canEditConfig = userHasPermissions(permissions, [
269 GQLUserPermission.ManageOrg,
270 ]);
271
272 const mappedApiCredential = taggedUnionToOneOfInput(apiCredential, {
273 GoogleContentSafetyApiIntegrationApiCredential: 'googleContentSafetyApi',
274 OpenAiIntegrationApiCredential: 'openAi',
275 ZentropiIntegrationApiCredential: 'zentropi',
276 PluginIntegrationApiCredential: 'pluginCredential',
277 });
278
279 const isPluginIntegration = ![
280 'GOOGLE_CONTENT_SAFETY_API',
281 'OPEN_AI',
282 'ZENTROPI',
283 ].includes(integrationName);
284 const validationMessage = (() => {
285 if (isPluginIntegration) {
286 return undefined;
287 }
288 if (
289 'googleContentSafetyApi' in mappedApiCredential &&
290 !(
291 mappedApiCredential[
292 'googleContentSafetyApi'
293 ] as GQLGoogleContentSafetyApiIntegrationApiCredential
294 ).apiKey
295 ) {
296 return 'Please input the Google Content Safety API key';
297 }
298
299 if (
300 'openAi' in mappedApiCredential &&
301 !(mappedApiCredential['openAi'] as GQLOpenAiIntegrationApiCredential)
302 .apiKey
303 ) {
304 return 'Please input the OpenAI API key';
305 }
306
307 if (
308 'zentropi' in mappedApiCredential &&
309 !(mappedApiCredential['zentropi'] as GQLZentropiIntegrationApiCredential)
310 .apiKey
311 ) {
312 return 'Please input the Zentropi API key';
313 }
314
315 return undefined;
316 })();
317
318 const saveButton = (
319 <CoopButton
320 title="Save"
321 loading={mutationLoading}
322 onClick={async () => {
323 const refetchQueries = [
324 namedOperations.Query.MyIntegrations,
325 {
326 query: GQLIntegrationConfigDocument,
327 variables: { name: integrationName },
328 },
329 ];
330 if (isPluginIntegration) {
331 const cred =
332 apiCredential.__typename === 'PluginIntegrationApiCredential'
333 ? (apiCredential.credential ?? {})
334 : {};
335 await setPluginConfig({
336 variables: {
337 input: { integrationId: integrationName, credential: cred },
338 },
339 refetchQueries,
340 });
341 } else {
342 await setConfig({
343 variables: {
344 input: {
345 apiCredential: stripTypename(mappedApiCredential),
346 },
347 },
348 refetchQueries,
349 });
350 }
351 }}
352 disabled={!canEditConfig || validationMessage != null}
353 disabledTooltipTitle={validationMessage}
354 />
355 );
356
357 const [modalTitle, modalBody, modalButtonText] =
358 mutationError == null
359 ? [
360 `${formattedName} Config Saved`,
361 `Your ${formattedName} Config was successfully saved!`,
362 'Done',
363 ]
364 : [
365 `Error Saving ${formattedName} Config`,
366 `We encountered an error trying to save your ${formattedName} Config. Please try again.`,
367 'OK',
368 ];
369
370 const onHideModal = () => {
371 hideModal();
372 if (mutationError == null) {
373 navigate(-1);
374 }
375 };
376
377 const modal = (
378 <CoopModal
379 title={modalTitle}
380 visible={modalVisible}
381 onClose={onHideModal}
382 footer={[
383 {
384 title: modalButtonText,
385 onClick: onHideModal,
386 type: 'primary',
387 },
388 ]}
389 >
390 {modalBody}
391 </CoopModal>
392 );
393
394 const headerSubtitle = (
395 integrationName: string,
396 formattedName: string,
397 ): React.ReactNode | string | undefined => {
398 switch (integrationName) {
399 case 'GOOGLE_CONTENT_SAFETY_API':
400 return (
401 <>
402 The Content Safety API is an AI classifier which issues a Child
403 Safety prioritization recommendation on content sent to it. Content
404 Safety API users must conduct their own manual review in order to
405 determine whether to take action on the content, and comply with
406 applicable local reporting laws. Apply for API keys{' '}
407 <a
408 href="https://protectingchildren.google/tools-for-partners/"
409 target="_blank"
410 rel="noopener noreferrer"
411 className="text-blue-600 hover:underline"
412 >
413 here
414 </a>{' '}
415 and mention in your application that you are using the Coop
416 moderation tool. Upon reviewing your application, Google will be
417 back in touch shortly to take the application forward if you
418 qualify.
419 </>
420 );
421 case 'OPEN_AI':
422 return `The ${formattedName} integration requires one API Key.`;
423 default:
424 return undefined;
425 }
426 };
427
428 const apiModelCard = response?.config?.modelCard;
429 const apiModelCardLearnMoreUrl = response?.config?.modelCardLearnMoreUrl;
430 const hasModelCard = apiModelCard != null;
431
432 return (
433 <div className="flex flex-col text-start">
434 <Helmet>
435 <title>{formattedName} Integration</title>
436 </Helmet>
437 <div className="flex flex-col justify-between w-4/5 mb-4">
438 <div className="flex items-center gap-3 mb-1">
439 <div className="w-12 h-12 rounded-full bg-slate-200 flex items-center justify-center shrink-0 overflow-hidden">
440 <img src={logo} alt="" className="w-full h-full object-contain" />
441 </div>
442 <div className="text-2xl font-bold">{`${formattedName} Integration`}</div>
443 </div>
444 {!hasModelCard && (
445 <div className="mb-4 text-base text-zinc-900">
446 {headerSubtitle(integrationName, formattedName)}
447 </div>
448 )}
449 </div>
450
451 {hasModelCard && apiModelCard ? (
452 <div className="flex flex-col lg:flex-row gap-8 w-full max-w-7xl">
453 <div className="flex-1 min-w-0">
454 <ModelCardView card={apiModelCard} />
455 </div>
456 <div className="flex flex-col lg:w-96 shrink-0">
457 {apiModelCardLearnMoreUrl != null && (
458 <a
459 href={apiModelCardLearnMoreUrl}
460 target="_blank"
461 rel="noopener noreferrer"
462 className="text-sm text-blue-600 hover:underline mb-4 inline-flex items-center gap-1"
463 >
464 <span className="align-middle">ⓘ</span>
465 <span>Learn more about how to read model cards</span>
466 </a>
467 )}
468 <div className="font-semibold text-zinc-800 mb-2">
469 Configuration
470 </div>
471 <div className="text-sm text-zinc-600 mb-3">
472 Configure your integration settings below.
473 </div>
474 <IntegrationConfigApiCredentialsSection
475 name={integrationName}
476 apiCredential={apiCredential}
477 setApiCredential={(cred: GQLIntegrationApiCredential) =>
478 setApiCredential(cred)
479 }
480 compact
481 />
482 {saveButton}
483 </div>
484 </div>
485 ) : (
486 <>
487 <IntegrationConfigApiCredentialsSection
488 name={integrationName}
489 apiCredential={apiCredential}
490 setApiCredential={(cred: GQLIntegrationApiCredential) =>
491 setApiCredential(cred)
492 }
493 />
494 {saveButton}
495 </>
496 )}
497 {modal}
498 </div>
499 );
500}