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 && (
-
+ <>
+
+
+ >
)}
{withCopyButton && (