Compare commits

..
Author SHA1 Message Date
Charles BochetandCursor d66c6456c4 fix: distinguish soft-deleted relations from RLS-restricted ones
When a relation FK exists but the target record is null, the frontend
now checks whether row-level permission predicates are configured on
the target object. If RLS is active, shows "Not shared" (record may be
access-restricted). If no RLS exists, shows empty (record is
soft-deleted).

Fixes #20076

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 23:33:37 +02:00
df63dbff05 fix: Invalid configuration instead of related notes (#20251)
## Summary
The Notes widget (and other record-bound widgets like Tasks, Files,
Calendar, Emails) incorrectly displayed "Invalid Configuration" when
rendered on dashboard or standalone page contexts. The root cause was an
overly broad `ErrorBoundary` that caught all runtime errors uniformly
and displayed a misleading error message.
## Related issue
Fixes: #20118 
## Problem Analysis
**Proximate Cause:**
- `NotesCard` calls `useTargetRecord()` which throws a generic `Error`
when `targetRecordIdentifier` is undefined
- `ErrorBoundary` in `WidgetCardShell.tsx` catches this error and
renders `PageLayoutWidgetInvalidConfigDisplay`
- This displays "Invalid Configuration" which is factually misleading

**Triggering Cause:**
- Commit 5cd8b7899d removed the feature flag gate on page layouts,
making them standard for all workspaces
- This exposed record-bound widgets to dashboard contexts where
`targetRecordIdentifier` is intentionally undefined

**Error Propagation Chain:**
```
WidgetContentRenderer → NoteWidget → NotesCard → useTargetRecord()
useTargetRecord() throws Error('useTargetRecord must be used within a record page context')
ErrorBoundary catches error → PageLayoutWidgetInvalidConfigDisplay renders misleading UI
```
## Solution
Introduced a distinction between **configuration errors** and **record
context requirement errors** by:
1. Creating a custom error class `RecordContextRequiredError`
2. Updating `useTargetRecord()` to throw this specific error type
3. Creating a dedicated display component for record context errors
4. Updating the `ErrorBoundary` fallback to handle error types
appropriately
## User Impact
| Before | After |
|--------|-------|
| "Invalid Configuration" (red badge) | "Record Required" (gray badge) |
| Misleading error message | Accurate context-aware message |
| Users think widget is broken | Users understand widget needs record
context |

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 21:10:40 +00:00
7 changed files with 45 additions and 25 deletions
-1
View File
@@ -1 +0,0 @@
/work/repos/twentyhq-twenty/node_modules
@@ -33,6 +33,11 @@ export const useActivities = <T extends Task | Note>({
(activityTargets: (TaskTarget | NoteTarget)[]) => {
for (const activityTarget of activityTargets) {
const activity = activityTarget[objectNameSingular];
if (!isDefined(activity)) {
continue;
}
store.set(recordStoreFamilyState.atomFamily(activity.id), activity);
}
},
@@ -3,9 +3,11 @@ import { useContext } from 'react';
import { useActivityTargetObjectRecords } from '@/activities/hooks/useActivityTargetObjectRecords';
import { type NoteTarget } from '@/activities/types/NoteTarget';
import { type TaskTarget } from '@/activities/types/TaskTarget';
import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { RecordChip } from '@/object-record/components/RecordChip';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { isActivityTargetField } from '@/object-record/record-field-list/utils/categorizeRelationFields';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import { useFieldFocus } from '@/object-record/record-field/ui/hooks/useFieldFocus';
@@ -13,6 +15,7 @@ import { useRelationFromManyFieldDisplay } from '@/object-record/record-field/ui
import { ForbiddenFieldDisplay } from '@/object-record/record-field/ui/meta-types/display/components/ForbiddenFieldDisplay';
import { extractTargetRecordsFromJunction } from '@/object-record/record-field/ui/utils/junction/extractTargetRecordsFromJunction';
import { getJunctionConfig } from '@/object-record/record-field/ui/utils/junction/getJunctionConfig';
import { getTargetObjectMetadataIdsFromField } from '@/object-record/record-field/ui/utils/junction/getTargetObjectMetadataIdsFromField';
import { hasJunctionConfig } from '@/object-record/record-field/ui/utils/junction/hasJunctionConfig';
import { ExpandableList } from '@/ui/layout/expandable-list/components/ExpandableList';
@@ -37,6 +40,7 @@ export const RelationFromManyFieldDisplay = () => {
const { isFocused } = useFieldFocus();
const { disableChipClick, triggerEvent } = useContext(FieldContext);
const { objectMetadataItems } = useObjectMetadataItems();
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const { fieldName, objectMetadataNameSingular } = fieldDefinition.metadata;
@@ -147,7 +151,23 @@ export const RelationFromManyFieldDisplay = () => {
.filter(isDefined);
if (fieldValue.some(isDefined) && targetRecordsWithMetadata.length === 0) {
return <ForbiddenFieldDisplay />;
const targetObjectMetadataIds = junctionConfig.targetFields.flatMap(
getTargetObjectMetadataIdsFromField,
);
const hasRowLevelRestrictions = targetObjectMetadataIds.some(
(targetId) =>
getObjectPermissionsForObject(
objectPermissionsByObjectMetadataId,
targetId,
).rowLevelPermissionPredicates.length > 0,
);
if (hasRowLevelRestrictions) {
return <ForbiddenFieldDisplay />;
}
return null;
}
return (
@@ -1,5 +1,6 @@
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { RecordChip } from '@/object-record/components/RecordChip';
import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import { ForbiddenFieldDisplay } from '@/object-record/record-field/ui/meta-types/display/components/ForbiddenFieldDisplay';
import { useRelationToOneFieldDisplay } from '@/object-record/record-field/ui/meta-types/hooks/useRelationToOneFieldDisplay';
@@ -16,8 +17,19 @@ export const RelationToOneFieldDisplay = () => {
const { disableChipClick, triggerEvent } = useContext(FieldContext);
const targetObjectPermissions = useObjectPermissionsForObject(
fieldDefinition.metadata.relationObjectMetadataId,
);
if (!isDefined(fieldValue) && isDefined(foreignKeyFieldValue)) {
return <ForbiddenFieldDisplay />;
const hasRowLevelRestrictions =
targetObjectPermissions.rowLevelPermissionPredicates.length > 0;
if (hasRowLevelRestrictions) {
return <ForbiddenFieldDisplay />;
}
return null;
}
if (
@@ -90,9 +90,7 @@ const computeAnimatedButtonDynamicStyles = (
break;
case 'blue':
result.background = !inverted
? disabled
? themeCssVariables.accent.accent4060
: themeCssVariables.color.blue
? themeCssVariables.color.blue
: themeCssVariables.background.primary;
result.borderColor = !inverted
? focus
@@ -272,9 +270,7 @@ const computeAnimatedButtonDynamicStyles = (
}`
: 'none';
result.color = !inverted
? !disabled
? themeCssVariables.font.color.danger
: themeCssVariables.color.red5
? themeCssVariables.font.color.danger
: themeCssVariables.font.color.inverted;
result.hoverBackground = !inverted
? !disabled
@@ -27,10 +27,7 @@ const StyledButton = styled.button`
transition: background-color 0.1s ease;
&:hover {
background-color: ${({ disabled }) =>
!disabled
? themeCssVariables.background.transparent.light
: 'transparent'};
background-color: ${themeCssVariables.background.transparent.light};
}
`;
@@ -77,22 +77,13 @@ const StyledButton = styled.button<
fullWidth ? '100%' : width ? `${width}px` : 'auto'};
&:hover {
background: ${({ variant, disabled }) => {
if (disabled) {
switch (variant) {
case 'primary':
return themeCssVariables.background.secondary;
case 'secondary':
return themeCssVariables.background.primary;
default:
return themeCssVariables.background.primary;
}
}
switch (variant) {
case 'secondary':
return themeCssVariables.background.tertiary;
default:
return themeCssVariables.background.primaryInvertedHover;
return !disabled
? themeCssVariables.background.primaryInvertedHover
: themeCssVariables.background.secondary;
}
}};
}