Skip to content

Commit 8c89269

Browse files
committed
refactor(perms): tighten comments and drop dead code
Cleanup pass on the issue-37 permission-filtering work — no behaviour changes, all 180 unit tests still pass, browser-verified across admin, restricted-with-PK and restricted-without-PK scenarios. - `fieldsWithRelational`: the previous `// CRITICAL: Always include the primary key field` comment was misleading because sanitize permission- filters the PK and language-code path immediately after. Comment now states the truth: PK + language code are added before the gate so sanitize can drop them when the user lacks read access, matching native Directus's permission-filtered `useCollection.primaryKeyField` (degraded interaction instead of an empty 403). - Drop the unused `probing` ref from `useTranslationLanguages` (declared, set, never consumed). - Strip issue/bug-ID references from in-source comments per CLAUDE.md — ticket pointers rot as the codebase evolves; PR descriptions own that history. - Compress over-long JSDoc and inline blocks in `sanitizeFilter`, `resolveTranslationValue`, `useTranslationLanguages`, and several spots in `super-table.vue`. WHY preserved, WHAT removed. - Add two regression tests documenting `sanitizeFields`' contract: primary key and `translations.languages_code` are both dropped when not in the user's read whitelist — callers rely on this to fall back gracefully. Net: +84 / -109 (-25 lines), 8 files.
1 parent 37deb8f commit 8c89269

8 files changed

Lines changed: 84 additions & 109 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2828
- `combinedFilter` no longer mixes side-effects with computed evaluation; the user notification is now emitted from a dedicated watcher.
2929
- `PermissionAction` union no longer includes the unused `'share'` action.
3030
- `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.
3132

3233
### Known limitations
3334
- 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.

src/components/EditableCellRelational.vue

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -257,9 +257,9 @@ const displayValue = computed(() => {
257257
return props.edits;
258258
}
259259
260-
// Special handling for translations fields (issue #37 bug C)
261-
// Delegated to a centralized helper to guard against translation-row objects
262-
// leaking through and rendering as the literal "[object Object]".
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]".
263263
if (actualFieldKey.value.includes('translations.')) {
264264
return resolveTranslationValue(
265265
props.item,
@@ -269,10 +269,8 @@ const displayValue = computed(() => {
269269
);
270270
}
271271
272-
// Handle relational fields with display templates.
273-
// Resolved priority: override → field-display → heuristic → none.
274-
// Issue #48: layout-level override (columnDisplays) takes priority over the
275-
// field-settings display.
272+
// Display-template resolution priority: column-display override →
273+
// field's own display template → relational heuristic → none.
276274
const storageKey = props.fieldKey.includes(':') ? props.fieldKey.split(':')[0] : props.fieldKey;
277275
const override = props.columnDisplays?.[storageKey];
278276
const fieldTemplate =

src/components/InlineEditPopover.vue

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -691,9 +691,9 @@ function handleCellClick(toggle: Function) {
691691
function formatDisplayValue(value: any): string {
692692
if (value === null || value === undefined) return '';
693693
694-
// Defensive guard (issue #37 bug C): a translation-row object leaked through
695-
// as the cell value (e.g. when sibling cells re-render while another cell's
696-
// popover opens). Extract its `text` so we never render "[object Object]".
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]".
697697
if (
698698
value &&
699699
typeof value === 'object' &&

src/composables/useTranslationLanguages.ts

Lines changed: 10 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,28 +2,19 @@ import { onMounted, ref, watch, type Ref } from 'vue';
22
import { useApi } from '@directus/extensions-sdk';
33

44
/**
5-
* Probes the user's accessible languages by querying which `languages_code`
6-
* values appear in the translations junction collection. The server enforces
7-
* the row-level filter automatically, so the result reflects exactly the
8-
* languages the user can read — including row-level restrictions that the
9-
* client cannot otherwise inspect (Directus does not expose raw permission
10-
* filters via /permissions/me).
11-
*
12-
* Returns:
13-
* - non-empty `string[]` when the probe succeeded and the collection had
14-
* at least one accessible row,
15-
* - `null` when the probe failed (network error, 403, etc.) — caller is
16-
* expected to fall back to permission-based detection,
17-
* - `null` when the collection is empty (no rows exist at all) so callers
18-
* don't accidentally drop every language column on a fresh installation.
5+
* Probe the translations junction for the language codes the user can actually
6+
* read. The server applies row-level permission filters during the aggregate,
7+
* so the result captures restrictions Directus does not expose via
8+
* `/permissions/me`. Returns `null` on failure or when the collection has no
9+
* accessible rows — the caller is expected to fall back to a permission-store
10+
* lookup so an empty collection doesn't silently drop every language column.
1911
*/
2012
export function useTranslationLanguages(
2113
translationsCollection: Ref<string | null>,
2214
languageCodeField: Ref<string>
2315
) {
2416
const api = useApi();
2517
const probedLanguages = ref<string[] | null>(null);
26-
const probing = ref(false);
2718

2819
async function probe(): Promise<void> {
2920
const collection = translationsCollection.value;
@@ -32,7 +23,6 @@ export function useTranslationLanguages(
3223
probedLanguages.value = null;
3324
return;
3425
}
35-
probing.value = true;
3626
try {
3727
const response = await api.get(`/items/${collection}`, {
3828
params: {
@@ -48,15 +38,12 @@ export function useTranslationLanguages(
4838
probedLanguages.value = codes.length > 0 ? codes : null;
4939
} catch {
5040
probedLanguages.value = null;
51-
} finally {
52-
probing.value = false;
5341
}
5442
}
5543

56-
// Re-probe on subsequent ref changes. Initial probe is deferred to onMounted
57-
// so we don't force evaluation of upstream computeds (e.g. hasTranslationFields)
58-
// before they are initialized in the parent setup() — a previous attempt with
59-
// `{ immediate: true }` triggered a TDZ error.
44+
// The initial probe is deferred to onMounted so upstream computeds (e.g.
45+
// `hasTranslationFields`) are initialized before the watcher's reactive
46+
// tracking touches them — `{ immediate: true }` here triggers a TDZ error.
6047
watch([translationsCollection, languageCodeField], () => {
6148
if (translationsCollection.value) probe();
6249
else probedLanguages.value = null;
@@ -66,5 +53,5 @@ export function useTranslationLanguages(
6653
if (translationsCollection.value) probe();
6754
});
6855

69-
return { probedLanguages, probing, probe };
56+
return { probedLanguages, probe };
7057
}

src/super-table.vue

Lines changed: 26 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -388,12 +388,10 @@ const perPageOptions = PER_PAGE_OPTIONS;
388388
const permissions = usePermissions();
389389
const filterSanitizationNotified = ref(false);
390390
391-
// Language items for v-select.
392-
// Uses the permission-store list (all languages the user can read in principle)
393-
// rather than the probe-based effectiveAccessibleLanguages — when adding columns
394-
// we want to offer every language the user could ever see, not just ones with
395-
// data right now. Column rendering itself still uses the stricter probe-based
396-
// list for tableHeaders/sanitizeFields.
391+
// "Add column" picker uses the permission-store list (every language the user
392+
// could ever see) rather than the probe-based `effectiveAccessibleLanguages`,
393+
// so empty languages still appear as options. Header rendering keeps the
394+
// stricter probe list.
397395
const languageItems = computed(() => {
398396
const baseList =
399397
languages.value && languages.value.length > 0 ? languages.value : DEFAULT_LANGUAGES;
@@ -449,8 +447,8 @@ const sortAllowed = computed(() => {
449447
// Use pagination composable
450448
const { page, limit } = useTablePagination(layoutQuery as any);
451449
452-
// Use sort composable — pass collection + fieldsStore so stale sort fields
453-
// referencing deleted columns are silently dropped (issue #47).
450+
// Pass collection + fieldsStore so stale sort fields referencing deleted
451+
// columns are silently dropped instead of triggering 400s on the next fetch.
454452
const { sort, tableSort, onSortChange } = useTableSort(
455453
layoutQuery as any,
456454
collection as Ref<string | null>,
@@ -508,32 +506,24 @@ const { aliasedFields, aliasQuery, getFromAliasedItem } = useAliasFields(
508506
columnDisplaysRef
509507
);
510508
511-
// Translation collection lookup (parent → junction). Used both for the
512-
// permission gate and for the row-level language probe below.
513-
// Declared AFTER `fields` is initialized because `hasTranslationFields`
514-
// reads `fields.value` and Vue's watcher in useTranslationLanguages tracks
515-
// dependencies eagerly during setup.
509+
// Must be declared after `fields` / `hasTranslationFields` so the watcher
510+
// inside `useTranslationLanguages` doesn't touch them before initialisation
511+
// (Vue tracks reactive dependencies eagerly during setup → TDZ otherwise).
516512
const translationsCollectionRef = computed<string | null>(() => {
517513
if (!hasTranslationFields.value) return null;
518514
return (
519515
relationsStore.getRelationsForField(collection.value, 'translations')?.[0]?.collection ?? null
520516
);
521517
});
522518
523-
// Probe which languages the user can actually read from translations rows.
524-
// `languages` collection permission is just a proxy — when admins restrict
525-
// translations row-by-row (e.g. via `languages_code._in: [de-DE, en-GB]`),
526-
// the probe is the only way to know exact accessible languages.
519+
// Probe wins over the static permission-store lookup because Directus does
520+
// not expose row-level filters (e.g. `languages_code._in: [de-DE, en-GB]`)
521+
// via /permissions/me — the aggregate query is the only way to discover them.
527522
const { probedLanguages } = useTranslationLanguages(
528523
translationsCollectionRef,
529524
computed(() => translationConfig.value.languageCodeField)
530525
);
531526
532-
// Effective list of accessible languages.
533-
// 1. Probe result, when available — most accurate (covers row-level filter).
534-
// 2. Permission-store `getAccessibleLanguages` fallback when probe returned
535-
// null (collection empty, probe failed, no translations).
536-
// 3. Empty array when neither source has data (defense in depth: no filter).
537527
const effectiveAccessibleLanguages = computed<string[]>(() => {
538528
if (probedLanguages.value && probedLanguages.value.length > 0) {
539529
return probedLanguages.value;
@@ -550,22 +540,21 @@ const fieldsWithRelational = computed(() => {
550540
return aliasInfo.fields || [aliasInfo.key];
551541
});
552542
553-
// Remove duplicates
554543
const adjustedFields = [...new Set(allDisplayFields)];
555544
556-
// CRITICAL: Always include the primary key field for navigation and identification
545+
// PK + language-code path are added BEFORE the permission gate so sanitize
546+
// can drop them when the user lacks read access — matches native Directus,
547+
// where `useCollection.primaryKeyField` is permission-filtered and a denied
548+
// PK simply degrades interaction (no item-key, no inline edit) rather than
549+
// 403'ing the entire fetch.
557550
const pkField = getPrimaryKeyFieldName();
558-
if (!adjustedFields.includes(pkField)) {
559-
adjustedFields.unshift(pkField); // Add at the beginning
560-
}
551+
if (!adjustedFields.includes(pkField)) adjustedFields.unshift(pkField);
561552
562-
// Ensure language code field is included for translations
563-
const languageFieldPath = getTranslationLanguageFieldPath(translationConfig.value);
564-
if (hasTranslationFields.value && !adjustedFields.includes(languageFieldPath)) {
565-
adjustedFields.push(languageFieldPath);
553+
if (hasTranslationFields.value) {
554+
const languageFieldPath = getTranslationLanguageFieldPath(translationConfig.value);
555+
if (!adjustedFields.includes(languageFieldPath)) adjustedFields.push(languageFieldPath);
566556
}
567557
568-
// Permission gate: drop fields/language-suffixed entries the user cannot read
569558
const translationsCollection = relationsStore.getRelationsForField(
570559
props.collection,
571560
'translations'
@@ -921,13 +910,11 @@ const sanitizedFilterResult = computed(() => {
921910
filters.length === 0 ? undefined : filters.length === 1 ? filters[0] : { _and: filters };
922911
if (!merged) return { sanitized: undefined, removed: [] as string[] };
923912
924-
// Permission check is scope-aware: when the walker descends into the
925-
// translations junction (via `_some`/`_none`/`_every`), `scope` will be
926-
// the junction collection name and we check sub-field permissions there
927-
// instead of on the parent collection. Without this, a filter like
928-
// `{ translations: { _some: { description: ... } } }` would slip past
929-
// the parent `canRead('translations')` check and hit the server with a
930-
// sub-field the user can't read — re-opening Bug E for nested filters.
913+
// `nestedScopes` makes the walker check sub-fields under `_some`/`_none`/
914+
// `_every` against the junction collection rather than the parent —
915+
// without it, `{ translations: { _some: { description: ... } } }` would
916+
// slip past the parent's `canRead('translations')` check and reach the
917+
// server with a sub-field the user cannot read.
931918
const nestedScopes: Record<string, string | undefined> = {
932919
translations: translationsCollectionRef.value ?? undefined,
933920
};

src/utils/resolveTranslationValue.ts

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,9 @@
11
/**
2-
* Safely resolve a translation field value from an item's translations array.
3-
*
4-
* Issue #37 (Bug C): When the popover opens for one cell, sibling translation
5-
* cells could re-render with the full translation row object as their value,
6-
* which then renders as the literal string "[object Object]" through Vue's
7-
* default `String()` coercion.
8-
*
9-
* This helper centralizes the lookup and guards against the edge case where
10-
* the resolved sub-field value is itself an object that contains a `text`
11-
* property (e.g. accidentally double-nested translation rows).
12-
*
13-
* @param item - Parent item carrying the `translations` array
14-
* @param fieldPath - Field key including the leading `translations.` segment (e.g. `translations.text`)
15-
* @param language - Target language code (e.g. `en-GB`); when null, no lookup is attempted
16-
* @param languageCodeField - Field on each translation row holding the language code
17-
* @returns The primitive translation value, or null when no translation matches
2+
* Resolve a single translation field value (e.g. `translations.text` for a
3+
* given language). Centralised so a sibling cell re-rendering during a popover
4+
* open cannot leak the whole translation row through Vue's `String()`
5+
* coercion as `"[object Object]"` — the final guard unwraps the row when it
6+
* accidentally arrives in place of the sub-field value.
187
*/
198
export function resolveTranslationValue(
209
item: any,

src/utils/sanitizeFilter.ts

Lines changed: 11 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,10 @@ export interface SanitizeResult {
77

88
export interface SanitizeFilterOptions {
99
/**
10-
* Map from a parent-field name to the collection it nests into.
11-
* When the walker traverses into the value of a field listed here,
12-
* inner field names will be checked against the nested collection's
13-
* permissions instead of the outer scope.
14-
*
15-
* Example: `{ translations: 'pages_translations' }`
16-
* - Top-level `translations` is checked against the parent collection.
17-
* - Sub-fields inside relation match operators (`_some`, `_none`, `_every`)
18-
* are checked against `pages_translations`.
10+
* Maps a parent-field name (e.g. `translations`) to the collection its
11+
* sub-fields live in (e.g. `pages_translations`). When the walker descends
12+
* into a `_some`/`_none`/`_every` value under that field, sub-fields are
13+
* checked against the nested collection instead of the outer scope.
1914
*/
2015
nestedScopes?: Record<string, string | undefined>;
2116
}
@@ -24,18 +19,13 @@ const LOGICAL_KEYS = new Set(['_and', '_or']);
2419
const RELATION_MATCH_OPS = new Set(['_some', '_none', '_every']);
2520

2621
/**
27-
* Walk a Directus filter tree and remove conditions that reference fields
28-
* the caller can't read. Logical operators (`_and`, `_or`) collapse if all
29-
* their branches are dropped. Relation match operators (`_some`, `_none`,
30-
* `_every`) recurse into a nested scope when configured via `nestedScopes`,
31-
* so e.g. `{ translations: { _some: { description: ... } } }` is checked
32-
* both at the parent level (translations field readable?) and at the
33-
* junction collection level (description field readable?).
34-
*
35-
* The `canRead` callback receives `(field, scope?)`. When `scope` is
36-
* undefined, the caller should check against the outer/parent collection.
37-
* When `scope` is set (matches a value from `nestedScopes`), the caller
38-
* should check against that collection.
22+
* Walk a Directus filter tree and remove conditions on fields the caller
23+
* cannot read. `_and`/`_or` branches collapse when emptied. Relation match
24+
* operators (`_some`/`_none`/`_every`) descend into the configured nested
25+
* scope so e.g. `{ translations: { _some: { description: ... } } }` is
26+
* checked at both the parent level (`translations` readable?) and the
27+
* junction-collection level (`description` readable?). The `canRead`
28+
* callback receives the current `scope` (undefined = parent collection).
3929
*/
4030
export function sanitizeFilter(
4131
filter: FilterValue,

tests/unit/composables/usePermissions.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,4 +184,27 @@ describe('usePermissions.sanitizeFields', () => {
184184
})
185185
).toEqual(['translations.text:de-DE']);
186186
});
187+
188+
// Documents the contract: sanitizeFields treats every field the same way,
189+
// including structural fields like the primary key and translation language
190+
// code. The `fieldsWithRelational` caller in super-table.vue relies on this
191+
// to fall back gracefully — when the user lacks read permission on the PK,
192+
// sanitize drops it, the API request omits it, and the layout degrades the
193+
// same way native Directus does (items render with limited interaction
194+
// instead of an empty 403 error state).
195+
it('drops the primary key when it is not in the read whitelist (graceful degradation)', () => {
196+
mockPermissions.value.issue_37_test.read.fields = ['title'];
197+
const { sanitizeFields } = usePermissions();
198+
expect(sanitizeFields('issue_37_test', ['id', 'title'])).toEqual(['title']);
199+
});
200+
201+
it('drops translations.languages_code when sub-field is not in the junction whitelist (graceful degradation)', () => {
202+
mockPermissions.value.issue_37_test_translations.read.fields = ['id', 'text'];
203+
const { sanitizeFields } = usePermissions();
204+
expect(
205+
sanitizeFields('issue_37_test', ['translations.languages_code', 'translations.text'], {
206+
translationsCollection: 'issue_37_test_translations',
207+
})
208+
).toEqual(['translations.text']);
209+
});
187210
});

0 commit comments

Comments
 (0)