Mirrored from GitHub github.com/roostorg/coop
0

Configure Feed

Select the types of activity you want to include in your feed.

coop / client / src / webpages / dashboard / integrations / IntegrationConfigForm.tsx
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}