cff17db6cb
## 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.
219 lines
6.3 KiB
TypeScript
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>
|
|
);
|
|
};
|