Permissions (#451)

* Start on permissions setup widget

* Calculate toggle states

* Render out statuses details

* Render all sub-toggles

* Rough in grid styling

* Tweak type / structure of permissions array

* Add inline saving for status checkboxes

* Fix popper reference

* Add sub-indicators + add active to list item

* Finish status toggles

* Add saving on column level

* Add border to system section

* Add field blacklist modal

* Make field blacklist work on collection level

* Add indeterminate state to field checkboxes

* Add hover / active state to activator

* Save all statuses to create on collection level too

* Visually align icons

* Account for status mapping without value key

* Style loader to match height of permissions

* Add status modal

* Allow status modal for create permission

* Add collections header

* Increase font-weight of permissions header
This commit is contained in:
Rijk van Zanten
2020-04-22 11:46:45 -04:00
committed by GitHub
parent d2a63d9a13
commit 4b2a43aa9d
19 changed files with 1316 additions and 35 deletions

View File

@@ -6,7 +6,7 @@
role="checkbox"
:aria-pressed="isChecked ? 'true' : 'false'"
:disabled="disabled"
:class="{ checked: isChecked }"
:class="{ checked: isChecked, indeterminate }"
>
<div class="prepend" v-if="$scopedSlots.prepend"><slot name="prepend" /></div>
<v-icon class="checkbox" :name="icon" />
@@ -57,14 +57,14 @@ export default defineComponent({
});
const icon = computed<string>(() => {
if (props.indeterminate) return 'indeterminate_check_box';
if (props.indeterminate === true) return 'indeterminate_check_box';
return isChecked.value ? 'check_box' : 'check_box_outline_blank';
});
return { isChecked, toggleInput, icon };
function toggleInput(): void {
if (props.indeterminate) {
if (props.indeterminate === true) {
emit('update:indeterminate', false);
}
@@ -129,7 +129,7 @@ export default defineComponent({
}
}
&:not(:disabled).checked {
&:not(:disabled):not(.indeterminate).checked {
.checkbox {
--v-icon-color: var(--v-checkbox-color);
}

View File

@@ -31,7 +31,7 @@
<slot />
</main>
</div>
<footer class="footer" v-if="$slots.footer">
<footer class="footer" v-if="$slots.footer || $scopedSlots.footer">
<slot name="footer" v-bind="{ close: () => $emit('toggle', false) }" />
</footer>
</article>

View File

@@ -23,6 +23,10 @@ export function useCollection(collection: Ref<string>) {
);
});
const ownerField = computed(() => {
return fields.value?.find((field) => field.type === 'owner');
});
const statusField = computed(() => {
return fields.value?.find((field) => field.type === 'status');
});
@@ -50,5 +54,5 @@ export function useCollection(collection: Ref<string>) {
);
});
return { info, fields, primaryKeyField, statusField, softDeleteStatus };
return { info, fields, primaryKeyField, ownerField, statusField, softDeleteStatus };
}

View File

@@ -251,6 +251,27 @@
"add_new_folder": "Add New Folder",
"folder_name": "Folder Name...",
"saves_automatically": "Saves Automatically",
"show_system_collections": "Show System Collections",
"hide_system_collections": "Hide System Collections",
"always": "Always",
"create": "Create",
"full": "All",
"mine": "Mine Only",
"on_create": "On Creation",
"on_update": "On Update",
"read": "Read",
"role": "Role Only",
"update": "Update",
"select_fields": "Select Fields",
"readable_fields": "Readable Fields",
"writable_fields": "Writable Fields",
"select_statuses": "Select Statuses",
"about_directus": "About Directus",
"activity_log": "Activity Log",
"add_field_filter": "Add a field filter",
@@ -353,7 +374,6 @@
"contains": "Contains",
"continue": "Continue",
"continue_as": "<b>{name}</b> is already authenticated for this project. If you recognize this account, please press continue.",
"create": "Create",
"create_collection": "Create Collection",
"create_field": "Create Field",
"create_role": "Create Role",
@@ -594,18 +614,6 @@
"otp": "One-Time Password",
"password": "Password",
"password_reset_sending": "Sending email...",
"permission_states": {
"always": "Always",
"create": "Create",
"full": "All",
"mine": "Mine Only",
"none": "None",
"on_create": "On Creation",
"on_update": "On Update",
"read": "Readonly",
"role": "Role Only",
"update": "Update"
},
"permissions": "Permissions",
"permissions_admin": "Admins have access to all managed data within the system by default.",
"permissions_no_collections": "This project does not have any collections yet.",
@@ -617,8 +625,6 @@
"project_key": "Project Key",
"project_name": "Project Name",
"project_not_configured": "Project Not Configured",
"read": "Read",
"readable_fields": "Readable Fields",
"readable_fields_copy": "Select the fields that the user can view",
"regex": "RegEx",
"related_collection": "Related Collection",
@@ -647,11 +653,9 @@
"search_interface": "Search for an interface...",
"select_existing": "Select Existing",
"select_field": "Select a Field",
"select_fields": "Select Fields",
"select_from_device": "Select from device",
"select_interface": "Select an interface",
"select_interface_below": "Select an interface below",
"select_statuses": "Select Statuses",
"select_statuses_copy": "Select the statuses the user can use",
"server_details": "Server Details",
"server_error": "Server Error",
@@ -692,7 +696,6 @@
"turn_all_off": "Turn all off",
"unsaved_changes": "Unsaved Changes",
"unsaved_changes_copy": "Are you sure you want to leave this page?",
"update": "Update",
"update_confirm": "Are you sure you want to update {count} items?",
"update_field": "Update Field",
"upload_exceeds_max_size": "{filename} can't be uploaded. Your server is not configured to handle uploads of this size.",
@@ -718,7 +721,6 @@
"why": "Why?",
"wrapping_up": "Wrapping Up",
"wrong_super_admin_password": "The super admin password you provided is incorrect.",
"writable_fields": "Writable Fields",
"writable_fields_copy": "Select the fields that the user can edit",
"yes": "Yes"
}

View File

@@ -57,7 +57,7 @@
<div class="fields">
<h2 class="title type-label">
{{ $t('fields_and_layout') }}
<span class="instant-save">{{ $t('fields_are_saved_instantly') }}</span>
<span class="instant-save">{{ $t('saves_automatically') }}</span>
</h2>
<fields-management :collection="collection" />
</div>

View File

@@ -0,0 +1,4 @@
import PermissionsFields from './permissions-fields.vue';
export { PermissionsFields };
export default PermissionsFields;

View File

@@ -0,0 +1,220 @@
<template>
<v-modal v-model="modalActive" :title="$t('select_fields')" persistent>
<template #activator="{ on }">
<span class="activator" @click="on" :class="{ limited: allAllowed === false }">
{{ allAllowed ? $t('all') : $t('limited') }}
</span>
</template>
<div class="fields">
<div class="read">
<p class="type-label">{{ $t('readable_fields') }}</p>
<v-checkbox
v-model="readableFields"
v-for="field in fields"
:value="field.field"
:key="field.field"
:indeterminate="readIndeterminate.includes(field.field)"
@update:indeterminate="
readIndeterminate = readIndeterminate.filter((f) => f !== field.field)
"
:label="field.name"
/>
</div>
<div class="write">
<p class="type-label">{{ $t('writable_fields') }}</p>
<v-checkbox
v-model="writableFields"
v-for="field in fields"
:value="field.field"
:key="field.field"
:label="field.name"
/>
</div>
</div>
<template #footer="{ close }">
<v-button secondary @click="close" :disabled="saving">{{ $t('cancel') }}</v-button>
<v-button @click="save" :loading="saving">{{ $t('save') }}</v-button>
</template>
</v-modal>
</template>
<script lang="ts">
import { defineComponent, ref, toRefs, computed, watch, PropType } from '@vue/composition-api';
import useCollection from '@/compositions/use-collection';
import { Permission } from '../../compositions/use-permissions';
import { intersection } from 'lodash';
export default defineComponent({
props: {
permissionId: {
type: Number,
default: undefined,
},
collection: {
type: String,
required: true,
},
role: {
type: Number,
required: true,
},
status: {
type: String,
default: null,
},
readBlacklist: {
type: Array as PropType<string[] | string[][]>,
required: true,
},
writeBlacklist: {
type: Array as PropType<string[] | string[][]>,
required: true,
},
savePermission: {
type: Function,
required: true,
},
combined: {
type: Boolean,
default: false,
},
},
setup(props) {
const { collection } = toRefs(props);
const { fields } = useCollection(collection);
const fieldKeys = computed(() => fields.value.map((field) => field.field));
const modalActive = ref(false);
const readableFields = ref<string[]>([]);
const writableFields = ref<string[]>([]);
const readIndeterminate = ref<string[]>([]);
const writeIndeterminate = ref<string[]>([]);
const allAllowed = computed(() => {
let blacklist = [...props.readBlacklist, ...props.writeBlacklist];
if (props.combined === true) {
blacklist = blacklist.flat();
}
return blacklist.length === 0;
});
watch(modalActive, (newVal) => {
if (newVal !== true) return;
if (props.combined === true) {
readableFields.value = invertBlacklist(
intersection(...(props.readBlacklist as string[][]))
);
readIndeterminate.value = [...new Set(props.readBlacklist.flat())].filter((k) =>
readableFields.value.includes(k)
);
} else {
readableFields.value = invertBlacklist(props.readBlacklist as string[]);
}
if (props.combined === true) {
writableFields.value = invertBlacklist(
intersection(...(props.writeBlacklist as string[][]))
);
writeIndeterminate.value = [...new Set(props.writeBlacklist.flat())].filter((k) =>
writableFields.value.includes(k)
);
} else {
writableFields.value = invertBlacklist(props.writeBlacklist as string[]);
}
});
const saving = ref(false);
return {
save,
fields,
modalActive,
readableFields,
writableFields,
saving,
allAllowed,
readIndeterminate,
writeIndeterminate,
};
async function save() {
saving.value = true;
const values: Partial<Permission> = {
collection: props.collection,
status: props.status,
role: props.role,
read_field_blacklist: fieldKeys.value.filter(
(key) => readableFields.value.includes(key) === false
),
write_field_blacklist: fieldKeys.value.filter(
(key) => writableFields.value.includes(key) === false
),
};
if (props.permissionId) {
values.id = props.permissionId;
}
await props.savePermission(values);
modalActive.value = false;
saving.value = false;
}
function invertBlacklist(blacklist: string[]) {
return fieldKeys.value.filter((key) => blacklist.includes(key) === false);
}
},
});
</script>
<style lang="scss" scoped>
.read,
.write {
display: grid;
grid-gap: 0 32px;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
.type-label {
grid-column: 1 / -1;
margin-bottom: 16px;
}
}
.read {
margin-bottom: 32px;
}
.limited {
color: var(--warning);
}
.activator {
position: relative;
width: max-content;
margin: -4px -8px;
margin-left: 32px;
padding: 4px 8px;
border-radius: var(--border-radius);
cursor: pointer;
&:hover {
background-color: var(--background-normal);
}
&:active {
background-color: var(--background-normal-alt);
}
}
</style>

View File

@@ -0,0 +1,4 @@
import PermissionsHeader from './permissions-header.vue';
export { PermissionsHeader };
export default PermissionsHeader;

View File

@@ -0,0 +1,40 @@
<template>
<div class="permissions-header">
<div class="name">{{ $tc('collection', 2) }}</div>
<v-icon name="add_circle" v-tooltip="$t('create')" />
<v-icon name="visibility" v-tooltip="$t('read')" />
<v-icon name="edit" v-tooltip="$t('update')" />
<v-icon name="delete" v-tooltip="$t('delete')" />
<v-icon name="comment" v-tooltip="$t('comment')" />
<div class="name fields">{{ $tc('field', 2) }}</div>
<div class="name">{{ $t('statuses') }}</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from '@vue/composition-api';
export default defineComponent({});
</script>
<style lang="scss" scoped>
.permissions-header {
display: grid;
grid-gap: var(--grid-gap);
grid-template-columns: var(--grid-template-columns);
align-items: center;
padding: 8px 12px;
.v-icon {
--v-icon-color: var(--foreground-subdued);
}
.fields {
margin-left: 40px;
}
.name {
font-weight: 600;
}
}
</style>

View File

@@ -0,0 +1,4 @@
import PermissionsManagement from './permissions-management.vue';
export { PermissionsManagement };
export default PermissionsManagement;

View File

@@ -0,0 +1,134 @@
<template>
<div class="permissions-management">
<div
class="loading"
v-if="loading && permissions === null"
:style="{
'--rows': collectionKeys.normal.length,
}"
>
<v-progress-circular indeterminate />
</div>
<template v-else>
<permissions-header />
<permissions-row
v-for="key in collectionKeys.normal"
:key="key"
:collection="key"
:role="role"
:saved-permissions="getPermissionsForCollection(key)"
:save-permission="savePermission"
:save-all="saveAll"
/>
<div class="system" v-if="systemActive">
<permissions-row
v-for="key in collectionKeys.system"
system
:key="key"
:collection="key"
:role="role"
:saved-permissions="getPermissionsForCollection(key)"
:save-permission="savePermission"
:save-all="saveAll"
/>
</div>
<button @click="systemActive = !systemActive" class="system-toggle">
{{ systemActive ? $t('hide_system_collections') : $t('show_system_collections') }}
</button>
</template>
</div>
</template>
<script lang="ts">
import { defineComponent, computed, ref, toRefs } from '@vue/composition-api';
import useCollectionsStore from '@/stores/collections';
import { orderBy } from 'lodash';
import PermissionsRow from '../permissions-row';
import usePermissions from '../../compositions/use-permissions';
import PermissionsHeader from '../permissions-header';
export default defineComponent({
components: { PermissionsRow, PermissionsHeader },
props: {
role: {
type: Number,
required: true,
},
},
setup(props) {
const collectionsStore = useCollectionsStore();
const { role } = toRefs(props);
const collectionKeys = computed(() => {
const keys = orderBy(
collectionsStore.state.collections.map((collection) => collection.collection),
['collection'],
['asc']
);
return {
normal: keys.filter((key) => key.startsWith('directus_') === false),
system: keys.filter((key) => key.startsWith('directus_') === true),
};
});
const systemActive = ref(false);
const { loading, error, permissions, savePermission, saveAll } = usePermissions(role);
return {
collectionKeys,
systemActive,
loading,
error,
permissions,
getPermissionsForCollection,
savePermission,
saveAll,
};
function getPermissionsForCollection(key: string) {
return permissions.value?.filter((permission) => permission.collection === key);
}
},
});
</script>
<style lang="scss" scoped>
.permissions-management {
--grid-template-columns: 2fr repeat(5, 24px) repeat(2, 1fr) 24px;
--grid-gap: 0 8px;
max-width: 800px; // same as fields setup
border: 2px solid var(--border-normal);
border-radius: var(--border-radius);
}
.loading {
display: flex;
align-items: center;
justify-content: center;
height: calc((var(--rows) * (40px + 2px)) + 38px);
}
.system {
border-top: 2px solid var(--border-subdued);
}
.system-toggle {
display: block;
width: 100%;
padding: 8px 0;
color: var(--foreground-subdued);
background-color: var(--background-subdued);
border-bottom-right-radius: var(--border-radius);
border-bottom-left-radius: var(--border-radius);
transition: color var(--fast) var(--transition);
&:hover {
color: var(--foreground-normal);
}
}
</style>

View File

@@ -0,0 +1,4 @@
import PermissionsRow from './permissions-row.vue';
export { PermissionsRow };
export default PermissionsRow;

View File

@@ -0,0 +1,389 @@
<template>
<div class="permissions-row">
<div class="row">
<div class="name">{{ info.name }}</div>
<permissions-toggle
type="create"
:options="['none', 'full']"
:value="getCombinedPermission('create')"
:save-permission="saveForAllStatuses"
:collection="collection"
:role="role"
/>
<permissions-toggle
type="read"
:options="ownerField ? ['none', 'mine', 'role', 'full'] : ['none', 'full']"
:value="getCombinedPermission('read')"
:save-permission="saveForAllStatuses"
:collection="collection"
:role="role"
/>
<permissions-toggle
type="update"
:options="ownerField ? ['none', 'mine', 'role', 'full'] : ['none', 'full']"
:value="getCombinedPermission('update')"
:save-permission="saveForAllStatuses"
:collection="collection"
:role="role"
/>
<permissions-toggle
type="delete"
:options="ownerField ? ['none', 'mine', 'role', 'full'] : ['none', 'full']"
:value="getCombinedPermission('delete')"
:save-permission="saveForAllStatuses"
:collection="collection"
:role="role"
/>
<permissions-toggle
type="comment"
:options="['none', 'read', 'create', 'update', 'full']"
:value="getCombinedPermission('comment')"
:save-permission="saveForAllStatuses"
:collection="collection"
:role="role"
/>
<permissions-fields
:collection="collection"
:role="role"
:save-permission="saveForAllStatuses"
:read-blacklist="getCombinedPermission('read_field_blacklist')"
:write-blacklist="getCombinedPermission('write_field_blacklist')"
combined
/>
<permissions-statuses
v-if="statuses"
:collection="collection"
:role="role"
:save-permission="saveForAllStatuses"
:status-blacklist="getCombinedPermission('status_blacklist')"
:statuses="statuses"
combined
/>
<div class="spacer" v-else>--</div>
<v-icon
@click="detailsOpen = !detailsOpen"
:name="detailsOpen ? 'expand_less' : 'expand_more'"
/>
</div>
<div class="details" v-if="detailsOpen">
<div class="row">
<div class="name">
<v-icon class="sub-indicator" name="subdirectory_arrow_right" />
{{ $t('on_create') }}
</div>
<v-icon v-for="n in 5" :key="n" class="spacer" name="block" />
<permissions-fields
:collection="collection"
:role="role"
:save-permission="savePermission"
status="$create"
:permission-id="getPermissionValue('id', '$create')"
:read-blacklist="getPermissionValue('read_field_blacklist', '$create')"
:write-blacklist="getPermissionValue('write_field_blacklist', '$create')"
/>
<permissions-statuses
v-if="statuses"
:collection="collection"
:role="role"
:save-permission="savePermission"
status="$create"
:statuses="statuses"
:permission-id="getPermissionValue('id', '$create')"
:status-blacklist="getPermissionValue('status_blacklist', '$create')"
/>
<div class="spacer" v-else>--</div>
</div>
<template v-if="statuses">
<div class="row" v-for="status in statuses" :key="status.value">
<div class="name">
<v-icon class="sub-indicator" name="subdirectory_arrow_right" />
{{ status.name }}
</div>
<permissions-toggle
type="create"
:options="['none', 'full']"
:value="getPermissionValue('create', status.value)"
:status="status.value"
:save-permission="savePermission"
:permission-id="getPermissionValue('id', status.value)"
:collection="collection"
:role="role"
/>
<permissions-toggle
type="read"
:options="ownerField ? ['none', 'mine', 'role', 'full'] : ['none', 'full']"
:value="getPermissionValue('read', status.value)"
:status="status.value"
:save-permission="savePermission"
:permission-id="getPermissionValue('id', status.value)"
:collection="collection"
:role="role"
/>
<permissions-toggle
type="update"
:options="ownerField ? ['none', 'mine', 'role', 'full'] : ['none', 'full']"
:value="getPermissionValue('update', status.value)"
:status="status.value"
:save-permission="savePermission"
:permission-id="getPermissionValue('id', status.value)"
:collection="collection"
:role="role"
/>
<permissions-toggle
type="delete"
:options="ownerField ? ['none', 'mine', 'role', 'full'] : ['none', 'full']"
:value="getPermissionValue('delete', status.value)"
:status="status.value"
:save-permission="savePermission"
:permission-id="getPermissionValue('id', status.value)"
:collection="collection"
:role="role"
/>
<permissions-toggle
type="comment"
:options="['none', 'read', 'create', 'update', 'full']"
:value="getPermissionValue('comment', status.value)"
:status="status.value"
:save-permission="savePermission"
:permission-id="getPermissionValue('id', status.value)"
:collection="collection"
:role="role"
/>
<permissions-fields
:collection="collection"
:role="role"
:save-permission="savePermission"
:status="status.value"
:permission-id="getPermissionValue('id', status.value)"
:read-blacklist="getPermissionValue('read_field_blacklist', status.value)"
:write-blacklist="getPermissionValue('write_field_blacklist', status.value)"
/>
<permissions-statuses
v-if="statuses"
:collection="collection"
:role="role"
:save-permission="savePermission"
:status="status.value"
:statuses="statuses"
:permission-id="getPermissionValue('id', status.value)"
:status-blacklist="getPermissionValue('status_blacklist', status.value)"
/>
<div class="spacer" v-else>--</div>
<div class="spacer" />
</div>
</template>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, toRefs, ref, computed, PropType } from '@vue/composition-api';
import useCollection from '@/compositions/use-collection';
import PermissionsToggle from '../permissions-toggle';
import PermissionsFields from '../permissions-fields';
import PermissionsStatuses from '../permissions-statuses';
import { Permission } from '../../compositions/use-permissions';
function getDefaultPermission(collection: string, role: number, status?: string) {
const defaultPermission: Permission = {
collection: collection,
role: role,
create: 'none',
read: 'none',
update: 'none',
delete: 'none',
comment: 'none',
read_field_blacklist: [],
write_field_blacklist: [],
status_blacklist: [],
status: status || null,
};
return defaultPermission;
}
export default defineComponent({
components: { PermissionsToggle, PermissionsFields, PermissionsStatuses },
props: {
role: {
type: Number,
required: true,
},
collection: {
type: String,
required: true,
},
system: {
type: Boolean,
default: false,
},
savedPermissions: {
type: Array as PropType<Permission[]>,
required: true,
},
savePermission: {
type: Function,
required: true,
},
saveAll: {
type: Function,
required: true,
},
},
setup(props) {
const { collection } = toRefs(props);
const { fields, info, statusField, ownerField } = useCollection(collection);
const detailsOpen = ref(false);
type Status = {
value: string;
name: string;
};
const statuses = computed<Status[] | null>(() => {
if (statusField.value && statusField.value.options) {
return Object.keys(statusField.value.options.status_mapping).map((key: string) => ({
...statusField.value?.options?.status_mapping[key],
value: key,
}));
}
return null;
});
const permissions = computed<Permission[]>(() => {
const createPermission =
props.savedPermissions?.find((permission) => permission.status === '$create') ||
getDefaultPermission(props.collection, props.role, '$create');
if (statusField.value && statuses.value) {
const statusPermissions = statuses.value.map((status) => {
const existing = props.savedPermissions.find(
(permission) => permission.status === status.value
);
return (
existing || getDefaultPermission(props.collection, props.role, status.value)
);
});
return [...statusPermissions, createPermission];
} else {
const collectionPermission =
props.savedPermissions.find((permission) => permission.status === null) ||
getDefaultPermission(props.collection, props.role);
return [collectionPermission, createPermission];
}
});
return {
info,
fields,
statusField,
statuses,
detailsOpen,
permissions,
ownerField,
getPermissionValue,
getCombinedPermission,
saveForAllStatuses,
};
function getPermissionValue(type: keyof Permission, status: string | null = null) {
const permission = permissions.value.find((permission) => permission.status === status);
return permission?.[type];
}
function getCombinedPermission(type: keyof Permission) {
if (type.endsWith('_blacklist')) {
return permissions.value.map((permission) => permission[type]);
}
if (statusField.value) {
let value = permissions.value[0][type];
for (const permission of permissions.value.filter(
({ status }) => status !== '$create'
)) {
if (value !== permission[type]) {
value = 'indeterminate';
break;
}
}
return value;
} else {
const permission = permissions.value.find(
(permission) => permission.status === null
);
return permission?.[type];
}
}
async function saveForAllStatuses(updates: Partial<Permission>) {
const create: Partial<Permission>[] = [];
const update: Partial<Permission>[] = [];
permissions.value.forEach((permission) => {
if (permission.id) {
update.push({
...updates,
id: permission.id,
status: permission.status,
});
} else {
create.push({
...updates,
status: permission.status,
});
}
});
await props.saveAll(create, update);
}
},
});
</script>
<style lang="scss" scoped>
.row {
display: grid;
grid-gap: var(--grid-gap);
grid-template-columns: var(--grid-template-columns);
align-items: center;
padding: 8px 12px;
}
.permissions-row:not(:first-child),
.details .row:first-child {
border-top: 2px solid var(--border-subdued);
}
.details {
grid-column: 1 / -1;
background-color: var(--background-subdued);
}
.sub-indicator {
--v-icon-color: var(--foreground-subdued);
}
.spacer {
color: var(--foreground-subdued);
}
</style>

View File

@@ -0,0 +1,4 @@
import PermissionsStatuses from './permissions-statuses.vue';
export { PermissionsStatuses };
export default PermissionsStatuses;

View File

@@ -0,0 +1,165 @@
<template>
<v-modal v-model="modalActive" :title="$t('select_statuses')" persistent>
<template #activator="{ on }">
<span class="activator" @click="on" :class="{ limited: allAllowed === false }">
{{ allAllowed ? $t('all') : $t('limited') }}
</span>
</template>
<div class="statuses">
<v-checkbox
v-model="allowedStatuses"
v-for="status in statuses"
:value="status.value"
:key="status.value"
:indeterminate="indeterminate.includes(status.value)"
@update:indeterminate="
indeterminate = indeterminate.filter((s) => s !== status.value)
"
:label="status.name"
/>
</div>
<template #footer="{ close }">
<v-button secondary @click="close" :disabled="saving">{{ $t('cancel') }}</v-button>
<v-button @click="save" :loading="saving">{{ $t('save') }}</v-button>
</template>
</v-modal>
</template>
<script lang="ts">
import { defineComponent, ref, computed, watch, PropType } from '@vue/composition-api';
import { Permission } from '../../compositions/use-permissions';
import { intersection } from 'lodash';
export default defineComponent({
props: {
permissionId: {
type: Number,
default: undefined,
},
collection: {
type: String,
required: true,
},
role: {
type: Number,
required: true,
},
status: {
type: String,
default: null,
},
statuses: {
type: Array,
required: true,
},
statusBlacklist: {
type: Array as PropType<string[] | string[][]>,
required: true,
},
savePermission: {
type: Function,
required: true,
},
combined: {
type: Boolean,
default: false,
},
},
setup(props) {
const modalActive = ref(false);
const allowedStatuses = ref<string[]>([]);
const indeterminate = ref<string[]>([]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const statusKeys = computed(() => props.statuses.map((status: any) => status.value));
const allAllowed = computed(() => {
let blacklist = [...props.statusBlacklist];
if (props.combined === true) {
blacklist = blacklist.flat();
}
return blacklist.length === 0;
});
watch(modalActive, (newVal) => {
if (newVal !== true) return;
if (props.combined === true) {
allowedStatuses.value = invertBlacklist(
intersection(...(props.statusBlacklist as string[][]))
);
allowedStatuses.value = [...new Set(props.statusBlacklist.flat())].filter((k) =>
allowedStatuses.value.includes(k)
);
} else {
allowedStatuses.value = invertBlacklist(props.statusBlacklist as string[]);
}
});
const saving = ref(false);
return {
save,
modalActive,
saving,
allAllowed,
allowedStatuses,
indeterminate,
};
async function save() {
saving.value = true;
const values: Partial<Permission> = {
collection: props.collection,
status: props.status,
role: props.role,
status_blacklist: statusKeys.value.filter(
(key) => allowedStatuses.value.includes(key) === false
),
};
if (props.permissionId) {
values.id = props.permissionId;
}
await props.savePermission(values);
modalActive.value = false;
saving.value = false;
}
function invertBlacklist(blacklist: string[]) {
return statusKeys.value.filter((key) => blacklist.includes(key) === false);
}
},
});
</script>
<style lang="scss" scoped>
.limited {
color: var(--warning);
}
.activator {
position: relative;
width: max-content;
margin: -4px -8px;
padding: 4px 8px;
border-radius: var(--border-radius);
cursor: pointer;
&:hover {
background-color: var(--background-normal);
}
&:active {
background-color: var(--background-normal-alt);
}
}
</style>

View File

@@ -0,0 +1,4 @@
import PermissionsToggle from './permissions-toggle.vue';
export { PermissionsToggle };
export default PermissionsToggle;

View File

@@ -0,0 +1,185 @@
<template>
<v-menu
show-arrow
placement="left-start"
class="permissions-toggle"
close-on-content-click
:disabled="saving"
>
<template #activator="{ toggle }">
<span>
<v-progress-circular class="spinner" indeterminate small v-if="saving" />
<div class="box" :class="value" v-else @click="toggle">
<v-icon v-if="iconName" :name="iconName" />
</div>
</span>
</template>
<v-list dense>
<v-list-item
v-for="option in _options"
:key="option.value"
:active="value === option.value"
@click="save(option.value)"
>
<v-list-item-icon>
<div class="box" :class="option.value">
<v-icon v-if="option.icon" :name="option.icon" />
</div>
</v-list-item-icon>
<v-list-item-content>{{ option.name }}</v-list-item-content>
</v-list-item>
</v-list>
</v-menu>
</template>
<script lang="ts">
import { defineComponent, PropType, computed, ref } from '@vue/composition-api';
import i18n from '@/lang';
import { Permission } from '../../compositions/use-permissions';
export default defineComponent({
props: {
value: {
type: String,
required: true,
},
options: {
type: Array as PropType<string[]>,
required: true,
},
loading: {
type: Boolean,
default: false,
},
status: {
type: String,
default: null,
},
savePermission: {
type: Function,
required: true,
},
type: {
type: String,
required: true,
},
permissionId: {
type: Number,
default: undefined,
},
collection: {
type: String,
required: true,
},
role: {
type: Number,
required: true,
},
},
setup(props) {
const iconName = computed(() => {
return getIconForValue(props.value);
});
const _options = computed(() => {
return props.options.map((option) => ({
value: option,
name: i18n.t(option),
icon: getIconForValue(option),
}));
});
const saving = ref(false);
return { iconName, _options, save, saving };
async function save(newValue: string) {
saving.value = true;
const values: Partial<Permission> = {
[props.type]: newValue,
collection: props.collection,
status: props.status,
role: props.role,
};
if (props.permissionId) {
values.id = props.permissionId;
}
await props.savePermission(values);
saving.value = false;
}
function getIconForValue(value: string) {
switch (value) {
case 'indeterminate':
return 'remove';
case 'mine':
return 'person';
case 'role':
return 'group';
case 'full':
return 'check';
case 'read':
return 'remove_red_eye';
case 'create':
return 'add';
case 'update':
return 'edit';
case 'none':
return null;
default:
return 'check_box_outline_blank';
}
}
},
});
</script>
<style lang="scss" scoped>
.box {
--color: var(--foreground-subdued);
position: relative;
left: 3px; // aligns it better with regular material icons
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
background-color: var(--color);
border: 2px solid transparent;
border-radius: 2px;
cursor: pointer;
&.none {
--color: transparent;
border-color: var(--foreground-subdued);
}
&.indeterminate {
--color: var(--foreground-subdued);
}
&.mine {
--color: #ff9800;
}
&.role {
--color: #fbc02d;
}
&.full {
--color: var(--success);
}
.v-icon {
--v-icon-size: 14px;
--v-icon-color: var(--foreground-inverted);
}
}
</style>

View File

@@ -0,0 +1,95 @@
import { ref, Ref, watch } from '@vue/composition-api';
import api from '@/api';
import useProjectsStore from '@/stores/projects';
export type Permission = {
id?: number;
collection: string;
role: number;
status: null | string;
create: 'none' | 'full';
read: 'none' | 'mine' | 'role' | 'full';
update: 'none' | 'mine' | 'role' | 'full';
delete: 'none' | 'mine' | 'role' | 'full';
comment: 'none' | 'read' | 'create' | 'update' | 'full';
read_field_blacklist: null | string[];
write_field_blacklist: null | string[];
status_blacklist: null | string[];
};
export default function usePermissions(role: Ref<number>) {
const loading = ref(false);
const error = ref(null);
const permissions = ref<Permission[]>(null);
const projectsStore = useProjectsStore();
watch(role, (newRole, oldRole) => {
if (newRole !== oldRole) {
reset();
fetchPermissions();
}
});
return { loading, error, permissions, fetchPermissions, savePermission, saveAll };
function reset() {
loading.value = false;
error.value = null;
permissions.value = null;
}
async function fetchPermissions() {
const { currentProjectKey } = projectsStore.state;
loading.value = true;
try {
const response = await api.get(`/${currentProjectKey}/permissions`, {
params: {
'filter[role][eq]': role.value,
},
});
permissions.value = response.data.data;
} catch (err) {
error.value = err;
} finally {
loading.value = false;
}
}
async function savePermission(updates: Partial<Permission>) {
const { currentProjectKey } = projectsStore.state;
try {
if (updates.id !== undefined) {
await api.patch(`/${currentProjectKey}/permissions/${updates.id}`, {
...updates,
});
} else {
await api.post(`/${currentProjectKey}/permissions`, updates);
}
await fetchPermissions();
} catch (err) {
throw err;
}
}
async function saveAll(create: Partial<Permission>[], update: Partial<Permission>[]) {
const { currentProjectKey } = projectsStore.state;
try {
if (create.length > 0) {
await api.post(`/${currentProjectKey}/permissions`, create);
}
if (update.length > 0) {
await api.patch(`/${currentProjectKey}/permissions`, update);
}
await fetchPermissions();
} catch (err) {
throw err;
}
}
}

View File

@@ -62,13 +62,22 @@
<settings-navigation />
</template>
<v-form
collection="directus_roles"
:loading="loading"
:initial-values="item"
:batch-mode="isBatch"
v-model="edits"
/>
<div class="roles">
<div class="permissions">
<h2 class="title type-label">
{{ $t('permissions') }}
<span class="instant-save">{{ $t('saves_automatically') }}</span>
</h2>
<permissions-management :role="+primaryKey" />
</div>
<v-form
collection="directus_roles"
:loading="loading"
:initial-values="item"
:batch-mode="isBatch"
v-model="edits"
/>
</div>
<template #drawer>
<activity-drawer-detail
@@ -89,6 +98,7 @@ import router from '@/router';
import ActivityDrawerDetail from '@/views/private/components/activity-drawer-detail';
import useItem from '@/compositions/use-item';
import SaveOptions from '@/views/private/components/save-options';
import PermissionsManagement from './components/permissions-management';
type Values = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -97,7 +107,7 @@ type Values = {
export default defineComponent({
name: 'roles-detail',
components: { SettingsNavigation, ActivityDrawerDetail, SaveOptions },
components: { SettingsNavigation, ActivityDrawerDetail, SaveOptions, PermissionsManagement },
props: {
primaryKey: {
type: String,
@@ -191,7 +201,7 @@ export default defineComponent({
--v-button-background-color-hover: var(--danger-dark);
}
.v-form {
.roles {
padding: var(--content-padding);
}
@@ -199,4 +209,17 @@ export default defineComponent({
--v-button-color-disabled: var(--warning);
--v-button-background-color-disabled: var(--warning-alt);
}
.title {
margin-bottom: 12px;
.instant-save {
margin-left: 4px;
color: var(--warning);
}
}
.permissions {
margin-bottom: 48px;
}
</style>