Improve Permissions in relational Interfaces (#16373)

* improve permissions check in m2m relations

* improve m2o relations

* More improvements

* finish relational permission rework

* fix field level permissions

* add tests to useRelationPermissions

* remove unused dependency

* fix rename

* fix permissions for translations

* fix disabled check

* ran linter and disabled "vue/one-component-per-file" for use-relation-permissions test

---------

Co-authored-by: Azri Kahar <42867097+azrikahar@users.noreply.github.com>
Co-authored-by: Pascal Jufer <pascal-jufer@bluewin.ch>
Co-authored-by: rijkvanzanten <rijkvanzanten@me.com>
Co-authored-by: Brainslug <br41nslug@users.noreply.github.com>
Co-authored-by: Brainslug <tim@brainslug.nl>
This commit is contained in:
Nitwel
2023-04-24 16:33:28 +02:00
committed by GitHub
parent 4ccfd2a4ae
commit 871e7eb954
15 changed files with 802 additions and 178 deletions

View File

@@ -60,14 +60,14 @@
<table-row
:headers="internalHeaders"
:item="element"
:show-select="!disabled && showSelect"
:show-select="disabled ? 'none' : showSelect"
:show-manual-sort="!disabled && showManualSort"
:is-selected="getSelectedState(element)"
:subdued="loading || reordering"
:sorted-manually="internalSort.by === manualSortKey"
:has-click-listener="!disabled && clickable"
:height="rowHeight"
@click="clickable ? $emit('click:row', { item: element, event: $event }) : null"
@click="!disabled && clickable ? $emit('click:row', { item: element, event: $event }) : null"
@item-selected="
onItemSelected({
item: element,

View File

@@ -2,13 +2,13 @@
<div
data-dropzone
class="v-upload"
:class="{ dragging, uploading }"
:class="{ dragging: dragging && fromUser, uploading }"
@dragenter.prevent="onDragEnter"
@dragover.prevent
@dragleave.prevent="onDragLeave"
@drop.stop.prevent="onDrop"
>
<template v-if="dragging">
<template v-if="dragging && fromUser">
<v-icon class="upload-icon" x-large name="file_upload" />
<p class="type-label">{{ t('drop_to_upload') }}</p>
</template>
@@ -27,7 +27,7 @@
<template v-else>
<div class="actions">
<v-button v-tooltip="t('click_to_browse')" icon rounded secondary @click="openFileBrowser">
<v-button v-if="fromUser" v-tooltip="t('click_to_browse')" icon rounded secondary @click="openFileBrowser">
<input ref="input" class="browse" type="file" :multiple="multiple" @input="onBrowseSelect" />
<v-icon name="file_upload" />
</v-button>
@@ -41,12 +41,19 @@
>
<v-icon name="folder_open" />
</v-button>
<v-button v-if="fromUrl" v-tooltip="t('import_from_url')" icon rounded secondary @click="activeDialog = 'url'">
<v-button
v-if="fromUrl && fromUser"
v-tooltip="t('import_from_url')"
icon
rounded
secondary
@click="activeDialog = 'url'"
>
<v-icon name="link" />
</v-button>
</div>
<p class="type-label">{{ t('drag_file_here') }}</p>
<p class="type-label">{{ t(fromUser ? 'drag_file_here' : 'choose_from_library') }}</p>
<template v-if="fromUrl !== false || fromLibrary !== false">
<drawer-collection
@@ -99,6 +106,8 @@ interface Props {
multiple?: boolean;
preset?: Record<string, any>;
fileId?: string;
/** In case that the user isn't allowed to upload files */
fromUser?: boolean;
fromUrl?: boolean;
fromLibrary?: boolean;
folder?: string;
@@ -108,6 +117,7 @@ const props = withDefaults(defineProps<Props>(), {
multiple: false,
preset: () => ({}),
fileId: undefined,
fromUser: true,
fromUrl: false,
fromLibrary: false,
folder: undefined,
@@ -239,7 +249,7 @@ function useDragging() {
const files = event.dataTransfer?.files;
if (files) {
if (files && props.fromUser) {
upload(files);
}
}

View File

@@ -40,7 +40,7 @@ export function useRelationMultiple(
const fetchedItems = ref<Record<string, any>[]>([]);
const existingItemCount = ref(0);
const { cleanItem, getPage, localDelete, getItemEdits, isEmpty } = useUtil();
const { cleanItem, getPage, isLocalItem, getItemEdits, isEmpty } = useUtil();
const _value = computed<ChangesItem>({
get() {
@@ -672,7 +672,7 @@ export function useRelationMultiple(
return false;
}
function localDelete(item: DisplayItem) {
function isLocalItem(item: DisplayItem) {
return item.$type !== undefined && (item.$type !== 'updated' || isItemSelected(item));
}
@@ -709,7 +709,7 @@ export function useRelationMultiple(
return {};
}
return { cleanItem, getPage, localDelete, getItemEdits, isEmpty };
return { cleanItem, getPage, isLocalItem, getItemEdits, isEmpty };
}
return {
@@ -726,7 +726,7 @@ export function useRelationMultiple(
useActions,
cleanItem,
isItemSelected,
localDelete,
isLocalItem,
getItemEdits,
};
}

View File

@@ -0,0 +1,435 @@
import { test, expect, vi } from 'vitest';
import { defineComponent, h, ref } from 'vue';
import { mount } from '@vue/test-utils';
import {
useRelationPermissionsM2A,
useRelationPermissionsM2M,
useRelationPermissionsM2O,
useRelationPermissionsO2M,
} from '@/composables/use-relation-permissions';
import { RelationO2M } from './use-relation-o2m';
import { createTestingPinia } from '@pinia/testing';
import { merge } from 'lodash';
import { RelationM2O } from './use-relation-m2o';
import { RelationM2M } from './use-relation-m2m';
import { RelationM2A } from './use-relation-m2a';
const currentUser = {
id: '9ff55949-9f97-4fa7-a09e-db81585d6c52',
first_name: 'Admin',
last_name: 'User',
email: 'admin@example.com',
role: {
admin_access: true,
app_access: true,
id: 'd5c4be68-627f-4082-a8c2-5d1f3ebce2f2',
},
};
const permissions = [
{
role: 'd5c4be68-627f-4082-a8c2-5d1f3ebce2f2',
permissions: {},
validation: null,
presets: null,
fields: ['*'],
system: false,
collection: 'a_b',
action: 'update',
},
{
role: 'd5c4be68-627f-4082-a8c2-5d1f3ebce2f2',
permissions: {},
validation: null,
presets: null,
fields: ['*'],
system: false,
collection: 'b',
action: 'update',
},
];
const relationO2M = ref({
relation: {
meta: {
one_deselect_action: 'nullify',
},
},
relatedCollection: {
collection: 'b',
},
type: 'o2m',
} as RelationO2M);
test('useRelationPermissionsO2M as admin', () => {
// eslint-disable-next-line vue/one-component-per-file
const TestComponent = defineComponent({
setup() {
return useRelationPermissionsO2M(relationO2M);
},
render: () => h('div'),
});
const wrapper = mount(TestComponent, {
global: {
plugins: [
createTestingPinia({
initialState: {
userStore: { currentUser },
permissionsStore: { permissions },
},
createSpy: vi.fn,
}),
],
},
});
expect(wrapper.vm.createAllowed).toBeTruthy();
expect(wrapper.vm.updateAllowed).toBeTruthy();
expect(wrapper.vm.deleteAllowed).toBeTruthy();
});
test('useRelationPermissionsO2M with no permissions', () => {
// eslint-disable-next-line vue/one-component-per-file
const TestComponent = defineComponent({
setup() {
return useRelationPermissionsO2M(relationO2M);
},
render: () => h('div'),
});
const wrapper = mount(TestComponent, {
global: {
plugins: [
createTestingPinia({
initialState: {
userStore: { currentUser: merge({}, currentUser, { role: { admin_access: false } }) },
permissionsStore: { permissions: [] },
},
createSpy: vi.fn,
}),
],
},
});
expect(wrapper.vm.createAllowed).toBeFalsy();
expect(wrapper.vm.updateAllowed).toBeFalsy();
expect(wrapper.vm.deleteAllowed).toBeFalsy();
});
test('useRelationPermissionsO2M with update permissions', () => {
// eslint-disable-next-line vue/one-component-per-file
const TestComponent = defineComponent({
setup() {
return useRelationPermissionsO2M(relationO2M);
},
render: () => h('div'),
});
const wrapper = mount(TestComponent, {
global: {
plugins: [
createTestingPinia({
initialState: {
userStore: { currentUser: merge({}, currentUser, { role: { admin_access: false } }) },
permissionsStore: { permissions },
},
createSpy: vi.fn,
}),
],
},
});
expect(wrapper.vm.createAllowed).toBeFalsy();
expect(wrapper.vm.updateAllowed).toBeTruthy();
expect(wrapper.vm.deleteAllowed).toBeTruthy();
});
const relationM2O = ref({
relatedCollection: {
collection: 'b',
},
type: 'm2o',
} as RelationM2O);
test('useRelationPermissionsM2O as admin', () => {
// eslint-disable-next-line vue/one-component-per-file
const TestComponent = defineComponent({
setup() {
return useRelationPermissionsM2O(relationM2O);
},
render: () => h('div'),
});
const wrapper = mount(TestComponent, {
global: {
plugins: [
createTestingPinia({
initialState: {
userStore: { currentUser },
permissionsStore: { permissions },
},
createSpy: vi.fn,
}),
],
},
});
expect(wrapper.vm.createAllowed).toBeTruthy();
expect(wrapper.vm.updateAllowed).toBeTruthy();
});
test('useRelationPermissionsM2O with no permissions', () => {
// eslint-disable-next-line vue/one-component-per-file
const TestComponent = defineComponent({
setup() {
return useRelationPermissionsM2O(relationM2O);
},
render: () => h('div'),
});
const wrapper = mount(TestComponent, {
global: {
plugins: [
createTestingPinia({
initialState: {
userStore: { currentUser: merge({}, currentUser, { role: { admin_access: false } }) },
permissionsStore: { permissions: [] },
},
createSpy: vi.fn,
}),
],
},
});
expect(wrapper.vm.createAllowed).toBeFalsy();
expect(wrapper.vm.updateAllowed).toBeFalsy();
});
test('useRelationPermissionsM2O with update permissions', () => {
// eslint-disable-next-line vue/one-component-per-file
const TestComponent = defineComponent({
setup() {
return useRelationPermissionsM2O(relationM2O);
},
render: () => h('div'),
});
const wrapper = mount(TestComponent, {
global: {
plugins: [
createTestingPinia({
initialState: {
userStore: { currentUser: merge({}, currentUser, { role: { admin_access: false } }) },
permissionsStore: { permissions },
},
createSpy: vi.fn,
}),
],
},
});
expect(wrapper.vm.createAllowed).toBeFalsy();
expect(wrapper.vm.updateAllowed).toBeTruthy();
});
const relationM2M = ref({
junctionCollection: {
collection: 'a_b',
},
relatedCollection: {
collection: 'b',
},
junction: {
meta: {
one_deselect_action: 'nullify',
},
},
} as RelationM2M);
test('useRelationPermissionsM2M as admin', () => {
// eslint-disable-next-line vue/one-component-per-file
const TestComponent = defineComponent({
setup() {
return useRelationPermissionsM2M(relationM2M);
},
render: () => h('div'),
});
const wrapper = mount(TestComponent, {
global: {
plugins: [
createTestingPinia({
initialState: {
userStore: { currentUser },
permissionsStore: { permissions },
},
createSpy: vi.fn,
}),
],
},
});
expect(wrapper.vm.createAllowed).toBeTruthy();
expect(wrapper.vm.updateAllowed).toBeTruthy();
expect(wrapper.vm.deleteAllowed).toBeTruthy();
expect(wrapper.vm.selectAllowed).toBeTruthy();
});
test('useRelationPermissionsM2M with no permissions', () => {
// eslint-disable-next-line vue/one-component-per-file
const TestComponent = defineComponent({
setup() {
return useRelationPermissionsM2M(relationM2M);
},
render: () => h('div'),
});
const wrapper = mount(TestComponent, {
global: {
plugins: [
createTestingPinia({
initialState: {
userStore: { currentUser: merge({}, currentUser, { role: { admin_access: false } }) },
permissionsStore: { permissions: [] },
},
createSpy: vi.fn,
}),
],
},
});
expect(wrapper.vm.createAllowed).toBeFalsy();
expect(wrapper.vm.updateAllowed).toBeFalsy();
expect(wrapper.vm.deleteAllowed).toBeFalsy();
expect(wrapper.vm.selectAllowed).toBeFalsy();
});
test('useRelationPermissionsM2M with update permissions', () => {
// eslint-disable-next-line vue/one-component-per-file
const TestComponent = defineComponent({
setup() {
return useRelationPermissionsM2M(relationM2M);
},
render: () => h('div'),
});
const wrapper = mount(TestComponent, {
global: {
plugins: [
createTestingPinia({
initialState: {
userStore: { currentUser: merge({}, currentUser, { role: { admin_access: false } }) },
permissionsStore: { permissions },
},
createSpy: vi.fn,
}),
],
},
});
expect(wrapper.vm.createAllowed).toBeFalsy();
expect(wrapper.vm.updateAllowed).toBeTruthy();
expect(wrapper.vm.deleteAllowed).toBeTruthy();
expect(wrapper.vm.selectAllowed).toBeFalsy();
});
const relationM2A = ref({
junctionCollection: {
collection: 'a_b',
},
allowedCollections: [{ collection: 'a' }, { collection: 'b' }],
junction: {
meta: {
one_deselect_action: 'nullify',
},
},
} as RelationM2A);
test('useRelationPermissionsM2A as admin', () => {
// eslint-disable-next-line vue/one-component-per-file
const TestComponent = defineComponent({
setup() {
return useRelationPermissionsM2A(relationM2A);
},
render: () => h('div'),
});
const wrapper = mount(TestComponent, {
global: {
plugins: [
createTestingPinia({
initialState: {
userStore: { currentUser },
permissionsStore: { permissions },
},
createSpy: vi.fn,
}),
],
},
});
expect(wrapper.vm.createAllowed).toEqual({ a: true, b: true });
expect(wrapper.vm.updateAllowed).toEqual({ a: true, b: true });
expect(wrapper.vm.deleteAllowed).toEqual({ a: true, b: true });
expect(wrapper.vm.selectAllowed).toBeTruthy();
});
test('useRelationPermissionsM2A with no permissions', () => {
// eslint-disable-next-line vue/one-component-per-file
const TestComponent = defineComponent({
setup() {
return useRelationPermissionsM2A(relationM2A);
},
render: () => h('div'),
});
const wrapper = mount(TestComponent, {
global: {
plugins: [
createTestingPinia({
initialState: {
userStore: { currentUser: merge({}, currentUser, { role: { admin_access: false } }) },
permissionsStore: { permissions: [] },
},
createSpy: vi.fn,
}),
],
},
});
expect(wrapper.vm.createAllowed).toEqual({ a: false, b: false });
expect(wrapper.vm.updateAllowed).toEqual({ a: false, b: false });
expect(wrapper.vm.deleteAllowed).toEqual({ a: false, b: false });
expect(wrapper.vm.selectAllowed).toBeFalsy();
});
test('useRelationPermissionsM2A with update permissions', () => {
// eslint-disable-next-line vue/one-component-per-file
const TestComponent = defineComponent({
setup() {
return useRelationPermissionsM2A(relationM2A);
},
render: () => h('div'),
});
const wrapper = mount(TestComponent, {
global: {
plugins: [
createTestingPinia({
initialState: {
userStore: { currentUser: merge({}, currentUser, { role: { admin_access: false } }) },
permissionsStore: { permissions },
},
createSpy: vi.fn,
}),
],
},
});
expect(wrapper.vm.createAllowed).toEqual({ a: false, b: false });
expect(wrapper.vm.updateAllowed).toEqual({ a: false, b: true });
expect(wrapper.vm.deleteAllowed).toEqual({ a: false, b: true });
expect(wrapper.vm.selectAllowed).toBeFalsy();
});

View File

@@ -0,0 +1,151 @@
import { usePermissionsStore } from '@/stores/permissions';
import { useUserStore } from '@/stores/user';
import { PermissionsAction } from '@directus/shared/types';
import { computed, Ref } from 'vue';
import { RelationM2A } from './use-relation-m2a';
import { RelationM2M } from './use-relation-m2m';
import { RelationM2O } from './use-relation-m2o';
import { RelationO2M } from './use-relation-o2m';
type Permissions = Record<PermissionsAction, boolean>;
export function useRelationPermissionsM2O(info: Ref<RelationM2O | undefined>) {
const relatedPerms = computed(() => getPermsForCollection(info.value?.relatedCollection.collection));
const createAllowed = computed(() => relatedPerms.value?.create);
const updateAllowed = computed(() => relatedPerms.value?.update);
return {
relatedPerms,
createAllowed,
updateAllowed,
};
}
export function useRelationPermissionsO2M(info: Ref<RelationO2M | undefined>) {
const relatedPerms = computed(() => getPermsForCollection(info.value?.relatedCollection.collection));
const createAllowed = computed(() => relatedPerms.value?.create);
const updateAllowed = computed(() => relatedPerms.value?.update);
const deleteAllowed = computed(() => {
if (info.value?.relation.meta?.one_deselect_action === 'delete') {
return relatedPerms.value?.delete;
}
return relatedPerms.value?.update;
});
return {
relatedPerms,
createAllowed,
updateAllowed,
deleteAllowed,
};
}
export function useRelationPermissionsM2M(info: Ref<RelationM2M | undefined>) {
const relatedPerms = computed(() => getPermsForCollection(info.value?.relatedCollection.collection));
const junctionPerms = computed(() => getPermsForCollection(info.value?.junctionCollection.collection));
const createAllowed = computed(() => junctionPerms.value.create && relatedPerms.value.create);
const selectAllowed = computed(() => junctionPerms.value.create);
const updateAllowed = computed(() => junctionPerms.value.update && relatedPerms.value.update);
const deleteAllowed = computed(() => {
if (info.value?.junction.meta?.one_deselect_action === 'delete') {
return junctionPerms.value.delete;
}
return junctionPerms.value.update;
});
return {
createAllowed,
selectAllowed,
updateAllowed,
deleteAllowed,
relatedPerms,
junctionPerms,
};
}
export function useRelationPermissionsM2A(info: Ref<RelationM2A | undefined>) {
const relatedPerms = computed(() => {
const perms: Record<string, Permissions> = {};
for (const collection of info.value?.allowedCollections ?? []) {
perms[collection.collection] = getPermsForCollection(collection.collection);
}
return perms;
});
const junctionPerms = computed(() => getPermsForCollection(info.value?.junctionCollection.collection));
const createAllowed = computed(() => {
return Object.fromEntries(
Object.entries(relatedPerms.value).map(([key, value]) => [key, value.create && junctionPerms.value.create])
);
});
const selectAllowed = computed(() => junctionPerms.value.create);
const updateAllowed = computed(() => {
return Object.fromEntries(
Object.entries(relatedPerms.value).map(([key, value]) => [key, value.update && junctionPerms.value.update])
);
});
const deleteAllowed = computed(() => {
if (info.value?.junction.meta?.one_deselect_action === 'delete') {
return Object.fromEntries(
Object.entries(relatedPerms.value).map(([key, value]) => [key, value.delete && junctionPerms.value.delete])
);
}
return Object.fromEntries(
Object.entries(relatedPerms.value).map(([key, value]) => [key, value.update && junctionPerms.value.update])
);
});
return {
createAllowed,
selectAllowed,
relatedPerms,
junctionPerms,
deleteAllowed,
updateAllowed,
};
}
function getPermsForCollection(collection: string | undefined) {
const userStore = useUserStore();
const isAdmin = userStore.currentUser?.role?.admin_access;
if (isAdmin) return getFullPerms(true);
const role = userStore.currentUser?.role?.id;
const permissions = getFullPerms(false);
const permissionsStore = usePermissionsStore();
if (collection === undefined) return permissions;
for (const permission of permissionsStore.permissions) {
if (permission.collection !== collection || permission.role !== role) continue;
permissions[permission.action] = true;
}
return permissions;
}
function getFullPerms(bool: boolean): Permissions {
return {
read: bool,
create: bool,
update: bool,
delete: bool,
comment: bool,
explain: bool,
share: bool,
};
}

View File

@@ -17,7 +17,7 @@ export function useRelationSingle(
const displayItem = ref<Record<string, any> | null>(null);
const loading = ref(false);
watch([value, previewQuery, relation], getDisplayItems, { immediate: true });
watch([value, previewQuery, relation], getDisplayItem, { immediate: true });
return { update, remove, refresh, displayItem, loading };
@@ -41,10 +41,10 @@ export function useRelationSingle(
}
async function refresh() {
await getDisplayItems();
await getDisplayItem();
}
async function getDisplayItems() {
async function getDisplayItem() {
const val = value.value;
if (!val) {

View File

@@ -47,7 +47,7 @@
<v-button v-tooltip="t('edit')" icon rounded @click="editImageDetails = true">
<v-icon name="open_in_new" />
</v-button>
<v-button v-tooltip="t('edit_image')" icon rounded @click="editImageEditor = true">
<v-button v-if="updateAllowed" v-tooltip="t('edit_image')" icon rounded @click="editImageEditor = true">
<v-icon name="tune" />
</v-button>
<v-button v-tooltip="t('deselect')" icon rounded @click="deselect">
@@ -61,8 +61,9 @@
</div>
<drawer-item
v-if="!disabled && image"
v-if="image"
v-model:active="editImageDetails"
:disabled="disabled || !updateAllowed"
collection="directus_files"
:primary-key="image.id"
:edits="edits"
@@ -79,13 +80,14 @@
<file-lightbox :id="image.id" v-model="lightboxActive" />
</div>
<v-upload v-else from-library from-url :folder="folder" @input="update($event.id)" />
<v-upload v-else from-library from-url :from-user="createAllowed" :folder="folder" @input="update($event.id)" />
</div>
</template>
<script setup lang="ts">
import api, { addTokenToURL } from '@/api';
import { useRelationM2O } from '@/composables/use-relation-m2o';
import { useRelationPermissionsM2O } from '@/composables/use-relation-permissions';
import { RelationQuerySingle, useRelationSingle } from '@/composables/use-relation-single';
import { formatFilesize } from '@/utils/format-filesize';
import { getAssetUrl } from '@/utils/get-asset-url';
@@ -198,6 +200,8 @@ const edits = computed(() => {
return props.value;
});
const { createAllowed, updateAllowed } = useRelationPermissionsM2O(relationInfo);
</script>
<style lang="scss" scoped>

View File

@@ -55,7 +55,7 @@
<v-divider v-if="!disabled" />
</template>
<template v-if="!disabled">
<v-list-item clickable @click="activeDialog = 'upload'">
<v-list-item v-if="createAllowed" clickable @click="activeDialog = 'upload'">
<v-list-item-icon><v-icon name="phonelink" /></v-list-item-icon>
<v-list-item-content>
{{ t(file ? 'replace_from_device' : 'upload_from_device') }}
@@ -69,7 +69,7 @@
</v-list-item-content>
</v-list-item>
<v-list-item clickable @click="activeDialog = 'url'">
<v-list-item v-if="createAllowed" clickable @click="activeDialog = 'url'">
<v-list-item-icon><v-icon name="link" /></v-list-item-icon>
<v-list-item-content>
{{ t(file ? 'replace_from_url' : 'import_from_url') }}
@@ -85,7 +85,7 @@
collection="directus_files"
:primary-key="file.id"
:edits="edits"
:disabled="disabled"
:disabled="disabled || !updateAllowed"
@input="update"
>
<template #actions>
@@ -145,18 +145,19 @@
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { ref, computed, toRefs } from 'vue';
import DrawerCollection from '@/views/private/components/drawer-collection.vue';
import api from '@/api';
import { useRelationM2O } from '@/composables/use-relation-m2o';
import { useRelationPermissionsM2O } from '@/composables/use-relation-permissions';
import { RelationQuerySingle, useRelationSingle } from '@/composables/use-relation-single';
import { addQueryToPath } from '@/utils/add-query-to-path';
import { getAssetUrl } from '@/utils/get-asset-url';
import { readableMimeType } from '@/utils/readable-mime-type';
import { unexpectedError } from '@/utils/unexpected-error';
import DrawerCollection from '@/views/private/components/drawer-collection.vue';
import DrawerItem from '@/views/private/components/drawer-item.vue';
import { addQueryToPath } from '@/utils/add-query-to-path';
import { useRelationM2O } from '@/composables/use-relation-m2o';
import { useRelationSingle, RelationQuerySingle } from '@/composables/use-relation-single';
import { Filter } from '@directus/types';
import { computed, ref, toRefs } from 'vue';
import { useI18n } from 'vue-i18n';
type FileInfo = {
id: string;
@@ -195,6 +196,7 @@ const query = ref<RelationQuerySingle>({
const { collection, field } = toRefs(props);
const { relationInfo } = useRelationM2O(collection, field);
const { displayItem: file, loading, update, remove } = useRelationSingle(value, query, relationInfo);
const { createAllowed, updateAllowed } = useRelationPermissionsM2O(relationInfo);
const { t } = useI18n();

View File

@@ -24,6 +24,7 @@
<v-list-item
:class="{ deleted: element.$type === 'deleted' }"
:dense="totalItemCount > 4"
:disabled="disabled || !updateAllowed"
block
clickable
@click="editItem(element)"
@@ -36,7 +37,7 @@
/>
<div class="spacer" />
<v-icon
v-if="!disabled"
v-if="!disabled && (deleteAllowed || isLocalItem(element))"
:name="getDeselectIcon(element)"
class="deselect"
@click.stop="deleteItem(element)"
@@ -78,7 +79,7 @@
<drawer-item
v-model:active="editModalActive"
:disabled="disabled"
:disabled="disabled || (!updateAllowed && currentlyEditing !== null)"
:collection="relationInfo.junctionCollection.collection"
:primary-key="currentlyEditing || '+'"
:related-primary-key="relatedPrimaryKey || '+'"
@@ -127,19 +128,18 @@
<script setup lang="ts">
import { useRelationM2M } from '@/composables/use-relation-m2m';
import { useRelationMultiple, RelationQueryMultiple, DisplayItem } from '@/composables/use-relation-multiple';
import { DisplayItem, RelationQueryMultiple, useRelationMultiple } from '@/composables/use-relation-multiple';
import { useRelationPermissionsM2M } from '@/composables/use-relation-permissions';
import { adjustFieldsForDisplays } from '@/utils/adjust-fields-for-displays';
import { getAssetUrl } from '@/utils/get-asset-url';
import DrawerCollection from '@/views/private/components/drawer-collection.vue';
import DrawerItem from '@/views/private/components/drawer-item.vue';
import { Filter } from '@directus/types';
import { getFieldsFromTemplate } from '@directus/utils';
import { clamp, get, isEmpty, isNil, set } from 'lodash';
import { computed, ref, toRefs } from 'vue';
import { useI18n } from 'vue-i18n';
import DrawerItem from '@/views/private/components/drawer-item.vue';
import DrawerCollection from '@/views/private/components/drawer-collection.vue';
import Draggable from 'vuedraggable';
import { getAssetUrl } from '@/utils/get-asset-url';
import { adjustFieldsForDisplays } from '@/utils/adjust-fields-for-displays';
import { get, clamp, isNil, set, isEmpty } from 'lodash';
import { usePermissionsStore } from '@/stores/permissions';
import { useUserStore } from '@/stores/user';
import { getFieldsFromTemplate } from '@directus/utils';
import { Filter } from '@directus/types';
const props = withDefaults(
defineProps<{
@@ -228,19 +228,17 @@ const {
loading,
selected,
isItemSelected,
localDelete,
isLocalItem,
getItemEdits,
} = useRelationMultiple(value, query, relationInfo, primaryKey);
const pageCount = computed(() => Math.ceil(totalItemCount.value / limit.value));
const { createAllowed, updateAllowed, selectAllowed, deleteAllowed } = useRelationPermissionsM2M(relationInfo);
const allowDrag = computed(
() => totalItemCount.value <= limit.value && relationInfo.value?.sortField !== undefined && !props.disabled
);
const pageCount = computed(() => Math.ceil(totalItemCount.value / limit.value));
function getDeselectIcon(item: DisplayItem) {
if (item.$type === 'deleted') return 'settings_backup_restore';
if (localDelete(item)) return 'delete';
if (isLocalItem(item)) return 'delete';
return 'close';
}
@@ -404,37 +402,13 @@ const customFilter = computed(() => {
return filter;
});
const userStore = useUserStore();
const permissionsStore = usePermissionsStore();
const createAllowed = computed(() => {
const admin = userStore.currentUser?.role.admin_access === true;
if (admin) return true;
const hasJunctionPermissions = !!permissionsStore.permissions.find(
(permission) =>
permission.action === 'create' && permission.collection === relationInfo.value?.junctionCollection.collection
);
const hasRelatedPermissions = !!permissionsStore.permissions.find(
(permission) =>
permission.action === 'create' && permission.collection === relationInfo.value?.relatedCollection.collection
);
return hasJunctionPermissions && hasRelatedPermissions;
});
const selectAllowed = computed(() => {
const admin = userStore.currentUser?.role.admin_access === true;
if (admin) return true;
const hasJunctionPermissions = !!permissionsStore.permissions.find(
(permission) =>
permission.action === 'create' && permission.collection === relationInfo.value?.junctionCollection.collection
);
return hasJunctionPermissions;
});
const allowDrag = computed(
() =>
totalItemCount.value <= limit.value &&
relationInfo.value?.sortField !== undefined &&
!props.disabled &&
updateAllowed.value
);
</script>
<style lang="scss" scoped>
@@ -471,6 +445,7 @@ const selectAllowed = computed(() => {
--v-icon-color: var(--foreground-subdued);
margin-right: 4px;
transition: color var(--fast) var(--transition);
cursor: pointer;
&:hover {
--v-icon-color: var(--danger);

View File

@@ -39,7 +39,7 @@
/>
<div class="spacer" />
<v-icon
v-if="!disabled"
v-if="!disabled && (deleteAllowed[element[relationInfo.collectionField.field]] || isLocalItem(element))"
class="clear-icon"
:name="getDeselectIcon(element)"
@click.stop="deleteItem(element)"
@@ -62,7 +62,7 @@
</v-list>
<div class="actions">
<v-menu v-if="enableCreate" :disabled="disabled" show-arrow>
<v-menu v-if="enableCreate && createCollections.length > 0" :disabled="disabled" show-arrow>
<template #activator="{ toggle }">
<v-button :disabled="disabled" @click="toggle">
{{ t('create_new') }}
@@ -85,7 +85,7 @@
</v-list>
</v-menu>
<v-menu v-if="enableSelect" :disabled="disabled" show-arrow>
<v-menu v-if="enableSelect && selectAllowed" :disabled="disabled" show-arrow>
<template #activator="{ toggle }">
<v-button class="existing" :disabled="disabled" @click="toggle">
{{ t('add_existing') }}
@@ -123,7 +123,9 @@
<drawer-item
v-model:active="editModalActive"
:disabled="disabled"
:disabled="
disabled || (editingCollection !== null && !updateAllowed[editingCollection] && currentlyEditing !== null)
"
:collection="relationInfo.junctionCollection.collection"
:primary-key="currentlyEditing || '+'"
:related-primary-key="relatedPrimaryKey || '+'"
@@ -138,6 +140,7 @@
<script setup lang="ts">
import { useRelationM2A } from '@/composables/use-relation-m2a';
import { DisplayItem, RelationQueryMultiple, useRelationMultiple } from '@/composables/use-relation-multiple';
import { useRelationPermissionsM2A } from '@/composables/use-relation-permissions';
import { addRelatedPrimaryKeyToFields } from '@/utils/add-related-primary-key-to-fields';
import { adjustFieldsForDisplays } from '@/utils/adjust-fields-for-displays';
import { hideDragImage } from '@/utils/hide-drag-image';
@@ -239,19 +242,15 @@ const {
loading,
selected,
isItemSelected,
localDelete,
isLocalItem,
getItemEdits,
} = useRelationMultiple(value, query, relationInfo, primaryKey);
const pageCount = computed(() => Math.ceil(totalItemCount.value / limit.value));
const allowDrag = computed(
() => totalItemCount.value <= limit.value && relationInfo.value?.sortField !== undefined && !props.disabled
);
function getDeselectIcon(item: DisplayItem) {
if (item.$type === 'deleted') return 'settings_backup_restore';
if (localDelete(item)) return 'delete';
if (isLocalItem(item)) return 'delete';
return 'close';
}
@@ -295,6 +294,7 @@ function sortItems(items: DisplayItem[]) {
const editModalActive = ref(false);
const currentlyEditing = ref<string | number | null>(null);
const relatedPrimaryKey = ref<string | number | null>(null);
const editingCollection = ref<string | null>(null);
const selectingFrom = ref<string | null>(null);
const editsAtStart = ref<Record<string, any>>({});
let newItem = false;
@@ -331,10 +331,12 @@ function editItem(item: DisplayItem) {
};
editModalActive.value = true;
editingCollection.value = item[relationInfo.value.collectionField.field];
if (item?.$type === 'created' && !isItemSelected(item)) {
currentlyEditing.value = null;
relatedPrimaryKey.value = null;
editingCollection.value = null;
} else {
if (!relationPkField) return;
currentlyEditing.value = get(item, [junctionPkField], null);
@@ -434,6 +436,25 @@ const customFilter = computed(() => {
return filter;
});
const { createAllowed, deleteAllowed, selectAllowed, updateAllowed } = useRelationPermissionsM2A(relationInfo);
const createCollections = computed(() => {
const info = relationInfo.value;
if (!info) return [];
return info.allowedCollections.filter((collection) => {
return createAllowed.value[collection.collection];
});
});
const allowDrag = computed(
() =>
totalItemCount.value <= limit.value &&
relationInfo.value?.sortField !== undefined &&
!props.disabled &&
updateAllowed.value
);
</script>
<style lang="scss" scoped>

View File

@@ -52,6 +52,7 @@
:loading="loading"
:items="displayItems"
:row-height="tableRowHeight"
:disabled="!updateAllowed"
:show-manual-sort="allowDrag"
:manual-sort-key="relationInfo?.sortField"
show-resize
@@ -79,7 +80,7 @@
</router-link>
<v-icon
v-if="!disabled && selectAllowed"
v-if="!disabled && (deleteAllowed || isLocalItem(item))"
v-tooltip="t(getDeselectTooltip(item))"
class="deselect"
:class="{ deleted: item.$type === 'deleted' }"
@@ -114,6 +115,7 @@
<v-list-item
block
clickable
:disabled="disabled"
:dense="totalItemCount > 4"
:class="{ deleted: element.$type === 'deleted' }"
@click="editItem(element)"
@@ -137,7 +139,7 @@
<v-icon name="launch" />
</router-link>
<v-icon
v-if="!disabled && selectAllowed"
v-if="!disabled && (deleteAllowed || isLocalItem(element))"
v-tooltip="t(getDeselectTooltip(element))"
class="deselect"
:name="getDeselectIcon(element)"
@@ -176,7 +178,7 @@
<drawer-item
v-model:active="editModalActive"
:disabled="disabled"
:disabled="disabled || (!updateAllowed && currentlyEditing !== null)"
:collection="relationInfo.junctionCollection.collection"
:primary-key="currentlyEditing || '+'"
:related-primary-key="relatedPrimaryKey || '+'"
@@ -214,12 +216,11 @@ import { Sort } from '@/components/v-table/types';
import Draggable from 'vuedraggable';
import { adjustFieldsForDisplays } from '@/utils/adjust-fields-for-displays';
import { isEmpty, get, clamp, isNil, set } from 'lodash';
import { usePermissionsStore } from '@/stores/permissions';
import { useUserStore } from '@/stores/user';
import { useFieldsStore } from '@/stores/fields';
import { LAYOUTS } from '@/types/interfaces';
import { formatCollectionItemsCount } from '@/utils/format-collection-items-count';
import { addRelatedPrimaryKeyToFields } from '@/utils/add-related-primary-key-to-fields';
import { useRelationPermissionsM2M } from '@/composables/use-relation-permissions';
const props = withDefaults(
defineProps<{
@@ -362,7 +363,7 @@ const {
loading,
selected,
isItemSelected,
localDelete,
isLocalItem,
getItemEdits,
} = useRelationMultiple(value, query, relationInfo, primaryKey);
@@ -436,13 +437,13 @@ const allowDrag = computed(
function getDeselectIcon(item: DisplayItem) {
if (item.$type === 'deleted') return 'settings_backup_restore';
if (localDelete(item)) return 'delete';
if (isLocalItem(item)) return 'delete';
return 'close';
}
function getDeselectTooltip(item: DisplayItem) {
if (item.$type === 'deleted') return 'undo_removed_item';
if (localDelete(item)) return 'delete_item';
if (isLocalItem(item)) return 'delete_item';
return 'remove_item';
}
@@ -605,37 +606,7 @@ function getLinkForItem(item: DisplayItem) {
return null;
}
const userStore = useUserStore();
const permissionsStore = usePermissionsStore();
const createAllowed = computed(() => {
const admin = userStore.currentUser?.role.admin_access === true;
if (admin) return true;
const hasJunctionPermissions = !!permissionsStore.permissions.find(
(permission) =>
permission.action === 'create' && permission.collection === relationInfo.value?.junctionCollection.collection
);
const hasRelatedPermissions = !!permissionsStore.permissions.find(
(permission) =>
permission.action === 'create' && permission.collection === relationInfo.value?.relatedCollection.collection
);
return hasJunctionPermissions && hasRelatedPermissions;
});
const selectAllowed = computed(() => {
const admin = userStore.currentUser?.role.admin_access === true;
if (admin) return true;
const hasJunctionPermissions = !!permissionsStore.permissions.find(
(permission) =>
permission.action === 'create' && permission.collection === relationInfo.value?.junctionCollection.collection
);
return hasJunctionPermissions;
});
const { createAllowed, updateAllowed, deleteAllowed, selectAllowed } = useRelationPermissionsM2M(relationInfo);
</script>
<style lang="scss">
@@ -763,6 +734,7 @@ const selectAllowed = computed(() => {
--v-icon-color: var(--foreground-subdued);
transition: color var(--fast) var(--transition);
margin: 0 4px;
cursor: pointer;
&:hover {
--v-icon-color: var(--danger);

View File

@@ -178,7 +178,7 @@ const query = computed<RelationQueryMultiple>(() => ({
page: page.value,
}));
const { displayItems, create, update, remove, select, cleanItem, localDelete, getItemEdits } = useRelationMultiple(
const { displayItems, create, update, remove, select, cleanItem, isLocalItem, getItemEdits } = useRelationMultiple(
value,
query,
relationInfo,
@@ -187,7 +187,7 @@ const { displayItems, create, update, remove, select, cleanItem, localDelete, ge
function getDeselectIcon(item: DisplayItem) {
if (item.$type === 'deleted') return 'settings_backup_restore';
if (localDelete(item)) return 'delete';
if (isLocalItem(item)) return 'delete';
return 'close';
}

View File

@@ -34,7 +34,7 @@
</v-button>
<v-button
v-if="!disabled && enableCreate && createAllowed && updateAllowed"
v-if="!disabled && enableCreate && createAllowed"
v-tooltip.bottom="createAllowed ? t('create_item') : t('not_allowed')"
rounded
icon
@@ -79,7 +79,7 @@
</router-link>
<v-icon
v-if="!disabled && updateAllowed"
v-if="!disabled && (deleteAllowed || isLocalItem(item))"
v-tooltip="t(getDeselectTooltip(item))"
class="deselect"
:name="getDeselectIcon(item)"
@@ -113,6 +113,7 @@
<v-list-item
block
clickable
:disabled="disabled"
:dense="totalItemCount > 4"
:class="{ deleted: element.$type === 'deleted' }"
@click="editItem(element)"
@@ -136,7 +137,7 @@
<v-icon name="launch" />
</router-link>
<v-icon
v-if="!disabled && updateAllowed"
v-if="!disabled && (deleteAllowed || isLocalItem(element))"
v-tooltip="t(getDeselectTooltip(element))"
class="deselect"
:name="getDeselectIcon(element)"
@@ -161,7 +162,7 @@
</template>
</template>
<template v-else>
<v-button v-if="enableCreate && createAllowed && updateAllowed" :disabled="disabled" @click="createItem">
<v-button v-if="enableCreate && createAllowed" :disabled="disabled" @click="createItem">
{{ t('create_new') }}
</v-button>
<v-button v-if="enableSelect && updateAllowed" :disabled="disabled" @click="selectModalActive = true">
@@ -174,7 +175,7 @@
</div>
<drawer-item
:disabled="disabled"
:disabled="disabled || (!updateAllowed && currentlyEditing !== '+')"
:active="currentlyEditing !== null"
:collection="relationInfo.relatedCollection.collection"
:primary-key="currentlyEditing || '+'"
@@ -211,12 +212,11 @@ import { Sort } from '@/components/v-table/types';
import Draggable from 'vuedraggable';
import { adjustFieldsForDisplays } from '@/utils/adjust-fields-for-displays';
import { isEmpty, clamp, get, isNil } from 'lodash';
import { usePermissionsStore } from '@/stores/permissions';
import { useUserStore } from '@/stores/user';
import { useFieldsStore } from '@/stores/fields';
import { LAYOUTS } from '@/types/interfaces';
import { formatCollectionItemsCount } from '@/utils/format-collection-items-count';
import { addRelatedPrimaryKeyToFields } from '@/utils/add-related-primary-key-to-fields';
import { useRelationPermissionsO2M } from '@/composables/use-relation-permissions';
const props = withDefaults(
defineProps<{
@@ -337,10 +337,12 @@ const {
loading,
selected,
isItemSelected,
localDelete,
isLocalItem,
getItemEdits,
} = useRelationMultiple(value, query, relationInfo, primaryKey);
const { createAllowed, deleteAllowed, updateAllowed } = useRelationPermissionsO2M(relationInfo);
const pageCount = computed(() => Math.ceil(totalItemCount.value / limit.value));
const showingCount = computed(() => {
@@ -411,13 +413,13 @@ const allowDrag = computed(
function getDeselectIcon(item: DisplayItem) {
if (item.$type === 'deleted') return 'settings_backup_restore';
if (localDelete(item)) return 'delete';
if (isLocalItem(item)) return 'delete';
return 'close';
}
function getDeselectTooltip(item: DisplayItem) {
if (item.$type === 'deleted') return 'undo_removed_item';
if (localDelete(item)) return 'delete_item';
if (isLocalItem(item)) return 'delete_item';
return 'remove_item';
}
@@ -565,29 +567,6 @@ function getLinkForItem(item: DisplayItem) {
return null;
}
const userStore = useUserStore();
const permissionsStore = usePermissionsStore();
const createAllowed = computed(() => {
const admin = userStore.currentUser?.role.admin_access === true;
if (admin) return true;
return !!permissionsStore.permissions.find(
(permission) =>
permission.action === 'create' && permission.collection === relationInfo.value?.relatedCollection.collection
);
});
const updateAllowed = computed(() => {
const admin = userStore.currentUser?.role.admin_access === true;
if (admin) return true;
return !!permissionsStore.permissions.find(
(permission) =>
permission.action === 'update' && permission.collection === relationInfo.value?.relatedCollection.collection
);
});
</script>
<style lang="scss">

View File

@@ -78,7 +78,6 @@
<script setup lang="ts">
import { RelationQuerySingle, useRelationSingle } from '@/composables/use-relation-single';
import { useRelationM2O } from '@/composables/use-relation-m2o';
import { usePermissionsStore } from '@/stores/permissions';
import { useCollectionsStore } from '@/stores/collections';
import { adjustFieldsForDisplays } from '@/utils/adjust-fields-for-displays';
import { parseFilter } from '@/utils/parse-filter';
@@ -90,6 +89,7 @@ import { get } from 'lodash';
import { render } from 'micromustache';
import { computed, inject, ref, toRefs } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRelationPermissionsM2O } from '@/composables/use-relation-permissions';
const props = withDefaults(
defineProps<{
@@ -171,6 +171,7 @@ const query = computed<RelationQuerySingle>(() => ({
}));
const { update, remove, displayItem, loading } = useRelationSingle(value, query, relationInfo);
const { createAllowed, updateAllowed } = useRelationPermissionsM2O(relationInfo);
const currentPrimaryKey = computed<string | number>(() => {
if (!displayItem.value || !props.value || !relationInfo.value) return '+';
@@ -228,18 +229,6 @@ function onSelection(selection: (number | string)[]) {
selectModalActive.value = false;
}
const { hasPermission } = usePermissionsStore();
const createAllowed = computed(() => {
if (!relationInfo.value) return false;
return hasPermission(relationInfo.value.relatedCollection.collection, 'create');
});
const updateAllowed = computed(() => {
if (!relationInfo.value) return false;
return hasPermission(relationInfo.value.relatedCollection.collection, 'update');
});
</script>
<style lang="scss" scoped>

View File

@@ -19,9 +19,9 @@
? firstItemInitial?.[relationInfo?.junctionPrimaryKeyField.field]
: null
"
:disabled="disabled"
:disabled="disabled || !firstChangesAllowed"
:loading="loading"
:fields="fields"
:fields="firstFields"
:model-value="firstItem"
:initial-values="firstItemInitial"
:badge="languageOptions.find((lang) => lang.value === firstLang)?.text"
@@ -50,10 +50,10 @@
? secondItemInitial?.[relationInfo?.junctionPrimaryKeyField.field]
: null
"
:disabled="disabled"
:disabled="disabled || !secondChangesAllowed"
:loading="loading"
:initial-values="secondItemInitial"
:fields="fields"
:fields="secondFields"
:badge="languageOptions.find((lang) => lang.value === secondLang)?.text"
:direction="languageOptions.find((lang) => lang.value === secondLang)?.direction"
:model-value="secondItem"
@@ -72,12 +72,14 @@ import VForm from '@/components/v-form/v-form.vue';
import VIcon from '@/components/v-icon/v-icon.vue';
import { useRelationM2M } from '@/composables/use-relation-m2m';
import { DisplayItem, RelationQueryMultiple, useRelationMultiple } from '@/composables/use-relation-multiple';
import { useRelationPermissionsM2M } from '@/composables/use-relation-permissions';
import { useWindowSize } from '@/composables/use-window-size';
import vTooltip from '@/directives/tooltip';
import { useFieldsStore } from '@/stores/fields';
import { usePermissionsStore } from '@/stores/permissions';
import { unexpectedError } from '@/utils/unexpected-error';
import { toArray } from '@directus/utils';
import { isNil } from 'lodash';
import { cloneDeep, isNil } from 'lodash';
import { computed, ref, toRefs, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import LanguageSelect from './language-select.vue';
@@ -120,6 +122,7 @@ const { relationInfo } = useRelationM2M(collection, field);
const { t, locale } = useI18n();
const fieldsStore = useFieldsStore();
const permissionsStore = usePermissionsStore();
const { width } = useWindowSize();
@@ -314,6 +317,89 @@ function useLanguages() {
}
}
}
const { junctionPerms } = useRelationPermissionsM2M(relationInfo);
const createAllowed = computed(() => junctionPerms.value.create);
const updateAllowed = computed(() => junctionPerms.value.update);
const firstItemNew = computed(
() => relationInfo.value && firstItemInitial.value?.[relationInfo.value.junctionPrimaryKeyField.field] === undefined
);
const secondItemNew = computed(
() => relationInfo.value && secondItemInitial.value?.[relationInfo.value.junctionPrimaryKeyField.field] === undefined
);
const firstChangesAllowed = computed(() => {
if (firstItemNew.value) {
return updateAllowed.value;
}
return createAllowed.value;
});
const secondChangesAllowed = computed(() => {
if (secondItemNew.value) {
return updateAllowed.value;
}
return createAllowed.value;
});
const firstFields = computed(() => {
let fieldsWithPerms = cloneDeep(fields.value);
if (!relationInfo.value) return fieldsWithPerms;
const permissions = permissionsStore.getPermissionsForUser(
relationInfo.value.junctionCollection.collection,
firstItemNew.value ? 'create' : 'update'
);
if (!permissions) return fieldsWithPerms;
if (permissions.fields?.includes('*') === false) {
fieldsWithPerms = fieldsWithPerms.map((field) => {
if (permissions.fields?.includes(field.field) === false) {
field.meta = {
...(field.meta || {}),
readonly: true,
} as any;
}
return field;
});
}
return fieldsWithPerms;
});
const secondFields = computed(() => {
let fieldsWithPerms = cloneDeep(fields.value);
if (!relationInfo.value) return fieldsWithPerms;
const permissions = permissionsStore.getPermissionsForUser(
relationInfo.value.junctionCollection.collection,
secondItemNew.value ? 'create' : 'update'
);
if (!permissions) return fieldsWithPerms;
if (permissions.fields?.includes('*') === false) {
fieldsWithPerms = fieldsWithPerms.map((field) => {
if (permissions.fields?.includes(field.field) === false) {
field.meta = {
...(field.meta || {}),
readonly: true,
} as any;
}
return field;
});
}
return fieldsWithPerms;
});
</script>
<style lang="scss" scoped>