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:
Binary file not shown.
@@ -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>
|
||||
);
|
||||
};
|
||||
+171
@@ -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>
|
||||
);
|
||||
};
|
||||
+22
@@ -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,
|
||||
};
|
||||
};
|
||||
+320
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
+128
@@ -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>
|
||||
|
||||
+13
-3
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+44
-16
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user