diff --git a/packages/twenty-apps/internal/twenty-linear/.yarn/install-state.gz b/packages/twenty-apps/internal/twenty-linear/.yarn/install-state.gz deleted file mode 100644 index 3f53467b6d..0000000000 Binary files a/packages/twenty-apps/internal/twenty-linear/.yarn/install-state.gz and /dev/null differ diff --git a/packages/twenty-front/src/modules/applications/components/AppConnectionHeader.tsx b/packages/twenty-front/src/modules/applications/components/AppConnectionHeader.tsx new file mode 100644 index 0000000000..9ec5093f78 --- /dev/null +++ b/packages/twenty-front/src/modules/applications/components/AppConnectionHeader.tsx @@ -0,0 +1,90 @@ +import { styled } from '@linaria/react'; +import { useContext, useState } from 'react'; +import { isNonEmptyString } from '@sniptt/guards'; +import { Avatar, IconRefresh } from 'twenty-ui/display'; +import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants'; + +type AppConnectionHeaderProps = { + appLogoUrl?: string | null; + appName: string; +}; + +const StyledContainer = styled.div` + align-items: center; + display: flex; + gap: ${themeCssVariables.spacing[2]}; + justify-content: center; +`; + +const StyledAppLogoTile = styled.div` + align-items: center; + backdrop-filter: ${themeCssVariables.blur.strong}; + background: ${themeCssVariables.background.primary}; + border-radius: ${themeCssVariables.border.radius.md}; + box-shadow: ${themeCssVariables.boxShadow.strong}; + box-sizing: border-box; + display: flex; + flex-shrink: 0; + height: ${themeCssVariables.spacing[12]}; + justify-content: center; + padding: ${themeCssVariables.spacing[1]}; + width: ${themeCssVariables.spacing[12]}; +`; + +const StyledAppLogo = styled.img` + border-radius: ${themeCssVariables.border.radius.sm}; + height: ${themeCssVariables.spacing[10]}; + object-fit: cover; + width: ${themeCssVariables.spacing[10]}; +`; + +const StyledLinkIconContainer = styled.div` + align-items: center; + background: ${themeCssVariables.background.primary}; + border-radius: ${themeCssVariables.border.radius.rounded}; + box-shadow: ${themeCssVariables.boxShadow.strong}; + color: ${themeCssVariables.font.color.primary}; + display: flex; + flex-shrink: 0; + height: ${themeCssVariables.spacing[6]}; + justify-content: center; + width: ${themeCssVariables.spacing[6]}; +`; + +export const AppConnectionHeader = ({ + appLogoUrl, + appName, +}: AppConnectionHeaderProps) => { + const { theme } = useContext(ThemeContext); + + const [hasAppLogoError, setHasAppLogoError] = useState(false); + + const showAppLogoImage = isNonEmptyString(appLogoUrl) && !hasAppLogoError; + + return ( + + + + + + + + + {showAppLogoImage ? ( + setHasAppLogoError(true)} + /> + ) : ( + + )} + + + ); +}; diff --git a/packages/twenty-front/src/modules/applications/components/AuthorizeActionButtons.tsx b/packages/twenty-front/src/modules/applications/components/AuthorizeActionButtons.tsx new file mode 100644 index 0000000000..80929715da --- /dev/null +++ b/packages/twenty-front/src/modules/applications/components/AuthorizeActionButtons.tsx @@ -0,0 +1,55 @@ +import { styled } from '@linaria/react'; +import { useLingui } from '@lingui/react/macro'; +import { MainButton } from 'twenty-ui/input'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; + +type AuthorizeActionButtonsProps = { + onAuthorize: () => void; + onCancel: () => void; + isLoading?: boolean; +}; + +const StyledButtonContainer = styled.div` + display: grid; + gap: ${themeCssVariables.spacing[3]}; + grid-template-columns: repeat( + 2, + minmax(${themeCssVariables.spacing[0]}, 1fr) + ); + margin-top: ${themeCssVariables.spacing[8]}; + width: 100%; +`; + +const StyledAuthorizeButton = styled(MainButton)` + box-shadow: none; +`; + +const StyledCancelButton = styled(MainButton)` + box-shadow: none; +`; + +export const AuthorizeActionButtons = ({ + onAuthorize, + onCancel, + isLoading, +}: AuthorizeActionButtonsProps) => { + const { t } = useLingui(); + + return ( + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/marketplace/components/SettingsApplicationInstallPermissionValidationModal.tsx b/packages/twenty-front/src/modules/marketplace/components/SettingsApplicationInstallPermissionValidationModal.tsx new file mode 100644 index 0000000000..9f4607760f --- /dev/null +++ b/packages/twenty-front/src/modules/marketplace/components/SettingsApplicationInstallPermissionValidationModal.tsx @@ -0,0 +1,171 @@ +import { AppConnectionHeader } from '@/applications/components/AppConnectionHeader'; +import { AuthorizeActionButtons } from '@/applications/components/AuthorizeActionButtons'; +import { + buildPermissionSummaryFromRoleManifest, + type PermissionSummaryItem, +} from '@/marketplace/utils/buildPermissionSummaryFromRoleManifest'; +import { useModal } from '@/ui/layout/modal/hooks/useModal'; +import { styled } from '@linaria/react'; +import { t } from '@lingui/core/macro'; +import { useMemo } from 'react'; +import { type RoleManifest } from 'twenty-shared/application'; +import { IconChevronLeft } from 'twenty-ui/display'; +import { LightButton } from 'twenty-ui/input'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; +import { StyledAppModal } from '~/pages/settings/applications/components/SettingsAppModalLayout'; + +type SettingsApplicationInstallPermissionValidationModalProps = { + modalInstanceId: string; + appDisplayName: string; + appLogoUrl?: string; + defaultRole?: RoleManifest; + onAuthorize: () => void; + isInstalling?: boolean; +}; + +const StyledFullscreenContainer = styled.div` + align-items: center; + background: ${themeCssVariables.background.secondary}; + display: flex; + flex-direction: column; + height: 100%; + justify-content: center; + position: relative; + width: 100%; +`; + +const StyledLightButton = styled(LightButton)` + left: ${themeCssVariables.spacing[4]}; + position: absolute; + top: ${themeCssVariables.spacing[4]}; +`; + +const StyledContent = styled.div` + display: flex; + flex-direction: column; + max-width: 400px; + width: 100%; +`; + +const StyledAppConnectionHeaderContainer = styled.div` + margin-bottom: ${themeCssVariables.spacing[4]}; +`; + +const StyledTitle = styled.div` + color: ${themeCssVariables.font.color.primary}; + font-size: ${themeCssVariables.font.size.lg}; + font-weight: ${themeCssVariables.font.weight.semiBold}; + margin-bottom: ${themeCssVariables.spacing[6]}; + text-align: center; +`; + +const StyledPermissionsCard = styled.div` + background: ${themeCssVariables.background.primary}; + border: 1px solid ${themeCssVariables.border.color.medium}; + border-radius: ${themeCssVariables.border.radius.md}; + margin-bottom: ${themeCssVariables.spacing[6]}; + padding: ${themeCssVariables.spacing[4]}; +`; + +const StyledPermissionsTitle = styled.div` + color: ${themeCssVariables.font.color.primary}; + font-size: ${themeCssVariables.font.size.md}; + font-weight: ${themeCssVariables.font.weight.medium}; + margin-bottom: ${themeCssVariables.spacing[3]}; +`; + +const StyledPermissionRow = styled.div` + align-items: center; + color: ${themeCssVariables.font.color.secondary}; + display: flex; + font-size: ${themeCssVariables.font.size.md}; + gap: ${themeCssVariables.spacing[2]}; + padding: ${themeCssVariables.spacing[2]} 0; +`; + +const StyledPermissionIcon = styled.div` + color: ${themeCssVariables.color.blue9}; + display: flex; +`; + +export const SettingsApplicationInstallPermissionValidationModal = ({ + modalInstanceId, + appDisplayName, + appLogoUrl, + defaultRole, + onAuthorize, + isInstalling, +}: SettingsApplicationInstallPermissionValidationModalProps) => { + const { closeModal } = useModal(); + + const permissionItems: PermissionSummaryItem[] = useMemo(() => { + if (!defaultRole) { + return []; + } + + return buildPermissionSummaryFromRoleManifest(defaultRole); + }, [defaultRole]); + + const handleAuthorize = () => { + closeModal(modalInstanceId); + onAuthorize(); + }; + + const handleClose = () => { + closeModal(modalInstanceId); + }; + + return ( + + + + + + + + + + + {t`Install ${appDisplayName} on your workspace`} + + + {permissionItems.length > 0 && ( + + + {t`${appDisplayName} would like to:`} + + {permissionItems.map((item) => ( + + + + + {item.label} + + ))} + + )} + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/marketplace/hooks/useInstallMarketplaceAppWithPermissionValidation.ts b/packages/twenty-front/src/modules/marketplace/hooks/useInstallMarketplaceAppWithPermissionValidation.ts new file mode 100644 index 0000000000..69bbe7007f --- /dev/null +++ b/packages/twenty-front/src/modules/marketplace/hooks/useInstallMarketplaceAppWithPermissionValidation.ts @@ -0,0 +1,22 @@ +import { useModal } from '@/ui/layout/modal/hooks/useModal'; +import { useInstallMarketplaceApp } from '@/marketplace/hooks/useInstallMarketplaceApp'; +import { useCallback } from 'react'; + +const INSTALL_PERMISSION_VALIDATION_MODAL_ID = + 'install-permission-validation-modal'; + +export const useInstallMarketplaceAppWithPermissionValidation = () => { + const { openModal } = useModal(); + const { install, isInstalling } = useInstallMarketplaceApp(); + + const requestInstall = useCallback(() => { + openModal(INSTALL_PERMISSION_VALIDATION_MODAL_ID); + }, [openModal]); + + return { + requestInstall, + install, + isInstalling, + modalInstanceId: INSTALL_PERMISSION_VALIDATION_MODAL_ID, + }; +}; diff --git a/packages/twenty-front/src/modules/marketplace/utils/__tests__/buildPermissionSummaryFromRoleManifest.test.ts b/packages/twenty-front/src/modules/marketplace/utils/__tests__/buildPermissionSummaryFromRoleManifest.test.ts new file mode 100644 index 0000000000..0d13aaf8bf --- /dev/null +++ b/packages/twenty-front/src/modules/marketplace/utils/__tests__/buildPermissionSummaryFromRoleManifest.test.ts @@ -0,0 +1,320 @@ +import { type RoleManifest } from 'twenty-shared/application'; +import { SystemPermissionFlag } from 'twenty-shared/constants'; + +import { buildPermissionSummaryFromRoleManifest } from '@/marketplace/utils/buildPermissionSummaryFromRoleManifest'; + +const baseRole: RoleManifest = { + universalIdentifier: 'test-role', + label: 'Test Role', +}; + +describe('buildPermissionSummaryFromRoleManifest', () => { + it('should return empty array when role has no permissions', () => { + const result = buildPermissionSummaryFromRoleManifest(baseRole); + + expect(result).toEqual([]); + }); + + describe('record permissions', () => { + it('should return "Read records" for read-only', () => { + const role: RoleManifest = { + ...baseRole, + canReadAllObjectRecords: true, + }; + + const result = buildPermissionSummaryFromRoleManifest(role); + + expect(result).toHaveLength(1); + expect(result[0].label).toBe('Read records'); + }); + + it('should return "Write records" for write-only', () => { + const role: RoleManifest = { + ...baseRole, + canUpdateAllObjectRecords: true, + }; + + const result = buildPermissionSummaryFromRoleManifest(role); + + expect(result).toHaveLength(1); + expect(result[0].label).toBe('Write records'); + }); + + it('should return "Read and write records" for read+write', () => { + const role: RoleManifest = { + ...baseRole, + canReadAllObjectRecords: true, + canUpdateAllObjectRecords: true, + }; + + const result = buildPermissionSummaryFromRoleManifest(role); + + expect(result).toHaveLength(1); + expect(result[0].label).toBe('Read and write records'); + }); + + it('should return "Delete records" for soft-delete only', () => { + const role: RoleManifest = { + ...baseRole, + canSoftDeleteAllObjectRecords: true, + }; + + const result = buildPermissionSummaryFromRoleManifest(role); + + expect(result).toHaveLength(1); + expect(result[0].label).toBe('Delete records'); + }); + + it('should return "Delete records" for destroy only', () => { + const role: RoleManifest = { + ...baseRole, + canDestroyAllObjectRecords: true, + }; + + const result = buildPermissionSummaryFromRoleManifest(role); + + expect(result).toHaveLength(1); + expect(result[0].label).toBe('Delete records'); + }); + + it('should return "Read and delete records" for read+delete', () => { + const role: RoleManifest = { + ...baseRole, + canReadAllObjectRecords: true, + canSoftDeleteAllObjectRecords: true, + }; + + const result = buildPermissionSummaryFromRoleManifest(role); + + expect(result).toHaveLength(1); + expect(result[0].label).toBe('Read and delete records'); + }); + + it('should return "Read, write, and delete records" with Oxford comma for all three', () => { + const role: RoleManifest = { + ...baseRole, + canReadAllObjectRecords: true, + canUpdateAllObjectRecords: true, + canSoftDeleteAllObjectRecords: true, + }; + + const result = buildPermissionSummaryFromRoleManifest(role); + + expect(result).toHaveLength(1); + expect(result[0].label).toBe('Read, write, and delete records'); + }); + + it('should treat destroy the same as soft-delete for label purposes', () => { + const role: RoleManifest = { + ...baseRole, + canReadAllObjectRecords: true, + canUpdateAllObjectRecords: true, + canDestroyAllObjectRecords: true, + }; + + const result = buildPermissionSummaryFromRoleManifest(role); + + expect(result[0].label).toBe('Read, write, and delete records'); + }); + }); + + describe('object permissions fallback', () => { + it('should show "Access specific object records" when objectPermissions exist but no global record flags', () => { + const role: RoleManifest = { + ...baseRole, + objectPermissions: [ + { + universalIdentifier: 'perm-1', + objectUniversalIdentifier: 'obj-1', + canReadObjectRecords: true, + }, + ], + }; + + const result = buildPermissionSummaryFromRoleManifest(role); + + expect(result).toHaveLength(1); + expect(result[0].label).toBe('Access specific object records'); + }); + + it('should not show object permissions fallback when global record flags are set', () => { + const role: RoleManifest = { + ...baseRole, + canReadAllObjectRecords: true, + objectPermissions: [ + { + universalIdentifier: 'perm-1', + objectUniversalIdentifier: 'obj-1', + canReadObjectRecords: true, + }, + ], + }; + + const result = buildPermissionSummaryFromRoleManifest(role); + + expect(result).toHaveLength(1); + expect(result[0].label).toBe('Read records'); + }); + }); + + describe('permission flags', () => { + it('should add DATA_MODEL flag as "Read and write data model configuration"', () => { + const role: RoleManifest = { + ...baseRole, + permissionFlagUniversalIdentifiers: [SystemPermissionFlag.DATA_MODEL], + }; + + const result = buildPermissionSummaryFromRoleManifest(role); + + expect(result).toHaveLength(1); + expect(result[0].label).toBe('Read and write data model configuration'); + }); + + it('should add WORKFLOWS flag', () => { + const role: RoleManifest = { + ...baseRole, + permissionFlagUniversalIdentifiers: [SystemPermissionFlag.WORKFLOWS], + }; + + const result = buildPermissionSummaryFromRoleManifest(role); + + expect(result).toHaveLength(1); + expect(result[0].label).toBe('Manage workflows'); + }); + + it('should add SECURITY flag', () => { + const role: RoleManifest = { + ...baseRole, + permissionFlagUniversalIdentifiers: [SystemPermissionFlag.SECURITY], + }; + + const result = buildPermissionSummaryFromRoleManifest(role); + + expect(result).toHaveLength(1); + expect(result[0].label).toBe('Manage security settings'); + }); + + it('should add WORKSPACE_MEMBERS flag', () => { + const role: RoleManifest = { + ...baseRole, + permissionFlagUniversalIdentifiers: [ + SystemPermissionFlag.WORKSPACE_MEMBERS, + ], + }; + + const result = buildPermissionSummaryFromRoleManifest(role); + + expect(result).toHaveLength(1); + expect(result[0].label).toBe('Manage workspace members'); + }); + + it('should add BILLING flag', () => { + const role: RoleManifest = { + ...baseRole, + permissionFlagUniversalIdentifiers: [SystemPermissionFlag.BILLING], + }; + + const result = buildPermissionSummaryFromRoleManifest(role); + + expect(result).toHaveLength(1); + expect(result[0].label).toBe('Manage billing'); + }); + + it('should add API_KEYS_AND_WEBHOOKS flag', () => { + const role: RoleManifest = { + ...baseRole, + permissionFlagUniversalIdentifiers: [ + SystemPermissionFlag.API_KEYS_AND_WEBHOOKS, + ], + }; + + const result = buildPermissionSummaryFromRoleManifest(role); + + expect(result).toHaveLength(1); + expect(result[0].label).toBe('Manage API keys and webhooks'); + }); + + it('should ignore unknown permission flags', () => { + const role: RoleManifest = { + ...baseRole, + permissionFlagUniversalIdentifiers: [ + 'unknown-flag-00000000-0000-0000-0000-000000000000', + ], + }; + + const result = buildPermissionSummaryFromRoleManifest(role); + + expect(result).toEqual([]); + }); + }); + + describe('settings and tools', () => { + it('should add "Update workspace settings" when canUpdateAllSettings is true', () => { + const role: RoleManifest = { + ...baseRole, + canUpdateAllSettings: true, + }; + + const result = buildPermissionSummaryFromRoleManifest(role); + + expect(result).toHaveLength(1); + expect(result[0].label).toBe('Update workspace settings'); + }); + + it('should add "Access all tools" when canAccessAllTools is true', () => { + const role: RoleManifest = { + ...baseRole, + canAccessAllTools: true, + }; + + const result = buildPermissionSummaryFromRoleManifest(role); + + expect(result).toHaveLength(1); + expect(result[0].label).toBe('Access all tools'); + }); + }); + + describe('combined permissions', () => { + it('should combine all permission types in correct order', () => { + const role: RoleManifest = { + ...baseRole, + canReadAllObjectRecords: true, + canUpdateAllObjectRecords: true, + canUpdateAllSettings: true, + canAccessAllTools: true, + permissionFlagUniversalIdentifiers: [ + SystemPermissionFlag.DATA_MODEL, + SystemPermissionFlag.WORKFLOWS, + ], + }; + + const result = buildPermissionSummaryFromRoleManifest(role); + + expect(result.map((item) => item.label)).toEqual([ + 'Read and write records', + 'Read and write data model configuration', + 'Update workspace settings', + 'Access all tools', + 'Manage workflows', + ]); + }); + + it('should handle a role with only undefined/false permission values', () => { + const role: RoleManifest = { + ...baseRole, + canReadAllObjectRecords: false, + canUpdateAllObjectRecords: false, + canSoftDeleteAllObjectRecords: false, + canDestroyAllObjectRecords: false, + canUpdateAllSettings: false, + canAccessAllTools: false, + objectPermissions: [], + permissionFlagUniversalIdentifiers: [], + }; + + const result = buildPermissionSummaryFromRoleManifest(role); + + expect(result).toEqual([]); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/marketplace/utils/buildPermissionSummaryFromRoleManifest.ts b/packages/twenty-front/src/modules/marketplace/utils/buildPermissionSummaryFromRoleManifest.ts new file mode 100644 index 0000000000..7046dd9fdb --- /dev/null +++ b/packages/twenty-front/src/modules/marketplace/utils/buildPermissionSummaryFromRoleManifest.ts @@ -0,0 +1,128 @@ +import { type RoleManifest } from 'twenty-shared/application'; +import { isDefined } from 'twenty-shared/utils'; +import { + IconCode, + type IconComponent, + IconCurrencyDollar, + IconDatabase, + IconHierarchy, + IconKey, + IconSettings, + IconSettingsAutomation, + IconTool, + IconUsers, +} from 'twenty-ui/display'; +import { SystemPermissionFlag } from 'twenty-shared/constants'; + +export type PermissionSummaryItem = { + Icon: IconComponent; + label: string; +}; + +export const buildPermissionSummaryFromRoleManifest = ( + defaultRole: RoleManifest, +): PermissionSummaryItem[] => { + const items: PermissionSummaryItem[] = []; + + const canRead = defaultRole.canReadAllObjectRecords ?? false; + const canUpdate = defaultRole.canUpdateAllObjectRecords ?? false; + const canSoftDelete = defaultRole.canSoftDeleteAllObjectRecords ?? false; + const canDestroy = defaultRole.canDestroyAllObjectRecords ?? false; + + if (canRead || canUpdate || canSoftDelete || canDestroy) { + const capabilities: string[] = []; + + if (canRead) { + capabilities.push('read'); + } + + if (canUpdate) { + capabilities.push('write'); + } + + if (canSoftDelete || canDestroy) { + capabilities.push('delete'); + } + + const label = + capabilities.length <= 2 + ? capabilities.join(' and ') + : capabilities.slice(0, -1).join(', ') + + ', and ' + + capabilities[capabilities.length - 1]; + + items.push({ + Icon: IconDatabase, + label: label.charAt(0).toUpperCase() + label.slice(1) + ' records', + }); + } + + if ((defaultRole.objectPermissions ?? []).length > 0 && items.length === 0) { + items.push({ + Icon: IconDatabase, + label: 'Access specific object records', + }); + } + + const hasDataModelFlag = ( + defaultRole.permissionFlagUniversalIdentifiers ?? [] + ).some((flag) => flag === SystemPermissionFlag.DATA_MODEL); + + if (hasDataModelFlag) { + items.push({ + Icon: IconHierarchy, + label: 'Read and write data model configuration', + }); + } + + if (defaultRole.canUpdateAllSettings) { + items.push({ + Icon: IconSettings, + label: 'Update workspace settings', + }); + } + + if (defaultRole.canAccessAllTools) { + items.push({ + Icon: IconTool, + label: 'Access all tools', + }); + } + + const otherFlags = ( + defaultRole.permissionFlagUniversalIdentifiers ?? [] + ).filter((flag) => flag !== SystemPermissionFlag.DATA_MODEL); + + const flagLabels: Record = { + [SystemPermissionFlag.WORKFLOWS]: { + label: 'Manage workflows', + Icon: IconSettingsAutomation, + }, + [SystemPermissionFlag.SECURITY]: { + label: 'Manage security settings', + Icon: IconKey, + }, + [SystemPermissionFlag.WORKSPACE_MEMBERS]: { + label: 'Manage workspace members', + Icon: IconUsers, + }, + [SystemPermissionFlag.BILLING]: { + label: 'Manage billing', + Icon: IconCurrencyDollar, + }, + [SystemPermissionFlag.API_KEYS_AND_WEBHOOKS]: { + label: 'Manage API keys and webhooks', + Icon: IconCode, + }, + }; + + for (const flag of otherFlags) { + const config = flagLabels[flag]; + + if (isDefined(config)) { + items.push(config); + } + } + + return items; +}; diff --git a/packages/twenty-front/src/pages/auth/Authorize.tsx b/packages/twenty-front/src/pages/auth/Authorize.tsx index 96d2a7a9ae..092c936356 100644 --- a/packages/twenty-front/src/pages/auth/Authorize.tsx +++ b/packages/twenty-front/src/pages/auth/Authorize.tsx @@ -3,23 +3,20 @@ import { useContext, useEffect, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { AppPath } from 'twenty-shared/types'; +import { AppConnectionHeader } from '@/applications/components/AppConnectionHeader'; +import { AuthorizeActionButtons } from '@/applications/components/AuthorizeActionButtons'; import { useRedirect } from '@/domain-manager/hooks/useRedirect'; import { Trans, useLingui } from '@lingui/react/macro'; -import { useQuery, useMutation } from '@apollo/client/react'; -import { isNonEmptyString } from '@sniptt/guards'; +import { useMutation, useQuery } from '@apollo/client/react'; import { isDefined } from 'twenty-shared/utils'; import { - Avatar, H1Title, H1TitleFontColor, - IconDatabase, - IconRefresh, - IconUserCircle, type IconComponent, + IconDatabase, + IconUserCircle, } from 'twenty-ui/display'; -import { MainButton } from 'twenty-ui/input'; import { ModalContent } from 'twenty-ui/layout'; -import { UndecoratedLink } from 'twenty-ui/navigation'; import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants'; import { AuthorizeAppDocument, @@ -59,41 +56,6 @@ const StyledHeader = styled.div` width: 100%; `; -const StyledAppLogoTile = styled.div` - align-items: center; - backdrop-filter: ${themeCssVariables.blur.strong}; - background: ${themeCssVariables.background.primary}; - border-radius: ${themeCssVariables.border.radius.md}; - box-shadow: ${themeCssVariables.boxShadow.strong}; - box-sizing: border-box; - display: flex; - flex-shrink: 0; - height: ${themeCssVariables.spacing[12]}; - justify-content: center; - padding: ${themeCssVariables.spacing[1]}; - width: ${themeCssVariables.spacing[12]}; -`; - -const StyledAppLogo = styled.img` - border-radius: ${themeCssVariables.border.radius.sm}; - height: ${themeCssVariables.spacing[10]}; - object-fit: cover; - width: ${themeCssVariables.spacing[10]}; -`; - -const StyledLinkIconContainer = styled.div` - align-items: center; - background: ${themeCssVariables.background.primary}; - border-radius: ${themeCssVariables.border.radius.rounded}; - box-shadow: ${themeCssVariables.boxShadow.strong}; - color: ${themeCssVariables.font.color.primary}; - display: flex; - flex-shrink: 0; - height: ${themeCssVariables.spacing[6]}; - justify-content: center; - width: ${themeCssVariables.spacing[6]}; -`; - const StyledOAuthTitle = styled(H1Title)` margin: 0; max-width: min(100%, var(--oauth-modal-content-max-width)); @@ -102,29 +64,6 @@ const StyledOAuthTitle = styled(H1Title)` width: max-content; `; -const StyledButtonContainer = styled.div` - display: grid; - gap: ${themeCssVariables.spacing[3]}; - grid-template-columns: repeat( - 2, - minmax(${themeCssVariables.spacing[0]}, 1fr) - ); - margin-top: ${themeCssVariables.spacing[8]}; - width: 100%; -`; - -const StyledCancelLinkContainer = styled.div` - min-width: 0; - - a { - display: block; - } -`; - -const StyledAuthorizeButton = styled(MainButton)` - box-shadow: none; -`; - const StyledPermissionSection = styled.div` display: flex; flex-direction: column; @@ -224,7 +163,6 @@ export const Authorize = () => { const applicationRegistration = data?.findApplicationRegistrationByClientId; const [authorizeApp] = useMutation(AuthorizeAppDocument); - const [hasLogoError, setHasLogoError] = useState(false); const [authorizeError, setAuthorizeError] = useState(null); const [isAuthorizing, setIsAuthorizing] = useState(false); @@ -288,37 +226,11 @@ export const Authorize = () => { const appLogoUrl = applicationRegistration.logoUrl; const requestedScopes: string[] = applicationRegistration.oAuthScopes ?? []; - const showLogoImage = isNonEmptyString(appLogoUrl) && !hasLogoError; - return ( - - - - - - - - {showLogoImage ? ( - setHasLogoError(true)} - /> - ) : ( - - )} - + { {authorizeError && ( {authorizeError} )} - - - - - - - - + navigate(AppPath.Index)} + onAuthorize={handleAuthorize} + isLoading={isAuthorizing} + /> diff --git a/packages/twenty-front/src/pages/settings/applications/SettingsAvailableApplicationDetails.tsx b/packages/twenty-front/src/pages/settings/applications/SettingsAvailableApplicationDetails.tsx index 9825ee3da7..c1816f1941 100644 --- a/packages/twenty-front/src/pages/settings/applications/SettingsAvailableApplicationDetails.tsx +++ b/packages/twenty-front/src/pages/settings/applications/SettingsAvailableApplicationDetails.tsx @@ -1,5 +1,6 @@ import { CurrentApplicationContext } from '@/applications/contexts/CurrentApplicationContext'; -import { useInstallMarketplaceApp } from '@/marketplace/hooks/useInstallMarketplaceApp'; +import { SettingsApplicationInstallPermissionValidationModal } from '@/marketplace/components/SettingsApplicationInstallPermissionValidationModal'; +import { useInstallMarketplaceAppWithPermissionValidation } from '@/marketplace/hooks/useInstallMarketplaceAppWithPermissionValidation'; import { useUpgradeApplication } from '@/marketplace/hooks/useUpgradeApplication'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag'; @@ -45,7 +46,8 @@ export const SettingsAvailableApplicationDetails = () => { }>(); const navigateSettings = useNavigateSettings(); - const { install, isInstalling } = useInstallMarketplaceApp(); + const { requestInstall, install, isInstalling, modalInstanceId } = + useInstallMarketplaceAppWithPermissionValidation(); const { upgrade, isUpgrading } = useUpgradeApplication(); const canInstallMarketplaceApps = useHasPermissionFlag( @@ -231,7 +233,7 @@ export const SettingsAvailableApplicationDetails = () => { }} isInstalled={isAlreadyInstalled} canInstallMarketplaceApps={canInstallMarketplaceApps} - onInstall={handleInstall} + onInstall={requestInstall} isInstalling={isInstalling} hasUpdate={hasUpdate} onUpgrade={handleUpgrade} @@ -298,6 +300,14 @@ export const SettingsAvailableApplicationDetails = () => { {renderActiveTabContent()} + ); }; diff --git a/packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationRegistrationShareLinkButtons.tsx b/packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationRegistrationShareLinkButtons.tsx index ac07c5a6a0..14a2d856a0 100644 --- a/packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationRegistrationShareLinkButtons.tsx +++ b/packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationRegistrationShareLinkButtons.tsx @@ -1,16 +1,20 @@ -import { Button } from 'twenty-ui/input'; +import { SettingsApplicationInstallPermissionValidationModal } from '@/marketplace/components/SettingsApplicationInstallPermissionValidationModal'; +import { useInstallMarketplaceAppWithPermissionValidation } from '@/marketplace/hooks/useInstallMarketplaceAppWithPermissionValidation'; +import { styled } from '@linaria/react'; +import { useLingui } from '@lingui/react/macro'; +import { useQuery } from '@apollo/client/react'; +import { type Manifest } from 'twenty-shared/application'; +import { isDefined } from 'twenty-shared/utils'; import { IconArrowUpRight, IconCopy, IconDownload, IconInfoCircle, } from 'twenty-ui/display'; -import { styled } from '@linaria/react'; +import { Button } from 'twenty-ui/input'; import { themeCssVariables } from 'twenty-ui/theme-constants'; +import { FindMarketplaceAppDetailDocument } from '~/generated-metadata/graphql'; import { useCopyToClipboard } from '~/hooks/useCopyToClipboard'; -import { useLingui } from '@lingui/react/macro'; -import { useInstallMarketplaceApp } from '@/marketplace/hooks/useInstallMarketplaceApp'; -import { isDefined } from 'twenty-shared/utils'; const StyledButtonGroup = styled.div` display: flex; @@ -34,29 +38,53 @@ export const SettingsApplicationRegistrationShareLinkButtons = ({ const { copyToClipboard } = useCopyToClipboard(); - const { install, isInstalling } = useInstallMarketplaceApp(); + const { requestInstall, install, isInstalling, modalInstanceId } = + useInstallMarketplaceAppWithPermissionValidation(); const installable = isDefined(isInstalled) && isDefined(universalIdentifier) && !isInstalled; + const { data: detailData } = useQuery(FindMarketplaceAppDetailDocument, { + variables: { universalIdentifier: universalIdentifier ?? '' }, + skip: !installable || !isDefined(universalIdentifier), + }); + + const manifest = detailData?.findMarketplaceAppDetail?.manifest as + | Manifest + | undefined; + const app = manifest?.application; + const displayName = app?.displayName ?? ''; + + const defaultRole = manifest?.roles?.find( + (r) => r.universalIdentifier === app?.defaultRoleUniversalIdentifier, + ); + const handleInstall = async () => { if (installable) { - await install({ - universalIdentifier, - }); + await install({ universalIdentifier }); } }; return ( {installable && ( -