Skip to content

Commit a7b88e6

Browse files
authored
Merge pull request #57 from smartlabsAT/feature/issue-37-permission-filtering
feat: permission-aware layout (issue #37)
2 parents e495a40 + 1f18a70 commit a7b88e6

17 files changed

Lines changed: 1097 additions & 146 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,6 @@ live-test-*.png
1818
tags-vs-real-tags.png
1919
status-cell-debug.png
2020
current-page.png
21+
issue-37-*.png
22+
step*.png
23+
test*-*.png

CHANGELOG.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,35 @@ All notable changes to the Super Layout Table Extension will be documented in th
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## v0.4.0 — Permission-aware layout (Issue #37)
9+
10+
### Fixed
11+
- **Bug A:** Translation language columns the user cannot read are no longer rendered
12+
- **Bug B:** Inline-edit popover blocked on cells the user cannot update; tooltip explains
13+
- **Bug C:** `[object Object]` no longer leaks into translation cells when popover open
14+
- **Bug D:** Hauptcollection field permissions and translations field permissions handled consistently
15+
- **Bug E:** Filters referencing inaccessible fields are sanitized server-side; user notified
16+
- **Bug F:** Auto-resolved by Bug A — language code never leaks into header
17+
- **Bug G:** Bulk-action duplicate button hidden when user lacks create permission
18+
- **L2 (post-audit):** Asymmetric permissions — when the `languages` collection is unrestricted but the translations junction has a row-level filter on `languages_code`, an aggregate-query probe now resolves the exact accessible languages (instead of `--` placeholder columns).
19+
- **L5 (post-audit):** File-browser drawer no longer renders empty when the user lacks read on `directus_files`. A clear warning notification fires instead.
20+
21+
### Added
22+
- `usePermissions` composable as single source of truth for permission checks
23+
- `sanitizeFilter` utility for permission-aware filter trees
24+
- `useTranslationLanguages` composable: probes the translations junction collection for accessible languages (covers row-level filters not exposed by `/permissions/me`)
25+
- 403 errors during inline-save now surface as notifications instead of being swallowed
26+
27+
### Refactor
28+
- `combinedFilter` no longer mixes side-effects with computed evaluation; the user notification is now emitted from a dedicated watcher.
29+
- `PermissionAction` union no longer includes the unused `'share'` action.
30+
- `usePermissions` guards against array-shaped permission stores (legacy / future Directus shape changes) instead of silently failing.
31+
- `fieldsWithRelational` clarifies its behaviour: the primary key and translation language code path are added BEFORE the permission gate, and sanitize drops them only if the user lacks read permission. This mirrors native Directus's `useCollection.primaryKeyField`, which is itself permission-filtered, so users without PK read access see the same graceful degradation in both layouts (items render with limited interaction) instead of an empty 403 error state.
32+
- `useTableApi.fetchItems` no longer requests `meta=filter_count,total_count` together with the items. The server resolves that meta block via `countDistinct(<pk>)` and would 403 for users without read on the primary key; the count is now fetched in parallel via `aggregate[count]=*` (`fetchItemCount`), which uses SQL `COUNT(*)` and works regardless of field-level permissions. Users without PK access can now use the layout instead of seeing an empty 403 state.
33+
34+
### Known limitations
35+
- Bulk-action **Edit / Delete / Add Item** buttons (rendered by Directus Core, not by this extension) remain visible-but-disabled when the user lacks the corresponding permission. A future Directus core PR is required to fully hide them.
36+
837
## [0.3.2] - 2026-05-09
938

1039
### Fixed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "directus-extension-super-table",
3-
"version": "0.3.2",
3+
"version": "0.4.0",
44
"description": "A powerful and feature-rich table layout extension for Directus 11+ with inline editing, quick filters, and manual sorting",
55
"keywords": [
66
"directus",

src/actions.vue

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,8 @@
22
<div class="action-buttons">
33
<!-- Duplicate Button nur anzeigen wenn Items ausgewählt sind -->
44
<v-button
5-
v-if="hasSelection"
5+
v-if="hasSelection && canDuplicate"
66
v-tooltip.bottom="duplicateTooltip"
7-
:disabled="!canDuplicate"
87
icon
98
rounded
109
secondary
@@ -97,6 +96,7 @@ import { computed, ref } from 'vue';
9796
import { useStores, useCollection } from '@directus/extensions-sdk';
9897
import { useI18n } from 'vue-i18n';
9998
import { useTableApi } from './composables/api';
99+
import { usePermissions } from './composables/usePermissions';
100100
101101
// Props from layout state
102102
const props = defineProps<{
@@ -118,6 +118,7 @@ const { t } = useI18n();
118118
const tableApi = useTableApi();
119119
const { useNotificationsStore } = useStores();
120120
const notificationsStore = useNotificationsStore();
121+
const permissions = usePermissions();
121122
122123
// State for Save Filter Dialog
123124
const saveDialogActive = ref(false);
@@ -191,12 +192,7 @@ const hasNativeFilter = computed(() => {
191192
return true;
192193
});
193194
194-
// Als Admin haben wir immer Create-Rechte, außer es wird explizit verboten
195-
// In Layout Extensions ist der Permissions-Check anders als in anderen Extensions
196-
const canDuplicate = computed(() => {
197-
// Einfacher Check - wenn wir hier sind, haben wir vermutlich Rechte
198-
return true;
199-
});
195+
const canDuplicate = computed(() => permissions.canCreate(props.collection));
200196
201197
const duplicateTooltip = computed(() => {
202198
const count = props.selection?.length || 0;

src/components/EditableCellRelational.vue

Lines changed: 47 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
:interface-type="'tags'"
1010
:interface-options="interfaceOptions"
1111
:is-editable="isFieldEditableComputed"
12+
:permission-denied="permissionDenied"
1213
:is-relational="false"
1314
:auto-save="false"
1415
:saving="saving"
@@ -55,6 +56,7 @@
5556
:interface-type="getInterfaceType() || undefined"
5657
:interface-options="interfaceOptions"
5758
:is-editable="isFieldEditableComputed"
59+
:permission-denied="permissionDenied"
5860
:is-relational="false"
5961
:auto-save="false"
6062
:language-code-field="props.languageCodeField"
@@ -166,10 +168,13 @@ import ColorCell from './CellRenderers/ColorCell.vue';
166168
import TagCell from './TagCell.vue';
167169
import { isFieldEditable, getFieldEditWarning, getFieldSupportLevel } from '../utils/fieldSupport';
168170
import { pickHeuristic } from '../utils/displayHeuristics';
171+
import { resolveTranslationValue } from '../utils/resolveTranslationValue';
172+
import { usePermissions } from '../composables/usePermissions';
169173
170174
const { useFieldsStore, useRelationsStore } = useStores();
171175
const fieldsStore = useFieldsStore();
172176
const relationsStore = useRelationsStore();
177+
const permissions = usePermissions();
173178
174179
const props = defineProps<{
175180
item: Item;
@@ -252,39 +257,20 @@ const displayValue = computed(() => {
252257
return props.edits;
253258
}
254259
255-
// Special handling for translations fields
260+
// Translation sub-fields go through the centralised helper so a sibling
261+
// re-render during a popover open cannot leak a whole translation row
262+
// through as "[object Object]".
256263
if (actualFieldKey.value.includes('translations.')) {
257-
const translationField = actualFieldKey.value.split('.').slice(1).join('.');
258-
259-
// Check if translations exist and is an array
260-
if (Array.isArray(props.item.translations) && props.item.translations.length > 0) {
261-
// Use the language from field key (if specified) or the selected language
262-
const targetLanguage = fieldLanguage.value;
263-
264-
if (targetLanguage) {
265-
const languageField = props.languageCodeField || 'languages_code';
266-
const translation = props.item.translations.find(
267-
(t: any) => t[languageField] === targetLanguage
268-
);
269-
270-
// Return the specific field value if translation exists
271-
if (translation) {
272-
return translation[translationField] || null;
273-
}
274-
}
275-
276-
// No translation for this language
277-
return null;
278-
}
279-
280-
// No translations available at all
281-
return null;
264+
return resolveTranslationValue(
265+
props.item,
266+
actualFieldKey.value,
267+
fieldLanguage.value ?? null,
268+
props.languageCodeField || 'languages_code'
269+
);
282270
}
283271
284-
// Handle relational fields with display templates.
285-
// Resolved priority: override → field-display → heuristic → none.
286-
// Issue #48: layout-level override (columnDisplays) takes priority over the
287-
// field-settings display.
272+
// Display-template resolution priority: column-display override →
273+
// field's own display template → relational heuristic → none.
288274
const storageKey = props.fieldKey.includes(':') ? props.fieldKey.split(':')[0] : props.fieldKey;
289275
const override = props.columnDisplays?.[storageKey];
290276
const fieldTemplate =
@@ -425,16 +411,40 @@ const resolvedDisplay = computed<ResolvedDisplay>(() => {
425411
const isFieldEditableComputed = computed(() => {
426412
if (!props.editMode) return false;
427413
428-
// Special case: translation fields should be editable if the base type is supported
414+
// Permission check first — denies independent of field-support
415+
const collection = props.field?.collection || props.item?.collection;
416+
if (!collection) return false;
417+
429418
if (actualFieldKey.value.startsWith('translations.')) {
430-
// For now, allow editing of translation fields if edit mode is on
431-
// The actual field support check will be done in the InlineEditPopover
432-
return true;
419+
// Translation sub-field: resolve the junction collection and check update permission.
420+
// `collection` may already be the junction (when called from a translation cell whose
421+
// field metadata.collection points at the junction) or the parent collection. Try the
422+
// parent → junction lookup first; fall back to treating `collection` as the junction.
423+
const subField = actualFieldKey.value.split('.').slice(1).join('.');
424+
const parentRels = relationsStore.getRelationsForField(collection, 'translations');
425+
const transCollection = parentRels?.[0]?.collection || collection;
426+
if (!permissions.canUpdate(transCollection, subField)) return false;
427+
} else {
428+
if (!permissions.canUpdate(collection, actualFieldKey.value)) return false;
433429
}
434430
435-
// Use the field support utility which already handles tags and other partial support fields
436-
const editable = isFieldEditable(props.field, actualFieldKey.value);
437-
return editable;
431+
// Field-support check (unchanged)
432+
if (actualFieldKey.value.startsWith('translations.')) return true;
433+
return isFieldEditable(props.field, actualFieldKey.value);
434+
});
435+
436+
const permissionDenied = computed(() => {
437+
if (!props.editMode) return false;
438+
const collection = props.field?.collection || props.item?.collection;
439+
if (!collection) return false;
440+
441+
if (actualFieldKey.value.startsWith('translations.')) {
442+
const subField = actualFieldKey.value.split('.').slice(1).join('.');
443+
const parentRels = relationsStore.getRelationsForField(collection, 'translations');
444+
const transCollection = parentRels?.[0]?.collection || collection;
445+
return !permissions.canUpdate(transCollection, subField);
446+
}
447+
return !permissions.canUpdate(collection, actualFieldKey.value);
438448
});
439449
440450
// Get field edit warning message

src/components/InlineEditPopover.vue

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -404,10 +404,16 @@
404404

405405
<script setup lang="ts">
406406
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue';
407+
import { useStores } from '@directus/extensions-sdk';
407408
import { useTableApi } from '../composables/api';
409+
import { usePermissions } from '../composables/usePermissions';
408410
import TagEditor from './CellRenderers/TagEditor.vue';
409411
// Note: useDrawer might not be available in all versions, we'll use alternative approach
410412
413+
const { useNotificationsStore } = useStores();
414+
const notificationsStore = useNotificationsStore();
415+
const permissions = usePermissions();
416+
411417
interface Props {
412418
value: any;
413419
fieldKey: string;
@@ -427,6 +433,7 @@ interface Props {
427433
editModeActive?: boolean;
428434
fieldEditWarning?: string;
429435
languageCodeField?: string;
436+
permissionDenied?: boolean;
430437
}
431438
432439
const props = withDefaults(defineProps<Props>(), {
@@ -580,9 +587,20 @@ watch(menuActive, (active) => {
580587
}
581588
}
582589
583-
// For file fields, open drawer immediately instead of popover
590+
// For file fields, open drawer immediately instead of popover.
591+
// Pre-check: the drawer needs read access to directus_files to render.
592+
// Without it the drawer would appear empty / broken; surface a clear
593+
// notification instead.
584594
if (isFileField.value) {
585595
menuActive.value = false; // Close the popover
596+
if (!permissions.canRead('directus_files')) {
597+
notificationsStore.add({
598+
type: 'warning',
599+
title: 'No file access',
600+
text: "You don't have permission to browse files. Ask an admin to grant read access on directus_files.",
601+
});
602+
return;
603+
}
586604
// Initialize values before opening file browser
587605
originalValue.value = props.value;
588606
localValue.value = props.value;
@@ -631,29 +649,21 @@ watch(menuActive, (active) => {
631649
632650
// Methods
633651
function getFieldTooltip() {
634-
// Only show tooltip in edit mode for non-editable fields
635-
if (!props.editModeActive) {
636-
return undefined;
637-
}
638-
639-
// Show warning for partially supported or unsupported fields
652+
if (!props.editModeActive) return undefined;
653+
if (props.permissionDenied) return 'You do not have permission to edit this field';
640654
if (
641655
props.fieldSupportLevel === 'partial' ||
642656
props.fieldSupportLevel === 'none' ||
643657
props.fieldSupportLevel === 'readonly'
644658
) {
645659
return props.fieldEditWarning || 'This field has limited or no inline editing support';
646660
}
647-
648661
return undefined;
649662
}
650663
651664
function shouldShowIcon() {
652-
// Only show lock icons when edit mode is active and field is not editable
653-
if (!props.editModeActive) {
654-
return false;
655-
}
656-
// Only show icon for non-editable fields (lock indicator)
665+
if (!props.editModeActive) return false;
666+
if (props.permissionDenied) return true;
657667
return (
658668
props.fieldSupportLevel === 'none' ||
659669
props.fieldSupportLevel === 'readonly' ||
@@ -681,6 +691,19 @@ function handleCellClick(toggle: Function) {
681691
function formatDisplayValue(value: any): string {
682692
if (value === null || value === undefined) return '';
683693
694+
// Defensive guard: a translation-row object can leak through as the cell
695+
// value (e.g. when a sibling re-renders while another cell's popover opens).
696+
// Extract `text` so we never render "[object Object]".
697+
if (
698+
value &&
699+
typeof value === 'object' &&
700+
!Array.isArray(value) &&
701+
'text' in value &&
702+
'languages_code' in value
703+
) {
704+
return String((value as any).text ?? '');
705+
}
706+
684707
// Handle hash/password fields - show dots instead of actual value
685708
if (
686709
props.fieldType === 'hash' ||

0 commit comments

Comments
 (0)