Files
twenty/packages/twenty-front/src/modules/activities/files/components/AttachmentRow.tsx
T
Félix Malfait cff17db6cb Enhance role-check system with stricter checks (#15392)
## Overview

This PR strengthens our permission system by introducing more granular
role-based access control across the platform.

## Changes

### New Permissions Added
- **Applications** - Control who can install and manage applications
- **Layouts** - Control who can customize page layouts and UI structure
- **AI** - Control access to AI features and agents
- **Upload File** - Separate permission for file uploads
- **Download File** - Separate permission for file downloads (frontend
visibility)

### Security Enhancements
- Implemented whitelist-based validation for workspace field updates
- Added explicit permission guards to core entity resolvers
- Enhanced ESLint rule to enforce permission checks on all mutations
- Created `CustomPermissionGuard` and `NoPermissionGuard` for better
code documentation

### Affected Components
- Core entity resolvers: webhooks, files, domains, applications,
layouts, postgres credentials
- Workspace update mutations now use whitelist validation
- Settings UI updated with new permission controls

### Developer Experience
- ESLint now catches missing permission guards during development
- Explicit guard markers make permission requirements clear in code
review
- Comprehensive test coverage for new permission logic

## Testing
-  All TypeScript type checks pass
-  ESLint validation passes
-  New permission guards properly enforced
-  Frontend UI displays new permissions correctly

## Migration Notes
Existing workspaces will need to assign the new permissions to roles as
needed. By default, all new permissions are set to `false` for non-admin
roles.
2025-11-07 15:37:17 +01:00

219 lines
6.3 KiB
TypeScript

import { ActivityRow } from '@/activities/components/ActivityRow';
import { AttachmentDropdown } from '@/activities/files/components/AttachmentDropdown';
import { type Attachment } from '@/activities/files/types/Attachment';
import { downloadFile } from '@/activities/files/utils/downloadFile';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useDestroyOneRecord } from '@/object-record/hooks/useDestroyOneRecord';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import {
FieldContext,
type GenericFieldContextType,
} from '@/object-record/record-field/ui/contexts/FieldContext';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { PREVIEWABLE_EXTENSIONS } from '@/activities/files/const/previewable-extensions.const';
import { FileIcon } from '@/file/components/FileIcon';
import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
import { IconCalendar, OverflowingTextWithTooltip } from 'twenty-ui/display';
import { isNavigationModifierPressed } from 'twenty-ui/utilities';
import { PermissionFlagType } from '~/generated-metadata/graphql';
import { formatToHumanReadableDate } from '~/utils/date-utils';
import { getFileNameAndExtension } from '~/utils/file/getFileNameAndExtension';
const StyledLeftContent = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(3)};
width: 100%;
overflow: auto;
flex: 1;
`;
const StyledRightContent = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(0.5)};
`;
const StyledCalendarIconContainer = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.light};
display: flex;
`;
const StyledLink = styled.a`
align-items: center;
appearance: none;
background: none;
border: none;
color: ${({ theme }) => theme.font.color.primary};
cursor: pointer;
display: flex;
font-family: inherit;
font-size: inherit;
padding: 0;
text-align: left;
text-decoration: none;
width: 100%;
:hover {
color: ${({ theme }) => theme.font.color.secondary};
}
`;
const StyledLinkContainer = styled.div`
overflow: auto;
width: 100%;
`;
type AttachmentRowProps = {
attachment: Attachment;
onPreview?: (attachment: Attachment) => void;
};
export const AttachmentRow = ({
attachment,
onPreview,
}: AttachmentRowProps) => {
const theme = useTheme();
const [isEditing, setIsEditing] = useState(false);
const hasDownloadPermission = useHasPermissionFlag(
PermissionFlagType.DOWNLOAD_FILE,
);
const { name: originalFileName, extension: attachmentFileExtension } =
getFileNameAndExtension(attachment.name);
const fileExtension =
attachmentFileExtension?.toLowerCase().replace('.', '') ?? '';
const isPreviewable = PREVIEWABLE_EXTENSIONS.includes(fileExtension);
const [attachmentFileName, setAttachmentFileName] =
useState(originalFileName);
const { destroyOneRecord: destroyOneAttachment } = useDestroyOneRecord({
objectNameSingular: CoreObjectNameSingular.Attachment,
});
const handleDelete = () => {
destroyOneAttachment(attachment.id);
};
const { updateOneRecord: updateOneAttachment } = useUpdateOneRecord({
objectNameSingular: CoreObjectNameSingular.Attachment,
});
const handleRename = () => {
setIsEditing(true);
};
const saveAttachmentName = () => {
setIsEditing(false);
const newFileName = `${attachmentFileName}${attachmentFileExtension}`;
updateOneAttachment({
idToUpdate: attachment.id,
updateOneRecordInput: { name: newFileName },
});
};
const handleOnBlur = () => {
saveAttachmentName();
};
const handleOnChange = (newFileName: string) => {
setAttachmentFileName(newFileName);
};
const handleOnKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
saveAttachmentName();
}
};
const handleDownload = () => {
downloadFile(
attachment.fullPath,
`${attachmentFileName}${attachmentFileExtension}`,
);
};
const handleOpenDocument = (e: React.MouseEvent) => {
// Cmd/Ctrl+click opens new tab, right click opens context menu
if (isNavigationModifierPressed(e) === true) {
return;
}
// Only prevent default and use preview if onPreview is provided
if (isDefined(onPreview)) {
e.preventDefault();
onPreview(attachment);
}
};
return (
<FieldContext.Provider
value={
{
recordId: attachment.id,
} as GenericFieldContextType
}
>
<ActivityRow disabled>
<StyledLeftContent>
<FileIcon fileCategory={attachment.fileCategory} />
{isEditing ? (
<SettingsTextInput
instanceId={`attachment-${attachment.id}-name`}
value={attachmentFileName}
onChange={handleOnChange}
onBlur={handleOnBlur}
autoFocus
onKeyDown={handleOnKeyDown}
/>
) : (
<StyledLinkContainer>
{isPreviewable ? (
<StyledLink
onClick={handleOpenDocument}
href={attachment.fullPath}
>
<OverflowingTextWithTooltip text={attachment.name} />
</StyledLink>
) : (
<StyledLink
href={attachment.fullPath}
target="_blank"
rel="noopener noreferrer"
>
<OverflowingTextWithTooltip text={attachment.name} />
</StyledLink>
)}
</StyledLinkContainer>
)}
</StyledLeftContent>
<StyledRightContent>
<StyledCalendarIconContainer>
<IconCalendar size={theme.icon.size.md} />
</StyledCalendarIconContainer>
{formatToHumanReadableDate(attachment.createdAt)}
<AttachmentDropdown
attachmentId={attachment.id}
onDelete={handleDelete}
onDownload={handleDownload}
onRename={handleRename}
hasDownloadPermission={hasDownloadPermission}
/>
</StyledRightContent>
</ActivityRow>
</FieldContext.Provider>
);
};