diff --git a/packages/create-twenty-app/src/utils/__tests__/test-template.spec.ts b/packages/create-twenty-app/src/utils/__tests__/test-template.spec.ts index 0bedcbf289..e3bab1ce07 100644 --- a/packages/create-twenty-app/src/utils/__tests__/test-template.spec.ts +++ b/packages/create-twenty-app/src/utils/__tests__/test-template.spec.ts @@ -47,7 +47,7 @@ describe('scaffoldIntegrationTest', () => { const content = await fs.readFile(testPath, 'utf8'); expect(content).toContain( - "import { appBuild, appUninstall } from 'twenty-sdk/cli'", + "import { appGenerateClient, appUninstall } from 'twenty-sdk/cli'", ); expect(content).toContain( "import { MetadataApiClient } from 'twenty-sdk/generated'", @@ -57,7 +57,7 @@ describe('scaffoldIntegrationTest', () => { ); expect(content).toContain('TWENTY_TEST_API_KEY'); expect(content).toContain('assertServerIsReachable'); - expect(content).toContain('appBuild'); + expect(content).toContain('appGenerateClient'); expect(content).toContain('appUninstall'); expect(content).toContain('findManyApplications'); expect(content).toContain('APPLICATION_UNIVERSAL_IDENTIFIER'); diff --git a/packages/create-twenty-app/src/utils/test-template.ts b/packages/create-twenty-app/src/utils/test-template.ts index 7504d0c29d..b85e945f7d 100644 --- a/packages/create-twenty-app/src/utils/test-template.ts +++ b/packages/create-twenty-app/src/utils/test-template.ts @@ -128,7 +128,7 @@ const createIntegrationTest = async ({ fileName: string; }) => { const content = `import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/application-config'; -import { appBuild, appUninstall } from 'twenty-sdk/cli'; +import { appGenerateClient, appUninstall } from 'twenty-sdk/cli'; import { MetadataApiClient } from 'twenty-sdk/generated'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; @@ -158,14 +158,14 @@ describe('App installation', () => { beforeAll(async () => { await assertServerIsReachable(); - const buildResult = await appBuild({ + const generateResult = await appGenerateClient({ appPath: APP_PATH, - onProgress: (message: string) => console.log(\`[build] \${message}\`), + onProgress: (message: string) => console.log(\`[generate-client] \${message}\`), }); - if (!buildResult.success) { + if (!generateResult.success) { throw new Error( - \`App build failed: \${buildResult.error?.message ?? 'Unknown error'}\`, + \`Client generation failed: \${generateResult.error?.message ?? 'Unknown error'}\`, ); } diff --git a/packages/twenty-apps/hello-world/src/__tests__/app-install.integration-test.ts b/packages/twenty-apps/hello-world/src/__tests__/app-install.integration-test.ts index 6b829b9430..3c23c6295f 100644 --- a/packages/twenty-apps/hello-world/src/__tests__/app-install.integration-test.ts +++ b/packages/twenty-apps/hello-world/src/__tests__/app-install.integration-test.ts @@ -1,5 +1,5 @@ import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/application-config'; -import { appBuild, appUninstall } from 'twenty-sdk/cli'; +import { appGenerateClient, appUninstall } from 'twenty-sdk/cli'; import { MetadataApiClient } from 'twenty-sdk/generated'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; @@ -29,14 +29,15 @@ describe('App installation', () => { beforeAll(async () => { await assertServerIsReachable(); - const buildResult = await appBuild({ + const generateResult = await appGenerateClient({ appPath: APP_PATH, - onProgress: (message: string) => console.log(`[build] ${message}`), + onProgress: (message: string) => + console.log(`[generate-client] ${message}`), }); - if (!buildResult.success) { + if (!generateResult.success) { throw new Error( - `App build failed: ${buildResult.error?.message ?? 'Unknown error'}`, + `Client generation failed: ${generateResult.error?.message ?? 'Unknown error'}`, ); } diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 2849239ffd..0d0d50ba35 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -1484,6 +1484,12 @@ export type DestroyViewSortInput = { id: Scalars['UUID']; }; +export type DevelopmentApplication = { + __typename?: 'DevelopmentApplication'; + id: Scalars['String']; + universalIdentifier: Scalars['String']; +}; + export type DomainRecord = { __typename?: 'DomainRecord'; key: Scalars['String']; @@ -2367,6 +2373,7 @@ export type Mutation = { createCoreViewGroup: CoreViewGroup; createCoreViewSort: CoreViewSort; createDatabaseConfigVariable: Scalars['Boolean']; + createDevelopmentApplication: DevelopmentApplication; createEmailingDomain: EmailingDomain; createFrontComponent: FrontComponent; createManyCoreViewFieldGroups: Array; @@ -2449,7 +2456,6 @@ export type Mutation = { initiateOTPProvisioningForAuthenticatedUser: InitiateTwoFactorAuthenticationProvisioning; installApplication: Scalars['Boolean']; installMarketplaceApp: Scalars['Boolean']; - registerNpmPackage: ApplicationRegistration; removeQueryFromEventStream: Scalars['Boolean']; removeRoleFromAgent: Scalars['Boolean']; renewApplicationToken: ApplicationTokenPair; @@ -2648,6 +2654,12 @@ export type MutationCreateDatabaseConfigVariableArgs = { }; +export type MutationCreateDevelopmentApplicationArgs = { + name: Scalars['String']; + universalIdentifier: Scalars['String']; +}; + + export type MutationCreateEmailingDomainArgs = { domain: Scalars['String']; driver: EmailingDomainDriver; @@ -3043,11 +3055,6 @@ export type MutationInstallMarketplaceAppArgs = { }; -export type MutationRegisterNpmPackageArgs = { - packageName: Scalars['String']; -}; - - export type MutationRemoveQueryFromEventStreamArgs = { input: RemoveQueryFromEventStreamInput; }; @@ -6265,13 +6272,6 @@ export type InstallMarketplaceAppMutationVariables = Exact<{ export type InstallMarketplaceAppMutation = { __typename?: 'Mutation', installMarketplaceApp: boolean }; -export type RegisterNpmPackageMutationVariables = Exact<{ - packageName: Scalars['String']; -}>; - - -export type RegisterNpmPackageMutation = { __typename?: 'Mutation', registerNpmPackage: { __typename?: 'ApplicationRegistration', id: string, universalIdentifier: string, name: string } }; - export type UpgradeApplicationMutationVariables = Exact<{ appRegistrationId: Scalars['String']; targetVersion: Scalars['String']; @@ -11604,41 +11604,6 @@ export function useInstallMarketplaceAppMutation(baseOptions?: Apollo.MutationHo export type InstallMarketplaceAppMutationHookResult = ReturnType; export type InstallMarketplaceAppMutationResult = Apollo.MutationResult; export type InstallMarketplaceAppMutationOptions = Apollo.BaseMutationOptions; -export const RegisterNpmPackageDocument = gql` - mutation RegisterNpmPackage($packageName: String!) { - registerNpmPackage(packageName: $packageName) { - id - universalIdentifier - name - } -} - `; -export type RegisterNpmPackageMutationFn = Apollo.MutationFunction; - -/** - * __useRegisterNpmPackageMutation__ - * - * To run a mutation, you first call `useRegisterNpmPackageMutation` within a React component and pass it any options that fit your needs. - * When your component renders, `useRegisterNpmPackageMutation` returns a tuple that includes: - * - A mutate function that you can call at any time to execute the mutation - * - An object with fields that represent the current status of the mutation's execution - * - * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; - * - * @example - * const [registerNpmPackageMutation, { data, loading, error }] = useRegisterNpmPackageMutation({ - * variables: { - * packageName: // value for 'packageName' - * }, - * }); - */ -export function useRegisterNpmPackageMutation(baseOptions?: Apollo.MutationHookOptions) { - const options = {...defaultOptions, ...baseOptions} - return Apollo.useMutation(RegisterNpmPackageDocument, options); - } -export type RegisterNpmPackageMutationHookResult = ReturnType; -export type RegisterNpmPackageMutationResult = Apollo.MutationResult; -export type RegisterNpmPackageMutationOptions = Apollo.BaseMutationOptions; export const UpgradeApplicationDocument = gql` mutation UpgradeApplication($appRegistrationId: String!, $targetVersion: String!) { upgradeApplication( diff --git a/packages/twenty-front/src/modules/marketplace/graphql/mutations/registerNpmPackage.ts b/packages/twenty-front/src/modules/marketplace/graphql/mutations/registerNpmPackage.ts deleted file mode 100644 index 9b4009d159..0000000000 --- a/packages/twenty-front/src/modules/marketplace/graphql/mutations/registerNpmPackage.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { gql } from '@apollo/client'; - -export const REGISTER_NPM_PACKAGE = gql` - mutation RegisterNpmPackage($packageName: String!) { - registerNpmPackage(packageName: $packageName) { - id - universalIdentifier - name - } - } -`; diff --git a/packages/twenty-front/src/modules/marketplace/hooks/useRegisterNpmPackage.ts b/packages/twenty-front/src/modules/marketplace/hooks/useRegisterNpmPackage.ts deleted file mode 100644 index 11b3aaa0f4..0000000000 --- a/packages/twenty-front/src/modules/marketplace/hooks/useRegisterNpmPackage.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; -import { useMutation } from '@apollo/client'; -import { t } from '@lingui/core/macro'; -import { useState } from 'react'; -import { isDefined } from 'twenty-shared/utils'; -import { REGISTER_NPM_PACKAGE } from '~/modules/marketplace/graphql/mutations/registerNpmPackage'; - -export const useRegisterNpmPackage = () => { - const [registerNpmPackageMutation] = useMutation(REGISTER_NPM_PACKAGE); - const { enqueueErrorSnackBar, enqueueSuccessSnackBar } = useSnackBar(); - const [isRegistering, setIsRegistering] = useState(false); - - const register = async (params: { - packageName: string; - }): Promise => { - setIsRegistering(true); - - try { - const result = await registerNpmPackageMutation({ - variables: { packageName: params.packageName }, - }); - - const registration = result.data?.registerNpmPackage; - - if (!isDefined(registration)) { - enqueueErrorSnackBar({ message: t`Registration failed.` }); - - return false; - } - - enqueueSuccessSnackBar({ - message: t`Package registered successfully.`, - }); - - return true; - } catch (error) { - const graphqlMessage = error instanceof Error ? error.message : undefined; - - enqueueErrorSnackBar({ - message: graphqlMessage ?? t`Failed to register npm package.`, - }); - - return false; - } finally { - setIsRegistering(false); - } - }; - - return { register, isRegistering }; -}; diff --git a/packages/twenty-front/src/pages/settings/applications/SettingsApplicationRegistrationDetails.tsx b/packages/twenty-front/src/pages/settings/applications/SettingsApplicationRegistrationDetails.tsx index 8c4b7f28c5..2d352d3ee4 100644 --- a/packages/twenty-front/src/pages/settings/applications/SettingsApplicationRegistrationDetails.tsx +++ b/packages/twenty-front/src/pages/settings/applications/SettingsApplicationRegistrationDetails.tsx @@ -1,952 +1,105 @@ -import { SettingsAdminTableCard } from '@/settings/admin-panel/components/SettingsAdminTableCard'; -import { DELETE_APPLICATION_REGISTRATION } from '@/settings/application-registrations/graphql/mutations/deleteApplicationRegistration'; -import { ROTATE_APPLICATION_REGISTRATION_CLIENT_SECRET } from '@/settings/application-registrations/graphql/mutations/rotateApplicationRegistrationClientSecret'; -import { TRANSFER_APPLICATION_REGISTRATION_OWNERSHIP } from '@/settings/application-registrations/graphql/mutations/transferApplicationRegistrationOwnership'; -import { UPDATE_APPLICATION_REGISTRATION } from '@/settings/application-registrations/graphql/mutations/updateApplicationRegistration'; -import { UPDATE_APPLICATION_REGISTRATION_VARIABLE } from '@/settings/application-registrations/graphql/mutations/updateApplicationRegistrationVariable'; -import { APPLICATION_REGISTRATION_TARBALL_URL } from '@/settings/application-registrations/graphql/queries/applicationRegistrationTarballUrl'; import { FIND_APPLICATION_REGISTRATION_STATS } from '@/settings/application-registrations/graphql/queries/findApplicationRegistrationStats'; -import { FIND_APPLICATION_REGISTRATION_VARIABLES } from '@/settings/application-registrations/graphql/queries/findApplicationRegistrationVariables'; -import { FIND_MANY_APPLICATION_REGISTRATIONS } from '@/settings/application-registrations/graphql/queries/findManyApplicationRegistrations'; import { FIND_ONE_APPLICATION_REGISTRATION } from '@/settings/application-registrations/graphql/queries/findOneApplicationRegistration'; -import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; -import { ApiKeyInput } from '@/settings/developers/components/ApiKeyInput'; -import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; -import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput'; -import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal'; -import { useModal } from '@/ui/layout/modal/hooks/useModal'; +import { TabList } from '@/ui/layout/tab-list/components/TabList'; +import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState'; import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; -import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue'; -import { useInstallMarketplaceApp } from '~/modules/marketplace/hooks/useInstallMarketplaceApp'; -import { useMutation, useQuery } from '@apollo/client'; -import { styled } from '@linaria/react'; -import { Trans, useLingui } from '@lingui/react/macro'; -import { isNonEmptyString } from '@sniptt/guards'; -import { useState } from 'react'; +import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; +import { useQuery } from '@apollo/client'; +import { useLingui } from '@lingui/react/macro'; import { useParams } from 'react-router-dom'; import { SettingsPath } from 'twenty-shared/types'; -import { getSettingsPath, isDefined, isValidUrl } from 'twenty-shared/utils'; -import { - H1Title, - H1TitleFontColor, - H2Title, - IconArrowRight, - IconChartBar, - IconCheck, - IconBox, - IconDownload, - IconExternalLink, - IconKey, - IconRefresh, - IconShield, - IconTag, - IconTextCaption, - IconTrash, - IconWorld, - Status, -} from 'twenty-ui/display'; -import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle'; -import { Button } from 'twenty-ui/input'; -import { - Card, - Section, - SectionAlignment, - SectionFontColor, -} from 'twenty-ui/layout'; -import { useCopyToClipboard } from '~/hooks/useCopyToClipboard'; -import { - ApplicationRegistrationSourceType, - useFindManyApplicationsQuery, -} from '~/generated-metadata/graphql'; -import { useNavigateSettings } from '~/hooks/useNavigateSettings'; -import { themeCssVariables } from 'twenty-ui/theme-constants'; -import { - StyledAppModal, - StyledAppModalButton, - StyledAppModalSection, - StyledAppModalTitle, -} from '~/pages/settings/applications/components/SettingsAppModalLayout'; -import { applicationRegistrationClientSecretFamilyState } from '~/pages/settings/applications/states/applicationRegistrationClientSecretFamilyState'; +import { getSettingsPath, isDefined } from 'twenty-shared/utils'; +import { IconInfoCircle, IconKey, IconWorld } from 'twenty-ui/display'; +import { SettingsApplicationRegistrationGeneralTab } from '~/pages/settings/applications/tabs/SettingsApplicationRegistrationGeneralTab'; +import { SettingsApplicationRegistrationOAuthTab } from '~/pages/settings/applications/tabs/SettingsApplicationRegistrationOAuthTab'; +import { SettingsApplicationRegistrationDistributionTab } from '~/pages/settings/applications/tabs/SettingsApplicationRegistrationDistributionTab'; -const DELETE_REGISTRATION_MODAL_ID = 'delete-application-registration-modal'; -const ROTATE_SECRET_MODAL_ID = 'rotate-application-registration-secret-modal'; -const TRANSFER_OWNERSHIP_MODAL_ID = - 'transfer-application-registration-ownership-modal'; - -const StyledInputContainer = styled.div` - align-items: center; - display: flex; - flex-direction: row; - gap: ${themeCssVariables.spacing[2]}; - width: 100%; -`; - -const StyledRedirectUriRow = styled.div` - align-items: center; - display: flex; - gap: ${themeCssVariables.spacing[2]}; - padding: ${themeCssVariables.spacing[1]} 0; -`; - -const StyledRedirectUriValue = styled.span` - color: ${themeCssVariables.font.color.primary}; - font-family: monospace; - word-break: break-all; -`; - -const StyledVariableRow = styled.div` - align-items: center; - border-bottom: 1px solid ${themeCssVariables.border.color.light}; - display: flex; - gap: ${themeCssVariables.spacing[2]}; - padding: ${themeCssVariables.spacing[2]} 0; -`; - -const StyledVariableInfo = styled.div` - display: flex; - flex: 1; - flex-direction: column; - gap: ${themeCssVariables.spacing['0.5']}; -`; - -const StyledVariableKey = styled.span` - color: ${themeCssVariables.font.color.primary}; - font-family: monospace; - font-weight: ${themeCssVariables.font.weight.medium}; -`; - -const StyledVariableDescription = styled.span` - color: ${themeCssVariables.font.color.tertiary}; - font-size: ${themeCssVariables.font.size.sm}; -`; - -const StyledRotateContainer = styled.div` - padding-top: ${themeCssVariables.spacing[2]}; -`; - -const StyledDangerButtonGroup = styled.div` - display: flex; - gap: ${themeCssVariables.spacing[2]}; -`; - -const StyledSourceRow = styled.div` - align-items: center; - display: flex; - gap: ${themeCssVariables.spacing[2]}; -`; - -const StyledDownloadLink = styled.a` - color: ${themeCssVariables.font.color.secondary}; - cursor: pointer; - text-decoration: underline; - &:hover { - color: ${themeCssVariables.font.color.primary}; - } -`; - -const StyledMarketplaceActions = styled.div` - display: flex; - gap: ${themeCssVariables.spacing[2]}; - padding-top: ${themeCssVariables.spacing[2]}; -`; - -type ServerVariable = { - id: string; - key: string; - description: string; - isSecret: boolean; - isRequired: boolean; - isFilled: boolean; -}; +const REGISTRATION_DETAIL_TAB_LIST_ID = + 'application-registration-detail-tab-list'; export const SettingsApplicationRegistrationDetails = () => { const { t } = useLingui(); - const navigate = useNavigateSettings(); - const { copyToClipboard } = useCopyToClipboard(); - const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar(); - const { openModal, closeModal } = useModal(); const { applicationRegistrationId = '' } = useParams<{ applicationRegistrationId: string; }>(); - const applicationRegistrationClientSecret = useAtomFamilyStateValue( - applicationRegistrationClientSecretFamilyState, - applicationRegistrationId, - ); - - const [isInstalling, setIsInstalling] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const [formRedirectUris, setFormRedirectUris] = useState([]); - const [newRedirectUri, setNewRedirectUri] = useState(''); - const [hasChanges, setHasChanges] = useState(false); - const [rotatedSecret, setRotatedSecret] = useState(null); - const [transferSubdomain, setTransferSubdomain] = useState(''); - const [isTransferring, setIsTransferring] = useState(false); - - const [variableValues, setVariableValues] = useState>( - {}, + const activeTabId = useAtomComponentStateValue( + activeTabIdComponentState, + REGISTRATION_DETAIL_TAB_LIST_ID, ); const { data, loading } = useQuery(FIND_ONE_APPLICATION_REGISTRATION, { variables: { id: applicationRegistrationId }, skip: !applicationRegistrationId, - onCompleted: (result) => { - const foundRegistration = result?.findOneApplicationRegistration; - - if (isDefined(foundRegistration)) { - setFormRedirectUris(foundRegistration.oAuthRedirectUris ?? []); - } - }, }); - const { data: variablesData } = useQuery( - FIND_APPLICATION_REGISTRATION_VARIABLES, - { - variables: { applicationRegistrationId }, - skip: !applicationRegistrationId, - }, - ); - const { data: statsData } = useQuery(FIND_APPLICATION_REGISTRATION_STATS, { variables: { id: applicationRegistrationId }, skip: !applicationRegistrationId, }); - const { data: tarballUrlData } = useQuery( - APPLICATION_REGISTRATION_TARBALL_URL, - { - variables: { id: applicationRegistrationId }, - skip: !applicationRegistrationId, - }, - ); - - const [updateRegistration] = useMutation(UPDATE_APPLICATION_REGISTRATION, { - refetchQueries: [ - FIND_ONE_APPLICATION_REGISTRATION, - FIND_MANY_APPLICATION_REGISTRATIONS, - ], - }); - const [deleteRegistration] = useMutation(DELETE_APPLICATION_REGISTRATION, { - refetchQueries: [FIND_MANY_APPLICATION_REGISTRATIONS], - }); - const [rotateSecret] = useMutation( - ROTATE_APPLICATION_REGISTRATION_CLIENT_SECRET, - ); - const [updateVariable] = useMutation( - UPDATE_APPLICATION_REGISTRATION_VARIABLE, - { - refetchQueries: [FIND_APPLICATION_REGISTRATION_VARIABLES], - }, - ); - const [transferOwnership] = useMutation( - TRANSFER_APPLICATION_REGISTRATION_OWNERSHIP, - { - refetchQueries: [ - FIND_ONE_APPLICATION_REGISTRATION, - FIND_MANY_APPLICATION_REGISTRATIONS, - ], - }, - ); - - const { install } = useInstallMarketplaceApp(); - const { data: applicationsData, refetch: refetchApplications } = - useFindManyApplicationsQuery(); - const registration = data?.findOneApplicationRegistration; - const variables: ServerVariable[] = - variablesData?.findApplicationRegistrationVariables ?? []; - - const isNpmSource = - registration?.sourceType === ApplicationRegistrationSourceType.NPM; - - if (loading || !registration) { - return null; - } - - const isInstalledOnWorkspace = ( - applicationsData?.findManyApplications ?? [] - ).some( - (application) => - application.universalIdentifier === registration.universalIdentifier, - ); - - const handleToggleListed = async () => { - try { - await updateRegistration({ - variables: { - input: { - id: applicationRegistrationId, - update: { - isListed: !registration.isListed, - }, - }, - }, - }); - enqueueSuccessSnackBar({ - message: registration.isListed - ? t`App removed from marketplace` - : t`App listed on marketplace`, - }); - } catch { - enqueueErrorSnackBar({ - message: t`Error updating marketplace listing`, - }); - } - }; - - const marketplacePageUrl = getSettingsPath( - SettingsPath.AvailableApplicationDetail, - { - availableApplicationId: registration.universalIdentifier, - }, - ); - - const handleInstallOnWorkspace = async () => { - setIsInstalling(true); - try { - const success = await install({ - universalIdentifier: registration.universalIdentifier, - }); - - if (success) { - await refetchApplications(); - enqueueSuccessSnackBar({ - message: t`App installed on this workspace`, - }); - } - } catch { - enqueueErrorSnackBar({ - message: t`Error installing app`, - }); - } finally { - setIsInstalling(false); - } - }; - - const handleTransferOwnership = async () => { - const trimmed = transferSubdomain.trim(); - - if (!isNonEmptyString(trimmed)) { - return; - } - - setIsTransferring(true); - try { - await transferOwnership({ - variables: { - applicationRegistrationId, - targetWorkspaceSubdomain: trimmed, - }, - }); - enqueueSuccessSnackBar({ - message: t`Ownership transferred successfully`, - }); - setTransferSubdomain(''); - navigate(SettingsPath.Applications); - } catch { - enqueueErrorSnackBar({ - message: t`Failed to transfer ownership. Check that the subdomain is correct.`, - }); - } finally { - setIsTransferring(false); - } - }; - - const markDirty = () => setHasChanges(true); - - const handleSave = async () => { - setIsLoading(true); - try { - await updateRegistration({ - variables: { - input: { - id: applicationRegistrationId, - update: { - oAuthRedirectUris: formRedirectUris, - }, - }, - }, - }); - setHasChanges(false); - enqueueSuccessSnackBar({ message: t`App updated` }); - } catch { - enqueueErrorSnackBar({ message: t`Error updating app` }); - } finally { - setIsLoading(false); - } - }; - - const handleCancel = () => { - setFormRedirectUris(registration.oAuthRedirectUris ?? []); - setHasChanges(false); - }; - - const handleDelete = async () => { - setIsLoading(true); - try { - await deleteRegistration({ - variables: { id: applicationRegistrationId }, - }); - navigate(SettingsPath.Applications); - } catch { - enqueueErrorSnackBar({ - message: t`Error deleting app`, - }); - } finally { - setIsLoading(false); - } - }; - - const handleRotateSecret = async () => { - setIsLoading(true); - try { - const result = await rotateSecret({ - variables: { id: applicationRegistrationId }, - }); - const secret = - result.data?.rotateApplicationRegistrationClientSecret?.clientSecret; - - if (isNonEmptyString(secret)) { - setRotatedSecret(secret); - enqueueSuccessSnackBar({ - message: t`Client secret rotated. Copy it now — it won't be shown again.`, - }); - } - } catch { - enqueueErrorSnackBar({ - message: t`Error rotating client secret`, - }); - } finally { - setIsLoading(false); - } - }; - - const handleAddRedirectUri = () => { - const trimmed = newRedirectUri.trim(); - - if (!trimmed) { - return; - } - - if (!isValidUrl(trimmed)) { - enqueueErrorSnackBar({ message: t`Please enter a valid URL` }); - - return; - } - - if (formRedirectUris.includes(trimmed)) { - enqueueErrorSnackBar({ message: t`This redirect URI is already added` }); - - return; - } - - setFormRedirectUris([...formRedirectUris, trimmed]); - setNewRedirectUri(''); - markDirty(); - }; - - const handleRemoveRedirectUri = (index: number) => { - setFormRedirectUris( - formRedirectUris.filter((_, uriIndex) => uriIndex !== index), - ); - markDirty(); - }; - - const handleSaveVariableValue = async (variable: ServerVariable) => { - const value = variableValues[variable.id]; - const variableKey = variable.key; - - if (!isNonEmptyString(value)) { - return; - } - - try { - await updateVariable({ - variables: { - input: { - id: variable.id, - update: { - value, - }, - }, - }, - }); - setVariableValues((previous) => { - const next = { ...previous }; - - delete next[variable.id]; - - return next; - }); - enqueueSuccessSnackBar({ - message: t`Variable ${variableKey} updated`, - }); - } catch { - enqueueErrorSnackBar({ - message: t`Error updating variable`, - }); - } - }; - - const displayedSecret = applicationRegistrationClientSecret ?? rotatedSecret; - const confirmationValue = t`yes`; - - const credentialItems = [ - { - Icon: IconKey, - label: t`Client ID`, - value: registration.oAuthClientId, - onClick: () => - copyToClipboard(registration.oAuthClientId, t`Client ID copied`), - }, - { - Icon: IconShield, - label: t`Scopes`, - value: (registration.oAuthScopes ?? []).join(', ') || '—', - }, - ]; - - const generalItems = [ - { - Icon: IconTag, - label: t`Name`, - value: registration.name, - }, - ...(isNonEmptyString(registration.description) - ? [ - { - Icon: IconTextCaption, - label: t`Description`, - value: registration.description, - }, - ] - : []), - { - Icon: IconWorld, - label: t`Universal ID`, - value: registration.universalIdentifier, - onClick: () => - copyToClipboard( - registration.universalIdentifier, - t`Universal identifier copied`, - ), - }, - ...(isNpmSource && isNonEmptyString(registration.sourcePackage) - ? [ - { - Icon: IconBox, - label: t`Package`, - value: registration.sourcePackage, - }, - ] - : registration.sourceType === ApplicationRegistrationSourceType.TARBALL - ? [ - { - Icon: IconBox, - label: t`Source`, - value: isNonEmptyString( - tarballUrlData?.applicationRegistrationTarballUrl, - ) ? ( - - - Tarball upload - - - Download - - - ) : ( - t`Tarball upload` - ), - }, - ] - : registration.sourceType === ApplicationRegistrationSourceType.LOCAL - ? [ - { - Icon: IconBox, - label: t`Source`, - value: t`Local development`, - }, - ] - : []), - ]; - const stats = statsData?.findApplicationRegistrationStats; const hasActiveInstalls = (stats?.activeInstalls ?? 0) > 0; - const versionDistributionLabel = - stats?.versionDistribution - ?.map( - (entry: { version: string; count: number }) => - `${entry.version} (${entry.count})`, - ) - .join(', ') || '—'; + if (loading || !isDefined(registration)) { + return null; + } - const statsItems = [ - { - Icon: IconDownload, - label: t`Active installs`, - value: stats?.activeInstalls ?? '—', - }, - { - Icon: IconTag, - label: t`Most installed version`, - value: stats?.mostInstalledVersion ?? '—', - }, - { - Icon: IconChartBar, - label: t`Distribution`, - value: versionDistributionLabel, - }, + const tabs = [ + { id: 'general', title: t`General`, Icon: IconInfoCircle }, + { id: 'oauth', title: t`OAuth`, Icon: IconKey }, + { id: 'distribution', title: t`Distribution`, Icon: IconWorld }, ]; + const renderActiveTabContent = () => { + switch (activeTabId) { + case 'oauth': + return ( + + ); + case 'distribution': + return ( + + ); + case 'general': + default: + return ( + + ); + } + }; + return ( - <> - - ) : undefined - } - > - - {stats && stats.activeInstalls > 0 && ( -
- - -
- )} - -
- - -
- -
- - - - {}} - disabled - /> - - -
- -
- -
- -
- - - -
- - {displayedSecret && ( -
- - -
- )} - -
- - {formRedirectUris.map((uri, index) => ( - - {uri} -
- - {variables.length > 0 && ( -
- - {variables.map((variable) => ( - - - - {variable.key} - {variable.isRequired && ( - * - )} - - {isNonEmptyString(variable.description) && ( - - {variable.description} - - )} - - {variable.isFilled && - !isNonEmptyString(variableValues[variable.id]) && ( - - )} - {!variable.isFilled && - !isNonEmptyString(variableValues[variable.id]) && ( - - )} - - setVariableValues((previous) => ({ - ...previous, - [variable.id]: value, - })) - } - placeholder={ - variable.isSecret ? t`Enter secret value` : t`Enter value` - } - fullWidth - /> -
- )} - -
- - -
-
-
- - - If you rotate this secret, any integration using the current secret - will stop working. Please type {`"${confirmationValue}"`} to - confirm. - - } - onConfirmClick={handleRotateSecret} - confirmButtonText={t`Rotate secret`} - loading={isLoading} - /> - - - Please type {`"${confirmationValue}"`} to confirm you want to delete - this app. All workspace installations linked to it will lose their - OAuth credentials. - - } - onConfirmClick={handleDelete} - confirmButtonText={t`Delete`} - loading={isLoading} - /> - - setTransferSubdomain('')} - padding="large" - dataGloballyPreventClickOutside - > - - - - - {t`Enter the workspace subdomain to transfer this app to. You will lose access to manage it.`} - -
- -
- { - closeModal(TRANSFER_OWNERSHIP_MODAL_ID); - setTransferSubdomain(''); - }} - variant="secondary" - title={t`Cancel`} - fullWidth + + + - -
- + {renderActiveTabContent()} + + ); }; diff --git a/packages/twenty-front/src/pages/settings/applications/components/SettingsRegisterNpmAppModal.tsx b/packages/twenty-front/src/pages/settings/applications/components/SettingsRegisterNpmAppModal.tsx deleted file mode 100644 index 5c101e8ab2..0000000000 --- a/packages/twenty-front/src/pages/settings/applications/components/SettingsRegisterNpmAppModal.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { styled } from '@linaria/react'; -import { useState } from 'react'; - -import { FIND_MANY_APPLICATION_REGISTRATIONS } from '@/settings/application-registrations/graphql/queries/findManyApplicationRegistrations'; -import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput'; -import { useModal } from '@/ui/layout/modal/hooks/useModal'; -import { useApolloClient } from '@apollo/client'; -import { useLingui } from '@lingui/react/macro'; -import { H1Title, H1TitleFontColor } from 'twenty-ui/display'; -import { Section, SectionAlignment, SectionFontColor } from 'twenty-ui/layout'; -import { themeCssVariables } from 'twenty-ui/theme-constants'; -import { useRegisterNpmPackage } from '~/modules/marketplace/hooks/useRegisterNpmPackage'; -import { - StyledAppModal, - StyledAppModalButton, - StyledAppModalSection, - StyledAppModalTitle, -} from '~/pages/settings/applications/components/SettingsAppModalLayout'; - -export const REGISTER_NPM_APP_MODAL_ID = 'register-npm-app-modal'; - -const StyledInputGroup = styled.div` - display: flex; - flex-direction: column; - gap: ${themeCssVariables.spacing[3]}; -`; - -export const SettingsRegisterNpmAppModal = () => { - const { t } = useLingui(); - const { closeModal } = useModal(); - const { register, isRegistering } = useRegisterNpmPackage(); - const apolloClient = useApolloClient(); - - const [packageName, setPackageName] = useState(''); - - const isValid = packageName.trim().length > 0; - - const handleRegister = async () => { - if (!isValid) { - return; - } - - const success = await register({ - packageName: packageName.trim(), - }); - - if (success) { - await apolloClient.refetchQueries({ - include: [FIND_MANY_APPLICATION_REGISTRATIONS], - }); - closeModal(REGISTER_NPM_APP_MODAL_ID); - setPackageName(''); - } - }; - - const handleCancel = () => { - closeModal(REGISTER_NPM_APP_MODAL_ID); - setPackageName(''); - }; - - return ( - - - - - - {t`Enter the npm package name to register as an application.`} - - -
- - - -
- - - -
- ); -}; diff --git a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationRegistrationDistributionTab.tsx b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationRegistrationDistributionTab.tsx new file mode 100644 index 0000000000..1a705864c5 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationRegistrationDistributionTab.tsx @@ -0,0 +1,165 @@ +import { SettingsAdminTableCard } from '@/settings/admin-panel/components/SettingsAdminTableCard'; +import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle'; +import { UPDATE_APPLICATION_REGISTRATION } from '@/settings/application-registrations/graphql/mutations/updateApplicationRegistration'; +import { FIND_APPLICATION_REGISTRATION_STATS } from '@/settings/application-registrations/graphql/queries/findApplicationRegistrationStats'; +import { FIND_MANY_APPLICATION_REGISTRATIONS } from '@/settings/application-registrations/graphql/queries/findManyApplicationRegistrations'; +import { FIND_ONE_APPLICATION_REGISTRATION } from '@/settings/application-registrations/graphql/queries/findOneApplicationRegistration'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { useMutation, useQuery } from '@apollo/client'; +import { styled } from '@linaria/react'; +import { useLingui } from '@lingui/react/macro'; +import { + H2Title, + IconChartBar, + IconDownload, + IconExternalLink, + IconTag, +} from 'twenty-ui/display'; +import { Button } from 'twenty-ui/input'; +import { Card, Section } from 'twenty-ui/layout'; +import { SettingsPath } from 'twenty-shared/types'; +import { getSettingsPath } from 'twenty-shared/utils'; +import { ApplicationRegistrationSourceType } from '~/generated-metadata/graphql'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; +import { type ApplicationRegistrationData } from '~/pages/settings/applications/tabs/types/ApplicationRegistrationData'; + +const StyledButtonGroup = styled.div` + display: flex; + gap: ${themeCssVariables.spacing[2]}; + padding-top: ${themeCssVariables.spacing[2]}; +`; + +export const SettingsApplicationRegistrationDistributionTab = ({ + registration, +}: { + registration: ApplicationRegistrationData; +}) => { + const { t } = useLingui(); + const { enqueueErrorSnackBar } = useSnackBar(); + + const applicationRegistrationId = registration.id; + + const isNpmSource = + registration.sourceType === ApplicationRegistrationSourceType.NPM; + + const { data: statsData } = useQuery(FIND_APPLICATION_REGISTRATION_STATS, { + variables: { id: applicationRegistrationId }, + skip: !applicationRegistrationId, + }); + + const [updateRegistration] = useMutation(UPDATE_APPLICATION_REGISTRATION, { + refetchQueries: [ + FIND_ONE_APPLICATION_REGISTRATION, + FIND_MANY_APPLICATION_REGISTRATIONS, + ], + }); + + const handleToggleListed = async () => { + try { + await updateRegistration({ + variables: { + input: { + id: applicationRegistrationId, + update: { + isListed: !registration.isListed, + }, + }, + }, + }); + } catch { + enqueueErrorSnackBar({ + message: t`Error updating marketplace listing`, + }); + } + }; + + const marketplacePageUrl = getSettingsPath( + SettingsPath.AvailableApplicationDetail, + { + availableApplicationId: registration.universalIdentifier, + }, + ); + + const stats = statsData?.findApplicationRegistrationStats; + const hasStats = (stats?.activeInstalls ?? 0) > 0; + + const versionDistributionLabel = + stats?.versionDistribution + ?.map( + (entry: { version: string; count: number }) => + `${entry.version} (${entry.count})`, + ) + .join(', ') || '—'; + + const statsItems = [ + { + Icon: IconDownload, + label: t`Active installs`, + value: stats?.activeInstalls ?? '—', + }, + { + Icon: IconTag, + label: t`Most installed version`, + value: stats?.mostInstalledVersion ?? '—', + }, + { + Icon: IconChartBar, + label: t`Distribution`, + value: versionDistributionLabel, + }, + ]; + + return ( + <> +
+ + + + {}} + disabled + /> + + +
+ + {hasStats && ( +
+ + +
+ )} + + ); +}; diff --git a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationRegistrationGeneralTab.tsx b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationRegistrationGeneralTab.tsx new file mode 100644 index 0000000000..b2211782b6 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationRegistrationGeneralTab.tsx @@ -0,0 +1,614 @@ +import { SettingsAdminTableCard } from '@/settings/admin-panel/components/SettingsAdminTableCard'; +import { DELETE_APPLICATION_REGISTRATION } from '@/settings/application-registrations/graphql/mutations/deleteApplicationRegistration'; +import { TRANSFER_APPLICATION_REGISTRATION_OWNERSHIP } from '@/settings/application-registrations/graphql/mutations/transferApplicationRegistrationOwnership'; +import { UPDATE_APPLICATION_REGISTRATION_VARIABLE } from '@/settings/application-registrations/graphql/mutations/updateApplicationRegistrationVariable'; +import { APPLICATION_REGISTRATION_TARBALL_URL } from '@/settings/application-registrations/graphql/queries/applicationRegistrationTarballUrl'; +import { FIND_APPLICATION_REGISTRATION_VARIABLES } from '@/settings/application-registrations/graphql/queries/findApplicationRegistrationVariables'; +import { FIND_MANY_APPLICATION_REGISTRATIONS } from '@/settings/application-registrations/graphql/queries/findManyApplicationRegistrations'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput'; +import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal'; +import { useModal } from '@/ui/layout/modal/hooks/useModal'; +import { useInstallMarketplaceApp } from '~/modules/marketplace/hooks/useInstallMarketplaceApp'; +import { useMutation, useQuery } from '@apollo/client'; +import { styled } from '@linaria/react'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { isNonEmptyString } from '@sniptt/guards'; +import { useState } from 'react'; +import { SettingsPath } from 'twenty-shared/types'; +import { getSettingsPath, isDefined } from 'twenty-shared/utils'; +import { + H1Title, + H1TitleFontColor, + H2Title, + IconArrowRight, + IconBox, + IconCheck, + IconDownload, + IconTag, + IconTextCaption, + IconTrash, + IconWorld, + Status, +} from 'twenty-ui/display'; +import { Button } from 'twenty-ui/input'; +import { Section, SectionAlignment, SectionFontColor } from 'twenty-ui/layout'; +import { + ApplicationRegistrationSourceType, + useFindManyApplicationsQuery, + useUninstallApplicationMutation, +} from '~/generated-metadata/graphql'; +import { useNavigateSettings } from '~/hooks/useNavigateSettings'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; +import { + StyledAppModal, + StyledAppModalButton, + StyledAppModalSection, + StyledAppModalTitle, +} from '~/pages/settings/applications/components/SettingsAppModalLayout'; +import { type ApplicationRegistrationData } from '~/pages/settings/applications/tabs/types/ApplicationRegistrationData'; + +const DELETE_REGISTRATION_MODAL_ID = 'delete-application-registration-modal'; +const TRANSFER_OWNERSHIP_MODAL_ID = + 'transfer-application-registration-ownership-modal'; +const UNINSTALL_APPLICATION_MODAL_ID = + 'uninstall-application-from-registration-modal'; + +const StyledVariableRow = styled.div` + align-items: center; + border-bottom: 1px solid ${themeCssVariables.border.color.light}; + display: flex; + gap: ${themeCssVariables.spacing[2]}; + padding: ${themeCssVariables.spacing[2]} 0; +`; + +const StyledVariableInfo = styled.div` + display: flex; + flex: 1; + flex-direction: column; + gap: ${themeCssVariables.spacing['0.5']}; +`; + +const StyledVariableKey = styled.span` + color: ${themeCssVariables.font.color.primary}; + font-family: monospace; + font-weight: ${themeCssVariables.font.weight.medium}; +`; + +const StyledVariableDescription = styled.span` + color: ${themeCssVariables.font.color.tertiary}; + font-size: ${themeCssVariables.font.size.sm}; +`; + +const StyledDangerButtonGroup = styled.div` + display: flex; + gap: ${themeCssVariables.spacing[2]}; +`; + +const StyledSourceRow = styled.div` + align-items: center; + display: flex; + gap: ${themeCssVariables.spacing[2]}; +`; + +const StyledDownloadLink = styled.a` + color: ${themeCssVariables.font.color.secondary}; + cursor: pointer; + text-decoration: underline; + &:hover { + color: ${themeCssVariables.font.color.primary}; + } +`; + +const StyledButtonGroup = styled.div` + display: flex; + gap: ${themeCssVariables.spacing[2]}; + padding-top: ${themeCssVariables.spacing[2]}; +`; + +type ServerVariable = { + id: string; + key: string; + description: string; + isSecret: boolean; + isRequired: boolean; + isFilled: boolean; +}; + +export const SettingsApplicationRegistrationGeneralTab = ({ + registration, + hasActiveInstalls, +}: { + registration: ApplicationRegistrationData; + hasActiveInstalls: boolean; +}) => { + const { t } = useLingui(); + const navigate = useNavigateSettings(); + const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar(); + const { openModal, closeModal } = useModal(); + + const [isInstalling, setIsInstalling] = useState(false); + const [isUninstalling, setIsUninstalling] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [isTransferring, setIsTransferring] = useState(false); + const [transferSubdomain, setTransferSubdomain] = useState(''); + const [variableValues, setVariableValues] = useState>( + {}, + ); + + const applicationRegistrationId = registration.id; + + const { data: variablesData } = useQuery( + FIND_APPLICATION_REGISTRATION_VARIABLES, + { + variables: { applicationRegistrationId }, + skip: !applicationRegistrationId, + }, + ); + + const { data: tarballUrlData } = useQuery( + APPLICATION_REGISTRATION_TARBALL_URL, + { + variables: { id: applicationRegistrationId }, + skip: !applicationRegistrationId, + }, + ); + + const [deleteRegistration] = useMutation(DELETE_APPLICATION_REGISTRATION, { + refetchQueries: [FIND_MANY_APPLICATION_REGISTRATIONS], + }); + + const [updateVariable] = useMutation( + UPDATE_APPLICATION_REGISTRATION_VARIABLE, + { + refetchQueries: [FIND_APPLICATION_REGISTRATION_VARIABLES], + }, + ); + + const [transferOwnership] = useMutation( + TRANSFER_APPLICATION_REGISTRATION_OWNERSHIP, + { + refetchQueries: [FIND_MANY_APPLICATION_REGISTRATIONS], + }, + ); + + const { install } = useInstallMarketplaceApp(); + const [uninstallApplication] = useUninstallApplicationMutation(); + const { data: applicationsData, refetch: refetchApplications } = + useFindManyApplicationsQuery(); + + const variables: ServerVariable[] = + variablesData?.findApplicationRegistrationVariables ?? []; + + const isNpmSource = + registration.sourceType === ApplicationRegistrationSourceType.NPM; + + const installedApp = (applicationsData?.findManyApplications ?? []).find( + (application) => + application.universalIdentifier === registration.universalIdentifier, + ); + + const isInstalledOnWorkspace = isDefined(installedApp); + + const installedAppUrl = isInstalledOnWorkspace + ? getSettingsPath(SettingsPath.ApplicationDetail, { + applicationId: installedApp.id, + }) + : undefined; + + const handleInstallOnWorkspace = async () => { + setIsInstalling(true); + try { + const success = await install({ + universalIdentifier: registration.universalIdentifier, + }); + + if (success) { + await refetchApplications(); + enqueueSuccessSnackBar({ + message: t`App installed on this workspace`, + }); + } + } catch { + enqueueErrorSnackBar({ + message: t`Error installing app`, + }); + } finally { + setIsInstalling(false); + } + }; + + const handleUninstallFromWorkspace = async () => { + setIsUninstalling(true); + try { + await uninstallApplication({ + variables: { + universalIdentifier: registration.universalIdentifier, + }, + }); + await refetchApplications(); + enqueueSuccessSnackBar({ + message: t`Application uninstalled from this workspace`, + }); + } catch { + enqueueErrorSnackBar({ + message: t`Error uninstalling application`, + }); + } finally { + setIsUninstalling(false); + } + }; + + const handleDelete = async () => { + setIsLoading(true); + try { + await deleteRegistration({ + variables: { id: applicationRegistrationId }, + }); + navigate(SettingsPath.Applications); + } catch { + enqueueErrorSnackBar({ + message: t`Error deleting app`, + }); + } finally { + setIsLoading(false); + } + }; + + const handleTransferOwnership = async () => { + const trimmed = transferSubdomain.trim(); + + if (!isNonEmptyString(trimmed)) { + return; + } + + setIsTransferring(true); + try { + await transferOwnership({ + variables: { + applicationRegistrationId, + targetWorkspaceSubdomain: trimmed, + }, + }); + enqueueSuccessSnackBar({ + message: t`Ownership transferred successfully`, + }); + setTransferSubdomain(''); + navigate(SettingsPath.Applications); + } catch { + enqueueErrorSnackBar({ + message: t`Failed to transfer ownership. Check that the subdomain is correct.`, + }); + } finally { + setIsTransferring(false); + } + }; + + const handleSaveVariableValue = async (variable: ServerVariable) => { + const value = variableValues[variable.id]; + const variableKey = variable.key; + + if (!isNonEmptyString(value)) { + return; + } + + try { + await updateVariable({ + variables: { + input: { + id: variable.id, + update: { + value, + }, + }, + }, + }); + setVariableValues((previous) => { + const next = { ...previous }; + + delete next[variable.id]; + + return next; + }); + enqueueSuccessSnackBar({ + message: t`Variable ${variableKey} updated`, + }); + } catch { + enqueueErrorSnackBar({ + message: t`Error updating variable`, + }); + } + }; + + const confirmationValue = t`yes`; + + const generalItems = [ + { + Icon: IconTag, + label: t`Name`, + value: registration.name, + }, + ...(isNonEmptyString(registration.description) + ? [ + { + Icon: IconTextCaption, + label: t`Description`, + value: registration.description, + }, + ] + : []), + { + Icon: IconWorld, + label: t`Universal ID`, + value: registration.universalIdentifier, + }, + ...(isNpmSource && isNonEmptyString(registration.sourcePackage) + ? [ + { + Icon: IconBox, + label: t`Package`, + value: registration.sourcePackage, + }, + ] + : registration.sourceType === ApplicationRegistrationSourceType.TARBALL + ? [ + { + Icon: IconBox, + label: t`Source`, + value: isNonEmptyString( + tarballUrlData?.applicationRegistrationTarballUrl, + ) ? ( + + + Tarball upload + + + Download + + + ) : ( + t`Tarball upload` + ), + }, + ] + : registration.sourceType === ApplicationRegistrationSourceType.LOCAL + ? [ + { + Icon: IconBox, + label: t`Source`, + value: t`Local development`, + }, + ] + : []), + ]; + + return ( + <> +
+ + +
+ +
+ + {isInstalledOnWorkspace ? ( + +
+ + + This will remove the application and all its data from this + workspace. Please type {`"${confirmationValue}"`} to confirm. + + } + onConfirmClick={handleUninstallFromWorkspace} + confirmButtonText={t`Uninstall`} + loading={isUninstalling} + /> + + {variables.length > 0 && ( +
+ + {variables.map((variable) => ( + + + + {variable.key} + {variable.isRequired && ( + * + )} + + {isNonEmptyString(variable.description) && ( + + {variable.description} + + )} + + {variable.isFilled && + !isNonEmptyString(variableValues[variable.id]) && ( + + )} + {!variable.isFilled && + !isNonEmptyString(variableValues[variable.id]) && ( + + )} + + setVariableValues((previous) => ({ + ...previous, + [variable.id]: value, + })) + } + placeholder={ + variable.isSecret ? t`Enter secret value` : t`Enter value` + } + fullWidth + /> +
+ )} + +
+ + +
+ + + Please type {`"${confirmationValue}"`} to confirm you want to delete + this app. All workspace installations linked to it will lose their + OAuth credentials. + + } + onConfirmClick={handleDelete} + confirmButtonText={t`Delete`} + loading={isLoading} + /> + + setTransferSubdomain('')} + padding="large" + dataGloballyPreventClickOutside + > + + + + + {t`Enter the workspace subdomain to transfer this app to. You will lose access to manage it.`} + +
+ +
+ { + closeModal(TRANSFER_OWNERSHIP_MODAL_ID); + setTransferSubdomain(''); + }} + variant="secondary" + title={t`Cancel`} + fullWidth + /> + +
+ + ); +}; diff --git a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationRegistrationOAuthTab.tsx b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationRegistrationOAuthTab.tsx new file mode 100644 index 0000000000..cc5e70a47d --- /dev/null +++ b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationRegistrationOAuthTab.tsx @@ -0,0 +1,298 @@ +import { SettingsAdminTableCard } from '@/settings/admin-panel/components/SettingsAdminTableCard'; +import { ROTATE_APPLICATION_REGISTRATION_CLIENT_SECRET } from '@/settings/application-registrations/graphql/mutations/rotateApplicationRegistrationClientSecret'; +import { UPDATE_APPLICATION_REGISTRATION } from '@/settings/application-registrations/graphql/mutations/updateApplicationRegistration'; +import { FIND_MANY_APPLICATION_REGISTRATIONS } from '@/settings/application-registrations/graphql/queries/findManyApplicationRegistrations'; +import { FIND_ONE_APPLICATION_REGISTRATION } from '@/settings/application-registrations/graphql/queries/findOneApplicationRegistration'; +import { ApiKeyInput } from '@/settings/developers/components/ApiKeyInput'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput'; +import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal'; +import { useModal } from '@/ui/layout/modal/hooks/useModal'; +import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue'; +import { useMutation } from '@apollo/client'; +import { styled } from '@linaria/react'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { isNonEmptyString } from '@sniptt/guards'; +import { useState } from 'react'; +import { + H2Title, + IconKey, + IconRefresh, + IconShield, + IconTrash, +} from 'twenty-ui/display'; +import { Button } from 'twenty-ui/input'; +import { Section } from 'twenty-ui/layout'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; +import { applicationRegistrationClientSecretFamilyState } from '~/pages/settings/applications/states/applicationRegistrationClientSecretFamilyState'; +import { type ApplicationRegistrationData } from '~/pages/settings/applications/tabs/types/ApplicationRegistrationData'; +import { isValidUrl } from 'twenty-shared/utils'; + +const ROTATE_SECRET_MODAL_ID = 'rotate-application-registration-secret-modal'; + +const StyledInputContainer = styled.div` + align-items: center; + display: flex; + flex-direction: row; + gap: ${themeCssVariables.spacing[2]}; + width: 100%; +`; + +const StyledRedirectUriRow = styled.div` + align-items: center; + display: flex; + gap: ${themeCssVariables.spacing[2]}; + padding: ${themeCssVariables.spacing[1]} 0; +`; + +const StyledRedirectUriValue = styled.span` + color: ${themeCssVariables.font.color.primary}; + font-family: monospace; + word-break: break-all; +`; + +const StyledRotateContainer = styled.div` + padding-top: ${themeCssVariables.spacing[2]}; +`; + +const StyledSaveContainer = styled.div` + display: flex; + gap: ${themeCssVariables.spacing[2]}; + padding-top: ${themeCssVariables.spacing[2]}; +`; + +export const SettingsApplicationRegistrationOAuthTab = ({ + registration, +}: { + registration: ApplicationRegistrationData; +}) => { + const { t } = useLingui(); + const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar(); + const { openModal } = useModal(); + + const applicationRegistrationId = registration.id; + + const applicationRegistrationClientSecret = useAtomFamilyStateValue( + applicationRegistrationClientSecretFamilyState, + applicationRegistrationId, + ); + + const [isLoading, setIsLoading] = useState(false); + const [rotatedSecret, setRotatedSecret] = useState(null); + const [formRedirectUris, setFormRedirectUris] = useState( + registration.oAuthRedirectUris ?? [], + ); + const [newRedirectUri, setNewRedirectUri] = useState(''); + const [hasChanges, setHasChanges] = useState(false); + + const [updateRegistration] = useMutation(UPDATE_APPLICATION_REGISTRATION, { + refetchQueries: [ + FIND_ONE_APPLICATION_REGISTRATION, + FIND_MANY_APPLICATION_REGISTRATIONS, + ], + }); + + const [rotateSecret] = useMutation( + ROTATE_APPLICATION_REGISTRATION_CLIENT_SECRET, + ); + + const markDirty = () => setHasChanges(true); + + const handleSave = async () => { + setIsLoading(true); + try { + await updateRegistration({ + variables: { + input: { + id: applicationRegistrationId, + update: { + oAuthRedirectUris: formRedirectUris, + }, + }, + }, + }); + setHasChanges(false); + enqueueSuccessSnackBar({ message: t`Redirect URIs updated` }); + } catch { + enqueueErrorSnackBar({ message: t`Error updating redirect URIs` }); + } finally { + setIsLoading(false); + } + }; + + const handleCancel = () => { + setFormRedirectUris(registration.oAuthRedirectUris ?? []); + setHasChanges(false); + }; + + const handleRotateSecret = async () => { + setIsLoading(true); + try { + const result = await rotateSecret({ + variables: { id: applicationRegistrationId }, + }); + const secret = + result.data?.rotateApplicationRegistrationClientSecret?.clientSecret; + + if (isNonEmptyString(secret)) { + setRotatedSecret(secret); + enqueueSuccessSnackBar({ + message: t`Client secret rotated. Copy it now — it won't be shown again.`, + }); + } + } catch { + enqueueErrorSnackBar({ + message: t`Error rotating client secret`, + }); + } finally { + setIsLoading(false); + } + }; + + const handleAddRedirectUri = () => { + const trimmed = newRedirectUri.trim(); + + if (!trimmed) { + return; + } + + if (!isValidUrl(trimmed)) { + enqueueErrorSnackBar({ message: t`Please enter a valid URL` }); + + return; + } + + if (formRedirectUris.includes(trimmed)) { + enqueueErrorSnackBar({ message: t`This redirect URI is already added` }); + + return; + } + + setFormRedirectUris([...formRedirectUris, trimmed]); + setNewRedirectUri(''); + markDirty(); + }; + + const handleRemoveRedirectUri = (index: number) => { + setFormRedirectUris( + formRedirectUris.filter((_, uriIndex) => uriIndex !== index), + ); + markDirty(); + }; + + const displayedSecret = rotatedSecret ?? applicationRegistrationClientSecret; + const confirmationValue = t`yes`; + + const credentialItems = [ + { + Icon: IconKey, + label: t`Client ID`, + value: registration.oAuthClientId, + }, + { + Icon: IconShield, + label: t`Scopes`, + value: (registration.oAuthScopes ?? []).join(', ') || '—', + }, + ]; + + return ( + <> +
+ + + +
+ + {displayedSecret && ( +
+ + +
+ )} + +
+ + {formRedirectUris.map((uri, index) => ( + + {uri} +
+ + + If you rotate this secret, any integration using the current secret + will stop working. Please type {`"${confirmationValue}"`} to + confirm. + + } + onConfirmClick={handleRotateSecret} + confirmButtonText={t`Rotate secret`} + loading={isLoading} + /> + + ); +}; diff --git a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationsDeveloperTab.tsx b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationsDeveloperTab.tsx index fd7915dbd0..a149bc8e64 100644 --- a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationsDeveloperTab.tsx +++ b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationsDeveloperTab.tsx @@ -2,10 +2,6 @@ import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMembe import { FIND_MANY_APPLICATION_REGISTRATIONS } from '@/settings/application-registrations/graphql/queries/findManyApplicationRegistrations'; import { SettingsListCard } from '@/settings/components/SettingsListCard'; import { getDocumentationUrl } from '@/support/utils/getDocumentationUrl'; -import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; -import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; -import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; -import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; import { useModal } from '@/ui/layout/modal/hooks/useModal'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; import { styled } from '@linaria/react'; @@ -18,30 +14,21 @@ import { CommandBlock, H2Title, IconApps, - IconChevronDown, IconChevronRight, IconCopy, - IconDownload, IconFileInfo, IconUpload, } from 'twenty-ui/display'; -import { Button, ButtonGroup, IconButton } from 'twenty-ui/input'; +import { Button } from 'twenty-ui/input'; import { Section } from 'twenty-ui/layout'; import { useContext } from 'react'; -import { MenuItem } from 'twenty-ui/navigation'; import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants'; import { useCopyToClipboard } from '~/hooks/useCopyToClipboard'; -import { - REGISTER_NPM_APP_MODAL_ID, - SettingsRegisterNpmAppModal, -} from '~/pages/settings/applications/components/SettingsRegisterNpmAppModal'; import { SettingsUploadTarballModal, UPLOAD_TARBALL_MODAL_ID, } from '~/pages/settings/applications/components/SettingsUploadTarballModal'; -const REGISTER_APP_DROPDOWN_ID = 'register-app-dropdown'; - const StyledButtonContainer = styled.div` margin: ${themeCssVariables.spacing[2]} 0; `; @@ -64,7 +51,6 @@ export const SettingsApplicationsDeveloperTab = () => { const navigate = useNavigate(); const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState); const { openModal } = useModal(); - const { closeDropdown } = useCloseDropdown(); const { copyToClipboard } = useCopyToClipboard(); @@ -90,16 +76,6 @@ export const SettingsApplicationsDeveloperTab = () => { /> ); - const handleRegisterFromNpm = () => { - closeDropdown(REGISTER_APP_DROPDOWN_ID); - openModal(REGISTER_NPM_APP_MODAL_ID); - }; - - const handleUploadTarball = () => { - closeDropdown(REGISTER_APP_DROPDOWN_ID); - openModal(UPLOAD_TARBALL_MODAL_ID); - }; - return ( <>
@@ -152,43 +128,15 @@ export const SettingsApplicationsDeveloperTab = () => { )}
- -