Add application installation validation modale (#20907)

## After

<img width="1512" height="829" alt="image"
src="https://github.com/user-attachments/assets/231d4f0d-6052-4c4e-9a2a-0244d2b3832e"
/>
This commit is contained in:
martmull
2026-05-26 11:48:31 +02:00
committed by GitHub
parent 015dca95fc
commit 7ca9081efa
12 changed files with 878 additions and 140 deletions
@@ -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 (
<StyledContainer>
<StyledAppLogoTile>
<StyledAppLogo src={'/images/integrations/twenty-logo.svg'} alt="" />
</StyledAppLogoTile>
<StyledLinkIconContainer aria-hidden>
<IconRefresh size={theme.icon.size.md} stroke={theme.icon.stroke.lg} />
</StyledLinkIconContainer>
<StyledAppLogoTile>
{showAppLogoImage ? (
<StyledAppLogo
src={appLogoUrl}
alt=""
onError={() => setHasAppLogoError(true)}
/>
) : (
<Avatar
size="xl"
placeholder={appName}
placeholderColorSeed={appName}
type="squared"
/>
)}
</StyledAppLogoTile>
</StyledContainer>
);
};
@@ -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 (
<StyledButtonContainer>
<StyledCancelButton
title={t`Cancel`}
variant="secondary"
onClick={onCancel}
fullWidth
disabled={isLoading}
/>
<StyledAuthorizeButton
title={isLoading ? t`Authorizing...` : t`Authorize`}
onClick={onAuthorize}
disabled={isLoading}
fullWidth
/>
</StyledButtonContainer>
);
};
@@ -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 (
<StyledAppModal
modalId={modalInstanceId}
isClosable
onClose={handleClose}
size="fullscreen"
padding="none"
overlay="transparent"
>
<StyledFullscreenContainer>
<StyledLightButton
Icon={IconChevronLeft}
title={t`Back to settings`}
onClick={handleClose}
/>
<StyledContent>
<StyledAppConnectionHeaderContainer>
<AppConnectionHeader
appLogoUrl={appLogoUrl}
appName={appDisplayName}
/>
</StyledAppConnectionHeaderContainer>
<StyledTitle>
{t`Install ${appDisplayName} on your workspace`}
</StyledTitle>
{permissionItems.length > 0 && (
<StyledPermissionsCard>
<StyledPermissionsTitle>
{t`${appDisplayName} would like to:`}
</StyledPermissionsTitle>
{permissionItems.map((item) => (
<StyledPermissionRow key={item.label}>
<StyledPermissionIcon>
<item.Icon size={16} />
</StyledPermissionIcon>
{item.label}
</StyledPermissionRow>
))}
</StyledPermissionsCard>
)}
<AuthorizeActionButtons
onCancel={handleClose}
onAuthorize={handleAuthorize}
isLoading={isInstalling}
/>
</StyledContent>
</StyledFullscreenContainer>
</StyledAppModal>
);
};
@@ -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,
};
};
@@ -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([]);
});
});
});
@@ -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<string, { label: string; Icon: IconComponent }> = {
[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;
};
@@ -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<string | null>(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 (
<ModalContent isVerticallyCentered isHorizontallyCentered>
<StyledCardWrapper>
<StyledHeader>
<StyledAppLogoTile>
<StyledAppLogo src="/images/integrations/twenty-logo.svg" alt="" />
</StyledAppLogoTile>
<StyledLinkIconContainer aria-hidden>
<IconRefresh
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
</StyledLinkIconContainer>
<StyledAppLogoTile>
{showLogoImage ? (
<StyledAppLogo
src={appLogoUrl}
alt=""
onError={() => setHasLogoError(true)}
/>
) : (
<Avatar
size="xl"
placeholder={appName}
placeholderColorSeed={appName}
type="squared"
/>
)}
</StyledAppLogoTile>
<AppConnectionHeader appLogoUrl={appLogoUrl} appName={appName} />
</StyledHeader>
<ModalContent contentPadding={10}>
<StyledOAuthTitle
@@ -352,24 +264,11 @@ export const Authorize = () => {
{authorizeError && (
<StyledErrorText>{authorizeError}</StyledErrorText>
)}
<StyledButtonContainer>
<StyledCancelLinkContainer>
<UndecoratedLink to={AppPath.Index} fullWidth>
<MainButton
title={t`Cancel`}
variant="secondary"
fullWidth
disabled={isAuthorizing}
/>
</UndecoratedLink>
</StyledCancelLinkContainer>
<StyledAuthorizeButton
title={isAuthorizing ? t`Authorizing...` : t`Authorize`}
onClick={handleAuthorize}
disabled={isAuthorizing}
fullWidth
/>
</StyledButtonContainer>
<AuthorizeActionButtons
onCancel={() => navigate(AppPath.Index)}
onAuthorize={handleAuthorize}
isLoading={isAuthorizing}
/>
</ModalContent>
</StyledCardWrapper>
</ModalContent>
@@ -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()}
</SettingsPageContainer>
</SubMenuTopBarContainer>
<SettingsApplicationInstallPermissionValidationModal
modalInstanceId={modalInstanceId}
appDisplayName={displayName}
appLogoUrl={app?.logoUrl}
defaultRole={defaultRole}
onAuthorize={handleInstall}
isInstalling={isInstalling}
/>
</CurrentApplicationContext.Provider>
);
};
@@ -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 (
<StyledButtonGroup>
{installable && (
<Button
Icon={IconDownload}
title={isInstalling ? t`Installing...` : t`Install`}
variant={'secondary'}
onClick={handleInstall}
disabled={isInstalling}
/>
<>
<Button
Icon={IconDownload}
title={isInstalling ? t`Installing...` : t`Install`}
variant={'secondary'}
onClick={requestInstall}
disabled={isInstalling}
/>
<SettingsApplicationInstallPermissionValidationModal
modalInstanceId={modalInstanceId}
appDisplayName={displayName}
appLogoUrl={app?.logoUrl}
defaultRole={defaultRole}
onAuthorize={handleInstall}
isInstalling={isInstalling}
/>
</>
)}
{withCopyButton && (
<Button
@@ -27,18 +27,23 @@ const StyledModalDiv = styled.div<{
}>`
display: flex;
flex-direction: column;
box-shadow: ${({ overlay }) =>
overlay === 'dark'
box-shadow: ${({ overlay, size }) => {
if (size === 'fullscreen') return 'none';
return overlay === 'dark'
? themeCssVariables.boxShadow.superHeavy
: overlay === 'transparent'
? 'none'
: themeCssVariables.boxShadow.strong};
background: ${({ overlay }) =>
overlay === 'transparent'
: themeCssVariables.boxShadow.strong;
}};
background: ${({ overlay, size }) => {
if (size === 'fullscreen') return themeCssVariables.background.primary;
return overlay === 'transparent'
? 'transparent'
: themeCssVariables.background.primary};
: themeCssVariables.background.primary;
}};
color: ${themeCssVariables.font.color.primary};
border-radius: ${({ isMobile, overlay, smallBorderRadius }) => {
border-radius: ${({ isMobile, overlay, smallBorderRadius, size }) => {
if (size === 'fullscreen') return '0';
if (isMobile === true || overlay === 'transparent') return '0';
if (smallBorderRadius === true) return themeCssVariables.spacing[1];
return themeCssVariables.border.radius.md;
@@ -51,6 +56,8 @@ const StyledModalDiv = styled.div<{
gap !== undefined ? `var(--t-spacing-${gap})` : 'unset'};
width: ${({ isMobile, size, narrowWidth }) => {
if (size === 'fullscreen')
return themeCssVariables.modal.size.fullscreen.width ?? 'auto';
if (narrowWidth === true)
return `calc(400px - ${themeCssVariables.spacing[32]})`;
if (isMobile)
@@ -84,6 +91,8 @@ const StyledModalDiv = styled.div<{
}
}};
height: ${({ isMobile, size, autoHeight }) => {
if (size === 'fullscreen')
return themeCssVariables.modal.size.fullscreen.height ?? 'auto';
if (autoHeight === true) return 'auto';
if (isMobile)
return themeCssVariables.modal.size.fullscreen.height ?? 'auto';
@@ -94,7 +103,8 @@ const StyledModalDiv = styled.div<{
return 'auto';
}
}};
max-height: ${({ isMobile }) => (isMobile ? 'none' : '90dvh')};
max-height: ${({ isMobile, size }) =>
isMobile || size === 'fullscreen' ? 'none' : '90dvh'};
`;
const AnimatedModalDiv = motion.create(StyledModalDiv);
@@ -1 +1,6 @@
export type ModalSize = 'small' | 'medium' | 'large' | 'extraLarge';
export type ModalSize =
| 'small'
| 'medium'
| 'large'
| 'extraLarge'
| 'fullscreen';