System permissions for app access (#4004)

* Pass relations through schema, instead of individual reads

* Fetch field transforms upfront

* Fix length check

* List if user has app access or not in accountability

* Load permissions up front, merge app access minimal permissions

* Show app access required permissions in permissions overview

* Show system minimal permissions in permissions detail

* Fix app access check in authenticate for jwt use

* Fix minimal permissions for presets

* Remove /permissions/me in favor of root use w/ permissions

* Fix logical nested OR in an AND

* Use root permissions endpoint with filter instead of /me

* Allow filter query on /permissions

* Add system minimal app access permissions into result of /permissions

* Remove stray console log

* Remove stray console.dir

* Set current role as role for minimal permissions

* Fix no-permissions state for user detail

* Add filter items function that allows altering existing result set
This commit is contained in:
Rijk van Zanten
2021-02-11 12:50:56 -05:00
committed by GitHub
parent 8c1402fb88
commit b7d87e581a
55 changed files with 897 additions and 524 deletions

View File

@@ -2,6 +2,7 @@ import env from '../../../env';
import logger from '../../../logger';
import installDatabase from '../../../database/seeds/run';
import runMigrations from '../../../database/migrations/run';
import { getSchema } from '../../../utils/get-schema';
import { nanoid } from 'nanoid';
export default async function bootstrap() {
@@ -22,7 +23,7 @@ export default async function bootstrap() {
await installDatabase(database);
const schema = await schemaInspector.overview();
const schema = await getSchema();
logger.info('Setting up first admin role...');
const rolesService = new RolesService({ schema });

View File

@@ -1,5 +1,7 @@
import { getSchema } from '../../../utils/get-schema';
export default async function rolesCreate({ name, admin }: any) {
const { default: database, schemaInspector } = require('../../../database/index');
const { default: database } = require('../../../database/index');
const { RolesService } = require('../../../services/roles');
if (!name) {
@@ -8,7 +10,7 @@ export default async function rolesCreate({ name, admin }: any) {
}
try {
const schema = await schemaInspector.overview();
const schema = await getSchema();
const service = new RolesService({ schema: schema, knex: database });
const id = await service.create({ name, admin_access: admin });

View File

@@ -1,3 +1,5 @@
import { getSchema } from '../../../utils/get-schema';
export default async function usersCreate({ email, password, role }: any) {
const { default: database, schemaInspector } = require('../../../database/index');
const { UsersService } = require('../../../services/users');
@@ -8,7 +10,7 @@ export default async function usersCreate({ email, password, role }: any) {
}
try {
const schema = await schemaInspector.overview();
const schema = await getSchema();
const service = new UsersService({ schema, knex: database });
const id = await service.create({ email, password, role, status: 'active' });

View File

@@ -52,7 +52,7 @@ router.get(
schema: req.schema,
});
if (req.params.field in req.schema[req.params.collection].columns === false) throw new ForbiddenException();
if (req.params.field in req.schema.tables[req.params.collection].columns === false) throw new ForbiddenException();
const field = await service.readOne(req.params.collection, req.params.field);

View File

@@ -1,8 +1,7 @@
import express from 'express';
import asyncHandler from '../utils/async-handler';
import { PermissionsService, MetaService } from '../services';
import { clone } from 'lodash';
import { InvalidCredentialsException, ForbiddenException, InvalidPayloadException } from '../exceptions';
import { ForbiddenException, InvalidPayloadException } from '../exceptions';
import useCollection from '../middleware/use-collection';
import { respond } from '../middleware/respond';
import { PrimaryKey } from '../types';
@@ -42,6 +41,7 @@ router.get(
accountability: req.accountability,
schema: req.schema,
});
const metaService = new MetaService({
accountability: req.accountability,
schema: req.schema,
@@ -56,36 +56,6 @@ router.get(
respond
);
router.get(
'/me',
asyncHandler(async (req, res, next) => {
const service = new PermissionsService({ schema: req.schema });
const query = clone(req.sanitizedQuery || {});
if (req.accountability?.role) {
query.filter = {
...(query.filter || {}),
role: {
_eq: req.accountability.role,
},
};
} else {
query.filter = {
...(query.filter || {}),
role: {
_null: true,
},
};
}
const items = await service.readByQuery(query);
res.locals.payload = { data: items || null };
return next();
}),
respond
);
router.get(
'/:pk',
asyncHandler(async (req, res, next) => {

View File

@@ -1,6 +1,5 @@
import knex, { Config } from 'knex';
import dotenv from 'dotenv';
import camelCase from 'camelcase';
import path from 'path';
import logger from '../logger';
import env from '../env';

View File

@@ -93,8 +93,8 @@ async function parseCurrentLevel(
children: (NestedCollectionNode | FieldNode)[],
schema: SchemaOverview
) {
const primaryKeyField = schema[collection].primary;
const columnsInCollection = Object.keys(schema[collection].columns);
const primaryKeyField = schema.tables[collection].primary;
const columnsInCollection = Object.keys(schema.tables[collection].columns);
const columnsToSelect: string[] = [];
const nestedCollectionNodes: NestedCollectionNode[] = [];
@@ -154,7 +154,7 @@ async function getDBQuery(
query.sort = query.sort || [{ column: primaryKeyField, order: 'asc' }];
await applyQuery(knex, table, dbQuery, queryCopy, schema);
await applyQuery(table, dbQuery, queryCopy, schema);
// Nested filters use joins to filter on the parent level, to prevent duplicate
// parents, we group the query by the current tables primary key (which is unique)

View File

@@ -0,0 +1,96 @@
# NOTE: Activity/collections/fields/presets/relations/revisions will have an extra hardcoded filter
# to filter out collections you don't have read access
- collection: directus_activity
action: read
permissions:
user:
_eq: $CURRENT_USER
- collection: directus_activity
action: create
validation:
comment:
_nnull: true
- collection: directus_collections
action: read
- collection: directus_fields
action: read
- collection: directus_permissions
action: read
permissions:
role:
_eq: $CURRENT_ROLE
- collection: directus_presets
action: read
permissions:
_or:
- user:
_eq: $CURRENT_USER
- _and:
- user:
_null: true
- role:
_eq: $CURRENT_ROLE
- _and:
- user:
_null: true
- role:
_null: true
- collection: directus_presets
action: create
validation:
- user:
_eq: $CURRENT_USER
- collection: directus_presets
action: update
permissions:
user:
_eq: $CURRENT_USER
- collection: directus_presets
action: delete
permissions:
user:
_eq: $CURRENT_USER
- collection: directus_relations
action: read
- collection: directus_roles
action: read
permissions:
id:
_eq: $CURRENT_ROLE
- collection: directus_settings
action: read
- collection: directus_users
action: read
permissions:
id:
_eq: $CURRENT_USER
fields:
- id
- first_name
- last_name
- email
- password
- location
- title
- description
- tags
- preferences_divider
- avatar
- language
- theme
- tfa_secret
- status
- role

View File

@@ -0,0 +1,17 @@
import { requireYAML } from '../../../utils/require-yaml';
import { Permission } from '../../../types';
import { merge } from 'lodash';
const defaults: Partial<Permission> = {
role: null,
permissions: {},
validation: null,
presets: null,
fields: ['*'],
limit: null,
system: true,
};
const permissions = requireYAML(require.resolve('./app-access-permissions.yaml')) as Permission[];
export const appAccessMinimalPermissions: Permission[] = permissions.map((row) => merge({}, defaults, row));

View File

@@ -14,6 +14,7 @@ const authenticate: RequestHandler = asyncHandler(async (req, res, next) => {
user: null,
role: null,
admin: false,
app: false,
ip: req.ip.startsWith('::ffff:') ? req.ip.substring(7) : req.ip,
userAgent: req.get('user-agent'),
};
@@ -36,7 +37,7 @@ const authenticate: RequestHandler = asyncHandler(async (req, res, next) => {
}
const user = await database
.select('role', 'directus_roles.admin_access')
.select('role', 'directus_roles.admin_access', 'directus_roles.app_access')
.from('directus_users')
.leftJoin('directus_roles', 'directus_users.role', 'directus_roles.id')
.where({
@@ -52,10 +53,11 @@ const authenticate: RequestHandler = asyncHandler(async (req, res, next) => {
req.accountability.user = payload.id;
req.accountability.role = user.role;
req.accountability.admin = user.admin_access === true || user.admin_access == 1;
req.accountability.app = user.app_access === true || user.app_access == 1;
} else {
// Try finding the user with the provided token
const user = await database
.select('directus_users.id', 'directus_users.role', 'directus_roles.admin_access')
.select('directus_users.id', 'directus_users.role', 'directus_roles.admin_access', 'directus_roles.app_access')
.from('directus_users')
.leftJoin('directus_roles', 'directus_users.role', 'directus_roles.id')
.where({
@@ -71,6 +73,7 @@ const authenticate: RequestHandler = asyncHandler(async (req, res, next) => {
req.accountability.user = user.id;
req.accountability.role = user.role;
req.accountability.admin = user.admin_access === true || user.admin_access == 1;
req.accountability.app = user.app_access === true || user.app_access == 1;
}
if (req.accountability?.user) {

View File

@@ -11,7 +11,7 @@ import { systemCollectionRows } from '../database/system-data/collections';
const collectionExists: RequestHandler = asyncHandler(async (req, res, next) => {
if (!req.params.collection) return next();
if (req.params.collection in req.schema === false) {
if (req.params.collection in req.schema.tables === false) {
throw new ForbiddenException();
}

View File

@@ -1,21 +1,10 @@
import { RequestHandler } from 'express';
import asyncHandler from '../utils/async-handler';
import { schemaInspector } from '../database';
import logger from '../logger';
const getSchema: RequestHandler = asyncHandler(async (req, res, next) => {
const schemaOverview = await schemaInspector.overview();
for (const [collection, info] of Object.entries(schemaOverview)) {
if (!info.primary) {
logger.warn(`Collection "${collection}" doesn't have a primary key column and will be ignored`);
delete schemaOverview[collection];
}
}
req.schema = schemaOverview;
import { getSchema } from '../utils/get-schema';
const schema: RequestHandler = asyncHandler(async (req, res, next) => {
req.schema = await getSchema(req.accountability);
return next();
});
export default getSchema;
export default schema;

View File

@@ -41,19 +41,12 @@ export class AuthorizationService {
async processAST(ast: AST, action: PermissionsAction = 'read'): Promise<AST> {
const collectionsRequested = getCollectionsFromAST(ast);
let permissionsForCollections = await this.knex
.select<Permission[]>('*')
.from('directus_permissions')
.where({ action, role: this.accountability?.role })
.whereIn(
'collection',
collectionsRequested.map(({ collection }) => collection)
let permissionsForCollections = this.schema.permissions.filter((permission) => {
return (
permission.action === action &&
collectionsRequested.map(({ collection }) => collection).includes(permission.collection)
);
permissionsForCollections = (await this.payloadService.processValues(
'read',
permissionsForCollections
)) as Permission[];
});
// If the permissions don't match the collections, you don't have permission to read all of them
const uniqueCollectionsRequestedCount = uniq(collectionsRequested.map(({ collection }) => collection)).length;
@@ -203,17 +196,13 @@ export class AuthorizationService {
presets: {},
};
} else {
permission = await this.knex
.select('*')
.from('directus_permissions')
.where({ action, collection, role: this.accountability?.role || null })
.first();
permission = this.schema.permissions.find((permission) => {
return permission.action === action;
});
if (!permission) throw new ForbiddenException();
permission = (await this.payloadService.processValues('read', permission as Item)) as Permission;
// Check if you have permission to access the fields you're trying to acces
// Check if you have permission to access the fields you're trying to access
const allowedFields = permission.fields || [];
@@ -235,22 +224,18 @@ export class AuthorizationService {
payloads = payloads.map((payload) => merge({}, preset, payload));
const columns = Object.values(this.schema[collection].columns);
const columns = Object.values(this.schema.tables[collection].columns);
let requiredColumns: string[] = [];
for (const column of columns) {
const field =
(await this.knex
.select<{ special: string }>('special')
.from('directus_fields')
.where({ collection, field: column.column_name })
.first()) ||
this.schema.fields.find((field) => field.collection === collection && field.field === column.column_name) ||
systemFieldRows.find(
(fieldMeta) => fieldMeta.field === column.column_name && fieldMeta.collection === collection
);
const specials = field?.special ? toArray(field.special) : [];
const specials = field?.special ?? [];
const hasGenerateSpecial = ['uuid', 'date-created', 'role-created', 'user-created'].some((name) =>
specials.includes(name)

View File

@@ -71,7 +71,7 @@ export class CollectionsService {
throw new InvalidPayloadException(`Collections can't start with "directus_"`);
}
if (payload.collection in this.schema) {
if (payload.collection in this.schema.tables) {
throw new InvalidPayloadException(`Collection "${payload.collection}" already exists.`);
}
@@ -113,12 +113,9 @@ export class CollectionsService {
const collectionKeys = toArray(collection);
if (this.accountability && this.accountability.admin !== true) {
const permissions = await this.knex
.select('collection')
.from('directus_permissions')
.where({ action: 'read' })
.where({ role: this.accountability.role })
.whereIn('collection', collectionKeys);
const permissions = this.schema.permissions.filter((permission) => {
return permission.action === 'read' && collectionKeys.includes(permission.collection);
});
if (collectionKeys.length !== permissions.length) {
const collectionsYouHavePermissionToRead = permissions.map(({ collection }) => collection);
@@ -163,12 +160,11 @@ export class CollectionsService {
let tablesInDatabase = await schemaInspector.tableInfo();
if (this.accountability && this.accountability.admin !== true) {
const collectionsYouHavePermissionToRead: string[] = (
await this.knex.select('collection').from('directus_permissions').where({
role: this.accountability.role,
action: 'read',
const collectionsYouHavePermissionToRead: string[] = this.schema.permissions
.filter((permission) => {
return permission.action === 'read';
})
).map(({ collection }) => collection);
.map(({ collection }) => collection);
tablesInDatabase = tablesInDatabase.filter((table) => {
return collectionsYouHavePermissionToRead.includes(table.name);
@@ -272,7 +268,7 @@ export class CollectionsService {
schema: this.schema,
});
const tablesInDatabase = Object.keys(this.schema);
const tablesInDatabase = Object.keys(this.schema.tables);
const collectionKeys = toArray(collection);
@@ -290,11 +286,9 @@ export class CollectionsService {
await this.knex('directus_activity').delete().whereIn('collection', collectionKeys);
await this.knex('directus_permissions').delete().whereIn('collection', collectionKeys);
const relations = await this.knex
.select<Relation[]>('*')
.from('directus_relations')
.where({ many_collection: collection })
.orWhere({ one_collection: collection });
const relations = this.schema.relations.filter((relation) => {
return relation.many_collection === collection || relation.one_collection === collection;
});
for (const relation of relations) {
const isM2O = relation.many_collection === collection;

View File

@@ -120,14 +120,14 @@ export class FieldsService {
// Filter the result so we only return the fields you have read access to
if (this.accountability && this.accountability.admin !== true) {
const permissions = await this.knex
.select('collection', 'fields')
.from('directus_permissions')
.where({ role: this.accountability.role, action: 'read' });
const permissions = this.schema.permissions.filter((permission) => {
return permission.action === 'read';
});
const allowedFieldsInCollection: Record<string, string[]> = {};
permissions.forEach((permission) => {
allowedFieldsInCollection[permission.collection] = (permission.fields || '').split(',');
allowedFieldsInCollection[permission.collection] = permission.fields ?? [];
});
if (collection && allowedFieldsInCollection.hasOwnProperty(collection) === false) {
@@ -147,19 +147,13 @@ export class FieldsService {
async readOne(collection: string, field: string) {
if (this.accountability && this.accountability.admin !== true) {
const permissions = await this.knex
.select('fields')
.from('directus_permissions')
.where({
role: this.accountability.role,
collection,
action: 'read',
})
.first();
const permissions = this.schema.permissions.find((permission) => {
return permission.action === 'read' && permission.collection === collection;
});
if (!permissions) throw new ForbiddenException();
if (permissions.fields !== '*') {
const allowedFields = (permissions.fields || '').split(',');
if (!permissions || !permissions.fields) throw new ForbiddenException();
if (permissions.fields.includes('*') === false) {
const allowedFields = permissions.fields;
if (allowedFields.includes(field) === false) throw new ForbiddenException();
}
}
@@ -201,10 +195,10 @@ export class FieldsService {
}
// Check if field already exists, either as a column, or as a row in directus_fields
if (field.field in this.schema[collection].columns) {
if (field.field in this.schema.tables[collection].columns) {
throw new InvalidPayloadException(`Field "${field.field}" already exists in collection "${collection}"`);
} else if (
!!(await this.knex.select('id').from('directus_fields').where({ collection, field: field.field }).first())
!!this.schema.fields.find((fieldMeta) => fieldMeta.collection === collection && fieldMeta.field === field.field)
) {
throw new InvalidPayloadException(`Field "${field.field}" already exists in collection "${collection}"`);
}
@@ -245,11 +239,9 @@ export class FieldsService {
}
if (field.meta) {
const record = await database
.select<{ id: number }>('id')
.from('directus_fields')
.where({ collection, field: field.field })
.first();
const record = this.schema.fields.find(
(fieldMeta) => fieldMeta.field === field.field && fieldMeta.collection === collection
);
if (record) {
await this.itemsService.update(
@@ -284,17 +276,18 @@ export class FieldsService {
await this.knex('directus_fields').delete().where({ collection, field });
if (field in this.schema[collection].columns) {
if (field in this.schema.tables[collection].columns) {
await this.knex.schema.table(collection, (table) => {
table.dropColumn(field);
});
}
const relations = await this.knex
.select<Relation[]>('*')
.from('directus_relations')
.where({ many_collection: collection, many_field: field })
.orWhere({ one_collection: collection, one_field: field });
const relations = this.schema.relations.filter((relation) => {
return (
(relation.many_collection === collection && relation.many_field === field) ||
(relation.one_collection === collection && relation.one_field === field)
);
});
for (const relation of relations) {
const isM2O = relation.many_collection === collection && relation.many_field === field;

View File

@@ -46,8 +46,8 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
async create(data: Partial<Item>[]): Promise<PrimaryKey[]>;
async create(data: Partial<Item>): Promise<PrimaryKey>;
async create(data: Partial<Item> | Partial<Item>[]): Promise<PrimaryKey | PrimaryKey[]> {
const primaryKeyField = this.schema[this.collection].primary;
const columns = Object.keys(this.schema[this.collection].columns);
const primaryKeyField = this.schema.tables[this.collection].primary;
const columns = Object.keys(this.schema.tables[this.collection].columns);
let payloads: AnyItem[] = clone(toArray(data));
@@ -210,7 +210,7 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
action: PermissionsAction = 'read'
): Promise<null | Partial<Item> | Partial<Item>[]> {
query = clone(query);
const primaryKeyField = this.schema[this.collection].primary;
const primaryKeyField = this.schema.tables[this.collection].primary;
const keys = toArray(key);
if (keys.length === 1) {
@@ -257,8 +257,8 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
data: Partial<Item> | Partial<Item>[],
key?: PrimaryKey | PrimaryKey[]
): Promise<PrimaryKey | PrimaryKey[]> {
const primaryKeyField = this.schema[this.collection].primary;
const columns = Object.keys(this.schema[this.collection].columns);
const primaryKeyField = this.schema.tables[this.collection].primary;
const columns = Object.keys(this.schema.tables[this.collection].columns);
// Updating one or more items to the same payload
if (data && key) {
@@ -401,7 +401,7 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
}
async updateByQuery(data: Partial<Item>, query: Query): Promise<PrimaryKey[]> {
const primaryKeyField = this.schema[this.collection].primary;
const primaryKeyField = this.schema.tables[this.collection].primary;
const readQuery = cloneDeep(query);
readQuery.fields = [primaryKeyField];
@@ -422,7 +422,7 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
upsert(data: Partial<Item>[]): Promise<PrimaryKey[]>;
upsert(data: Partial<Item>): Promise<PrimaryKey>;
async upsert(data: Partial<Item> | Partial<Item>[]): Promise<PrimaryKey | PrimaryKey[]> {
const primaryKeyField = this.schema[this.collection].primary;
const primaryKeyField = this.schema.tables[this.collection].primary;
const payloads = toArray(data);
const primaryKeys: PrimaryKey[] = [];
@@ -452,7 +452,7 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
delete(keys: PrimaryKey[]): Promise<PrimaryKey[]>;
async delete(key: PrimaryKey | PrimaryKey[]): Promise<PrimaryKey | PrimaryKey[]> {
const keys = toArray(key);
const primaryKeyField = this.schema[this.collection].primary;
const primaryKeyField = this.schema.tables[this.collection].primary;
if (this.accountability && this.accountability.admin !== true) {
const authorizationService = new AuthorizationService({
@@ -508,7 +508,7 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
}
async deleteByQuery(query: Query): Promise<PrimaryKey[]> {
const primaryKeyField = this.schema[this.collection].primary;
const primaryKeyField = this.schema.tables[this.collection].primary;
const readQuery = cloneDeep(query);
readQuery.fields = [primaryKeyField];
@@ -532,7 +532,7 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
const record = (await this.readByQuery(query, opts)) as Partial<Item>;
if (!record) {
let columns = Object.values(this.schema[this.collection].columns);
let columns = Object.values(this.schema.tables[this.collection].columns);
const defaults: Record<string, any> = {};
if (query.fields && query.fields.includes('*') === false) {
@@ -552,7 +552,7 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
}
async upsertSingleton(data: Partial<Item>) {
const primaryKeyField = this.schema[this.collection].primary;
const primaryKeyField = this.schema.tables[this.collection].primary;
const record = await this.knex.select(primaryKeyField).from(this.collection).limit(1).first();

View File

@@ -1,16 +1,18 @@
import { Query } from '../types/query';
import database from '../database';
import { AbstractServiceOptions, Accountability } from '../types';
import { AbstractServiceOptions, Accountability, SchemaOverview } from '../types';
import Knex from 'knex';
import { applyFilter } from '../utils/apply-query';
export class MetaService {
knex: Knex;
accountability: Accountability | null;
schema: SchemaOverview;
constructor(options?: AbstractServiceOptions) {
this.knex = options?.knex || database;
this.accountability = options?.accountability || null;
constructor(options: AbstractServiceOptions) {
this.knex = options.knex || database;
this.accountability = options.accountability || null;
this.schema = options.schema;
}
async getMetaForQuery(collection: string, query: Query) {
@@ -40,7 +42,7 @@ export class MetaService {
const dbQuery = this.knex(collection).count('*', { as: 'count' });
if (query.filter) {
await applyFilter(this.knex, dbQuery, query.filter, collection);
await applyFilter(this.schema, dbQuery, query.filter, collection);
}
const records = await dbQuery;

View File

@@ -7,16 +7,13 @@ import argon2 from 'argon2';
import { v4 as uuidv4 } from 'uuid';
import database from '../database';
import { clone, isObject, cloneDeep } from 'lodash';
import { Relation, Item, AbstractServiceOptions, Accountability, PrimaryKey, SchemaOverview } from '../types';
import { Item, AbstractServiceOptions, Accountability, PrimaryKey, SchemaOverview } from '../types';
import { ItemsService } from './items';
import { URL } from 'url';
import Knex from 'knex';
import env from '../env';
import getLocalType from '../utils/get-local-type';
import { format, formatISO } from 'date-fns';
import { ForbiddenException } from '../exceptions';
import { toArray } from '../utils/to-array';
import { FieldMeta } from '../types';
import { systemFieldRows } from '../database/system-data/fields';
import { systemRelationRows } from '../database/system-data/relations';
import { InvalidPayloadException } from '../exceptions';
@@ -141,13 +138,20 @@ export class PayloadService {
const fieldsInPayload = Object.keys(processedPayload[0]);
let specialFieldsInCollection: FieldMeta[] = await this.knex
.select('field', 'special')
.from('directus_fields')
.where({ collection: this.collection })
.whereNotNull('special');
let specialFieldsInCollection = this.schema.fields.filter(
(field) => field.collection === this.collection && field.special && field.special.length > 0
);
specialFieldsInCollection.push(...systemFieldRows.filter((fieldMeta) => fieldMeta.collection === this.collection));
specialFieldsInCollection.push(
...systemFieldRows
.filter((fieldMeta) => fieldMeta.collection === this.collection)
.map((fieldMeta) => ({
id: fieldMeta.id,
collection: fieldMeta.collection,
field: fieldMeta.field,
special: fieldMeta.special ?? [],
}))
);
if (action === 'read') {
specialFieldsInCollection = specialFieldsInCollection.filter((fieldMeta) => {
@@ -187,7 +191,12 @@ export class PayloadService {
return processedPayload[0];
}
async processField(field: FieldMeta, payload: Partial<Item>, action: Action, accountability: Accountability | null) {
async processField(
field: SchemaOverview['fields'][number],
payload: Partial<Item>,
action: Action,
accountability: Accountability | null
) {
if (!field.special) return payload[field.field];
const fieldSpecials = field.special ? toArray(field.special) : [];
@@ -212,7 +221,7 @@ export class PayloadService {
* shouldn't return with time / timezone info respectively
*/
async processDates(payloads: Partial<Record<string, any>>[]) {
const columnsInCollection = Object.values(this.schema[this.collection].columns);
const columnsInCollection = Object.values(this.schema.tables[this.collection].columns);
const columnsWithType = columnsInCollection.map((column) => ({
name: column.column_name,
@@ -265,10 +274,9 @@ export class PayloadService {
processA2O(payloads: Partial<Item>): Promise<Partial<Item>>;
async processA2O(payload: Partial<Item> | Partial<Item>[]): Promise<Partial<Item> | Partial<Item>[]> {
const relations = [
...(await this.knex
.select<Relation[]>('*')
.from('directus_relations')
.where({ many_collection: this.collection })),
...this.schema.relations.filter((relation) => {
return relation.many_collection === this.collection;
}),
...systemRelationRows.filter((systemRelation) => systemRelation.many_collection === this.collection),
];
@@ -309,7 +317,7 @@ export class PayloadService {
schema: this.schema,
});
const relatedPrimary = this.schema[relatedCollection].primary;
const relatedPrimary = this.schema.tables[relatedCollection].primary;
const relatedRecord: Partial<Item> = payload[relation.many_field];
const hasPrimaryKey = relatedRecord.hasOwnProperty(relatedPrimary);
@@ -337,10 +345,9 @@ export class PayloadService {
processM2O(payloads: Partial<Item>): Promise<Partial<Item>>;
async processM2O(payload: Partial<Item> | Partial<Item>[]): Promise<Partial<Item> | Partial<Item>[]> {
const relations = [
...(await this.knex
.select<Relation[]>('*')
.from('directus_relations')
.where({ many_collection: this.collection })),
...this.schema.relations.filter((relation) => {
return relation.many_collection === this.collection;
}),
...systemRelationRows.filter((systemRelation) => systemRelation.many_collection === this.collection),
];
@@ -391,10 +398,9 @@ export class PayloadService {
*/
async processO2M(payload: Partial<Item> | Partial<Item>[], parent?: PrimaryKey) {
const relations = [
...(await this.knex
.select<Relation[]>('*')
.from('directus_relations')
.where({ one_collection: this.collection })),
...this.schema.relations.filter((relation) => {
return relation.one_collection === this.collection;
}),
...systemRelationRows.filter((systemRelation) => systemRelation.one_collection === this.collection),
];

View File

@@ -1,34 +1,78 @@
import { AbstractServiceOptions, PermissionsAction } from '../types';
import { AbstractServiceOptions, PermissionsAction, Query, Item, PrimaryKey } from '../types';
import { ItemsService } from '../services/items';
import { filterItems } from '../utils/filter-items';
import { appAccessMinimalPermissions } from '../database/system-data/app-access-permissions';
export class PermissionsService extends ItemsService {
constructor(options: AbstractServiceOptions) {
super('directus_permissions', options);
}
async getAllowedCollections(role: string | null, action: PermissionsAction) {
const query = this.knex.select('collection').from('directus_permissions').where({ role, action });
const results = await query;
return results.map((result) => result.collection);
}
getAllowedFields(action: PermissionsAction, collection?: string) {
const results = this.schema.permissions.filter((permission) => {
let matchesCollection = true;
async getAllowedFields(role: string | null, action: PermissionsAction, collection?: string) {
const query = this.knex.select('collection', 'fields').from('directus_permissions').where({ role, action });
if (collection) {
matchesCollection = permission.collection === collection;
}
if (collection) {
query.andWhere({ collection });
}
const results = await query;
return permission.action === action;
});
const fieldsPerCollection: Record<string, string[]> = {};
for (const result of results) {
const { collection, fields } = result;
if (!fieldsPerCollection[collection]) fieldsPerCollection[collection] = [];
fieldsPerCollection[collection].push(...(fields || '').split(','));
fieldsPerCollection[collection].push(...(fields ?? []));
}
return fieldsPerCollection;
}
async readByQuery(
query: Query,
opts?: { stripNonRequested?: boolean }
): Promise<null | Partial<Item> | Partial<Item>[]> {
const result = await super.readByQuery(query, opts);
if (Array.isArray(result) && this.accountability && this.accountability.app === true) {
result.push(
...filterItems(
appAccessMinimalPermissions.map((permission) => ({
...permission,
role: this.accountability!.role,
})),
query.filter
)
);
}
return result;
}
readByKey(keys: PrimaryKey[], query?: Query, action?: PermissionsAction): Promise<null | Partial<Item>[]>;
readByKey(key: PrimaryKey, query?: Query, action?: PermissionsAction): Promise<null | Partial<Item>>;
async readByKey(
key: PrimaryKey | PrimaryKey[],
query: Query = {},
action: PermissionsAction = 'read'
): Promise<null | Partial<Item> | Partial<Item>[]> {
const result = await super.readByKey(key as any, query, action);
if (Array.isArray(result) && this.accountability && this.accountability.app === true) {
result.push(
...filterItems(
appAccessMinimalPermissions.map((permission) => ({
...permission,
role: this.accountability!.role,
})),
query.filter
)
);
}
return result;
}
}

View File

@@ -61,12 +61,13 @@ export class RelationsService extends ItemsService {
if (relations === null) return null;
if (this.accountability === null || this.accountability?.admin === true) return relations;
const allowedCollections = await this.permissionsService.getAllowedCollections(
this.accountability?.role || null,
'read'
);
const allowedCollections = this.schema.permissions
.filter((permission) => {
return permission.action === 'read';
})
.map(({ collection }) => collection);
const allowedFields = await this.permissionsService.getAllowedFields(this.accountability?.role || null, 'read');
const allowedFields = this.permissionsService.getAllowedFields('read');
relations = toArray(relations);

View File

@@ -62,6 +62,7 @@ interface SpecificationSubService {
class OASService implements SpecificationSubService {
accountability: Accountability | null;
knex: Knex;
schema: SchemaOverview;
fieldsService: FieldsService;
collectionsService: CollectionsService;
@@ -81,6 +82,7 @@ class OASService implements SpecificationSubService {
) {
this.accountability = options.accountability || null;
this.knex = options.knex || database;
this.schema = options.schema;
this.fieldsService = fieldsService;
this.collectionsService = collectionsService;
@@ -91,10 +93,7 @@ class OASService implements SpecificationSubService {
const collections = await this.collectionsService.readByQuery();
const fields = await this.fieldsService.readAll();
const relations = (await this.relationsService.readByQuery({})) as Relation[];
const permissions: Permission[] = await this.knex
.select('*')
.from('directus_permissions')
.where({ role: this.accountability?.role || null });
const permissions = this.schema.permissions;
const tags = await this.generateTags(collections);
const paths = await this.generatePaths(permissions, tags);
@@ -104,7 +103,8 @@ class OASService implements SpecificationSubService {
openapi: '3.0.1',
info: {
title: 'Dynamic API Specification',
description: 'This is a dynamicly generated API specification for all endpoints existing on the current .',
description:
'This is a dynamically generated API specification for all endpoints existing on the current project.',
version: version,
},
servers: [

View File

@@ -27,28 +27,22 @@ export class UtilsService {
}
if (this.accountability?.admin !== true) {
const permissions = await this.knex
.select('fields')
.from('directus_permissions')
.where({
collection,
action: 'update',
role: this.accountability?.role || null,
})
.first();
const permissions = this.schema.permissions.find((permission) => {
return permission.collection === collection && permission.action === 'update';
});
if (!permissions) {
throw new ForbiddenException();
}
const allowedFields = permissions.fields.split(',');
const allowedFields = permissions.fields ?? [];
if (allowedFields[0] !== '*' && allowedFields.includes(sortField) === false) {
throw new ForbiddenException();
}
}
const primaryKeyField = this.schema[collection].primary;
const primaryKeyField = this.schema.tables[collection].primary;
// Make sure all rows have a sort value
const countResponse = await this.knex.count('* as count').from(collection).whereNull(sortField).first();

View File

@@ -2,6 +2,7 @@ export type Accountability = {
role: string | null;
user?: string | null;
admin?: boolean;
app?: boolean;
ip?: string;
userAgent?: string;

View File

@@ -3,9 +3,11 @@
*/
import { Permission } from './permissions';
import { Relation } from './relation';
import { Query } from './query';
import { Accountability } from './accountability';
import { SchemaOverview } from '@directus/schema/dist/types/overview';
import { SchemaOverview } from './schema';
export {};
@@ -19,7 +21,6 @@ declare global {
accountability?: Accountability;
singleton?: boolean;
permissions?: Permission;
}
}
}

View File

@@ -1,13 +1,14 @@
export type PermissionsAction = 'create' | 'read' | 'update' | 'delete' | 'comment' | 'explain';
export type Permission = {
id: number;
id?: number;
role: string | null;
collection: string;
action: PermissionsAction;
permissions: Record<string, any>;
validation: Record<string, any>;
validation: Record<string, any> | null;
limit: number | null;
presets: Record<string, any> | null;
fields: string[] | null;
system?: true;
};

View File

@@ -1,3 +1,15 @@
import { SchemaOverview as SO } from '@directus/schema/dist/types/overview';
import { Relation } from './relation';
import { Permission } from './permissions';
export type SchemaOverview = SO;
export type SchemaOverview = {
tables: SO;
relations: Relation[];
fields: {
id: number;
collection: string;
field: string;
special: string[];
}[];
permissions: Permission[];
};

View File

@@ -1,6 +1,5 @@
import { QueryBuilder } from 'knex';
import { Query, Filter, Relation, SchemaOverview } from '../types';
import Knex from 'knex';
import { clone, isPlainObject } from 'lodash';
import { systemRelationRows } from '../database/system-data/relations';
import { nanoid } from 'nanoid';
@@ -8,14 +7,13 @@ import getLocalType from './get-local-type';
import validate from 'uuid-validate';
export default async function applyQuery(
knex: Knex,
collection: string,
dbQuery: QueryBuilder,
query: Query,
schema: SchemaOverview
) {
if (query.filter) {
await applyFilter(knex, dbQuery, query.filter, collection);
await applyFilter(schema, dbQuery, query.filter, collection);
}
if (query.sort) {
@@ -39,7 +37,7 @@ export default async function applyQuery(
}
if (query.search) {
const columns = Object.values(schema[collection].columns);
const columns = Object.values(schema.tables[collection].columns);
dbQuery.andWhere(function () {
columns
@@ -64,8 +62,13 @@ export default async function applyQuery(
}
}
export async function applyFilter(knex: Knex, rootQuery: QueryBuilder, rootFilter: Filter, collection: string) {
const relations: Relation[] = [...(await knex.select('*').from('directus_relations')), ...systemRelationRows];
export async function applyFilter(
schema: SchemaOverview,
rootQuery: QueryBuilder,
rootFilter: Filter,
collection: string
) {
const relations: Relation[] = [...schema.relations, ...systemRelationRows];
const aliasMap: Record<string, string> = {};
@@ -75,6 +78,11 @@ export async function applyFilter(knex: Knex, rootQuery: QueryBuilder, rootFilte
function addJoins(dbQuery: QueryBuilder, filter: Filter, collection: string) {
for (const [key, value] of Object.entries(filter)) {
if (key === '_or' || key === '_and') {
// If the _or array contains an empty object (full permissions), we should short-circuit and ignore all other
// permission checks, as {} already matches full permissions.
if (key === '_or' && value.some((subFilter: Record<string, any>) => Object.keys(subFilter).length === 0))
continue;
value.forEach((subFilter: Record<string, any>) => {
addJoins(dbQuery, subFilter, collection);
});
@@ -137,8 +145,13 @@ export async function applyFilter(knex: Knex, rootQuery: QueryBuilder, rootFilte
function addWhereClauses(dbQuery: QueryBuilder, filter: Filter, collection: string, logical: 'and' | 'or' = 'and') {
for (const [key, value] of Object.entries(filter)) {
if (key === '_or' || key === '_and') {
// If the _or array contains an empty object (full permissions), we should short-circuit and ignore all other
// permission checks, as {} already matches full permissions.
if (key === '_or' && value.some((subFilter: Record<string, any>) => Object.keys(subFilter).length === 0))
continue;
/** @NOTE this callback function isn't called until Knex runs the query */
dbQuery.where((subQuery) => {
dbQuery[logical].where((subQuery) => {
value.forEach((subFilter: Record<string, any>) => {
addWhereClauses(subQuery, subFilter, collection, key === '_and' ? 'and' : 'or');
});

View File

@@ -0,0 +1,38 @@
import { Query } from '../types';
import generateJoi from './generate-joi';
/*
Note: Filtering is normally done through SQL in run-ast. This function can be used in case an already
existing array of items has to be filtered using the same filter syntax as used in the ast-to-sql flow
*/
export function filterItems(items: Record<string, any>[], filter: Query['filter']) {
if (!filter) return items;
return items.filter((item) => {
return passesFilter(item, filter);
});
function passesFilter(item: Record<string, any>, filter: Query['filter']): boolean {
if (!filter) return true;
if (Object.keys(filter)[0] === '_and') {
const subfilter = Object.values(filter)[0] as Query['filter'][];
return subfilter.every((subFilter) => {
return passesFilter(item, subFilter);
});
} else if (Object.keys(filter)[0] === '_or') {
const subfilter = Object.values(filter)[0] as Query['filter'][];
return subfilter.some((subFilter) => {
return passesFilter(item, subFilter);
});
} else {
const schema = generateJoi(filter);
const { error } = schema.validate(item);
return error === undefined;
}
}
}

View File

@@ -7,7 +7,6 @@ import {
NestedCollectionNode,
FieldNode,
Query,
Relation,
PermissionsAction,
Accountability,
SchemaOverview,
@@ -39,20 +38,14 @@ export default async function getASTFromQuery(
const accountability = options?.accountability;
const action = options?.action || 'read';
const knex = options?.knex || database;
/**
* we might not need al this info at all times, but it's easier to fetch it all once, than trying to fetch it for every
* requested field. @todo look into utilizing graphql/dataloader for this purpose
*/
const relations = [...(await knex.select<Relation[]>('*').from('directus_relations')), ...systemRelationRows];
const relations = [...schema.relations, ...systemRelationRows];
const permissions =
accountability && accountability.admin !== true
? await knex
.select<{ collection: string; fields: string }[]>('collection', 'fields')
.from('directus_permissions')
.where({ role: accountability.role, action: action })
? schema.permissions.filter((permission) => {
return permission.action === action;
})
: null;
const ast: AST = {
@@ -159,7 +152,7 @@ export default async function getASTFromQuery(
children: {},
query: {},
relatedKey: {},
parentKey: schema[parentCollection].primary,
parentKey: schema.tables[parentCollection].primary,
fieldKey: relationalField,
relation: relation,
};
@@ -171,7 +164,7 @@ export default async function getASTFromQuery(
);
child.query[relatedCollection] = {};
child.relatedKey[relatedCollection] = schema[relatedCollection].primary;
child.relatedKey[relatedCollection] = schema.tables[relatedCollection].primary;
}
} else if (relatedCollection) {
if (permissions && permissions.some((permission) => permission.collection === relatedCollection) === false) {
@@ -182,8 +175,8 @@ export default async function getASTFromQuery(
type: relationType,
name: relatedCollection,
fieldKey: relationalField,
parentKey: schema[parentCollection].primary,
relatedKey: schema[relatedCollection].primary,
parentKey: schema.tables[parentCollection].primary,
relatedKey: schema.tables[relatedCollection].primary,
relation: relation,
query: deep?.[relationalField] || {},
children: await parseFields(relatedCollection, nestedFields as string[]),
@@ -206,9 +199,7 @@ export default async function getASTFromQuery(
let allowedFields = fieldsInCollection;
if (permissions) {
const permittedFields = permissions
.find((permission) => parentCollection === permission.collection)
?.fields?.split(',');
const permittedFields = permissions.find((permission) => parentCollection === permission.collection)?.fields;
if (permittedFields) allowedFields = permittedFields;
}
@@ -294,9 +285,9 @@ export default async function getASTFromQuery(
}
async function getFieldsInCollection(collection: string) {
const columns = Object.keys(schema[collection].columns);
const columns = Object.keys(schema.tables[collection].columns);
const fields = [
...(await knex.select('field').from('directus_fields').where({ collection })).map((field) => field.field),
...schema.fields.filter((field) => field.collection === collection).map((field) => field.field),
...systemFieldRows.filter((fieldMeta) => fieldMeta.collection === collection).map((fieldMeta) => fieldMeta.field),
];

View File

@@ -2,7 +2,7 @@ import getLocalType from './get-local-type';
import { Column } from '@directus/schema/dist/types/column';
import { SchemaOverview } from '../types';
export default function getDefaultValue(column: SchemaOverview[string]['columns'][string] | Column) {
export default function getDefaultValue(column: SchemaOverview['tables'][string]['columns'][string] | Column) {
const type = getLocalType(column);
let defaultValue = column.default_value || null;

View File

@@ -81,7 +81,7 @@ const localTypeMap: Record<string, { type: typeof types[number]; useTimezone?: b
};
export default function getLocalType(
column: SchemaOverview[string]['columns'][string] | Column,
column: SchemaOverview['tables'][string]['columns'][string] | Column,
field?: FieldMeta
): typeof types[number] | 'unknown' {
const type = localTypeMap[column.data_type.toLowerCase().split('(')[0]];

View File

@@ -0,0 +1,69 @@
import { appAccessMinimalPermissions } from '../database/system-data/app-access-permissions';
import { Accountability, SchemaOverview, Permission } from '../types';
import database, { schemaInspector } from '../database';
import logger from '../logger';
import { mergePermissions } from './merge-permissions';
export async function getSchema(accountability?: Accountability): Promise<SchemaOverview> {
const schemaOverview = await schemaInspector.overview();
for (const [collection, info] of Object.entries(schemaOverview)) {
if (!info.primary) {
logger.warn(`Collection "${collection}" doesn't have a primary key column and will be ignored`);
delete schemaOverview[collection];
}
}
const relations = await database.select('*').from('directus_relations');
const fields = await database
.select<{ id: number; collection: string; field: string; special: string }[]>(
'id',
'collection',
'field',
'special'
)
.from('directus_fields');
let permissions: Permission[] = [];
if (accountability && accountability.admin !== true) {
const permissionsForRole = await database
.select('*')
.from('directus_permissions')
.where({ role: accountability.role });
permissions = permissionsForRole.map((permissionRaw) => {
if (permissionRaw.permissions && typeof permissionRaw.permissions === 'string') {
permissionRaw.permissions = JSON.parse(permissionRaw.permissions);
}
if (permissionRaw.validation && typeof permissionRaw.validation === 'string') {
permissionRaw.validation = JSON.parse(permissionRaw.validation);
}
if (permissionRaw.fields && typeof permissionRaw.fields === 'string') {
permissionRaw.fields = permissionRaw.fields.split(',');
}
return permissionRaw;
});
if (accountability.app === true) {
permissions = mergePermissions(
permissions,
appAccessMinimalPermissions.map((perm) => ({ ...perm, role: accountability.role }))
);
}
}
return {
tables: schemaOverview,
relations: relations,
fields: fields.map((transform) => ({
...transform,
special: transform.special?.split(','),
})),
permissions: permissions,
};
}

View File

@@ -0,0 +1,88 @@
import { Permission } from '../types';
import { merge, omit } from 'lodash';
export function mergePermissions(...permissions: Permission[][]): Permission[] {
const allPermissions = permissions.flat();
const mergedPermissions = allPermissions
.reduce((acc, val) => {
const key = `${val.collection}__${val.action}__${val.role || '$PUBLIC'}`;
const current = acc.get(key);
acc.set(key, current ? mergePerm(current, val) : val);
return acc;
}, new Map())
.values();
const result = Array.from(mergedPermissions).map((perm) => {
return omit(perm, ['id', 'system']) as Permission;
});
return result;
}
function mergePerm(currentPerm: Permission, newPerm: Permission) {
let permissions = currentPerm.permissions;
let validation = currentPerm.validation;
let fields = currentPerm.fields;
let presets = currentPerm.presets;
let limit = currentPerm.limit;
if (newPerm.permissions) {
if (currentPerm.permissions && Object.keys(currentPerm.permissions)[0] === '_or') {
permissions = {
_or: [...currentPerm.permissions._or, newPerm.permissions],
};
} else if (currentPerm.permissions) {
permissions = {
_or: [currentPerm.permissions, newPerm.permissions],
};
} else {
permissions = {
_or: [newPerm.permissions],
};
}
}
if (newPerm.validation) {
if (currentPerm.validation && Object.keys(currentPerm.validation)[0] === '_or') {
validation = {
_or: [...currentPerm.validation._or, newPerm.validation],
};
} else if (currentPerm.validation) {
validation = {
_or: [currentPerm.validation, newPerm.validation],
};
} else {
validation = {
_or: [newPerm.validation],
};
}
}
if (newPerm.fields) {
if (Array.isArray(currentPerm.fields)) {
fields = [...new Set([...currentPerm.fields, ...newPerm.fields])];
} else {
fields = newPerm.fields;
}
if (fields.includes('*')) fields = ['*'];
}
if (newPerm.presets) {
presets = merge({}, presets, newPerm.presets);
}
if (newPerm.limit && newPerm.limit > (currentPerm.limit || 0)) {
limit = newPerm.limit;
}
return {
...currentPerm,
permissions,
validation,
fields,
presets,
limit,
};
}

View File

@@ -34,6 +34,7 @@
},
"gitHead": "4476da28dbbc2824e680137aa28b2b91b5afabec",
"dependencies": {
"directus": "file:../api",
"@directus/docs": "file:../docs",
"@directus/format-title": "file:../packages/format-title"
},

View File

@@ -120,7 +120,7 @@ hr {
margin-bottom: 56px;
text-align: center;
}
hr: after {
hr:after {
content: "...";
font-size: 28px;
letter-spacing: 16px;

View File

@@ -11,6 +11,7 @@ role_name: Role Name
db_only_click_to_configure: 'Database Only: Click to Configure '
show_archived_items: Show Archived Items
required: Required
required_for_app_access: Required for App Access
requires_value: Requires value
create_preset: Create Preset
create_role: Create Role
@@ -52,8 +53,14 @@ archive_confirm: Are you sure you want to archive this item?
archive_confirm_count: >-
No Items Selected | Are you sure you want to archive this item? | Are you sure you want to archive these {count}
items?
reset_system_permissions: Reset System Permissions
reset_system_permissions_copy: Reset all all system permissions to their defaults
reset_system_permissions_to: 'Reset System Permissions to:'
reset_system_permissions_copy:
This action will overwrite any custom permissions you may have applied to the system collections. Are you sure?
the_following_are_minimum_permissions:
The following are minimum permissions required when "App Access" is enabled. You can extend permissions beyond this,
but not below.
app_access_minimum: App Access Minimum
recommended_defaults: Recommended Defaults
unarchive: Unarchive
unarchive_confirm: Are you sure you want to unarchive this item?
nested_files_folders_will_be_moved: Nested files and folders will be moved one level up.

View File

@@ -124,7 +124,7 @@
rounded
icon
:loading="saving"
:disabled="isSavable === false"
:disabled="isSavable === false || saveAllowed === false"
v-tooltip.bottom="saveAllowed ? $t('save') : $t('not_allowed')"
@click="saveAndQuit"
>
@@ -175,7 +175,7 @@
<div class="page-description" v-html="marked($t('page_help_collections_item'))" />
</sidebar-detail>
<revisions-drawer-detail
v-if="isNew === false && _primaryKey"
v-if="isNew === false && _primaryKey && hasRevisionsPermissions"
:collection="collection"
:primary-key="_primaryKey"
ref="revisionsDrawerDetail"
@@ -195,26 +195,19 @@ import { defineComponent, computed, toRefs, ref } from '@vue/composition-api';
import Vue from 'vue';
import CollectionsNavigation from '../components/navigation.vue';
import router from '../../../router';
import router from '@/router';
import CollectionsNotFound from './not-found.vue';
import useCollection from '../../../composables/use-collection';
import RevisionsDrawerDetail from '../../../views/private/components/revisions-drawer-detail';
import CommentsSidebarDetail from '../../../views/private/components/comments-sidebar-detail';
import useItem from '../../../composables/use-item';
import SaveOptions from '../../../views/private/components/save-options';
import i18n from '../../../lang';
import useCollection from '@/composables/use-collection';
import RevisionsDrawerDetail from '@/views/private/components/revisions-drawer-detail';
import CommentsSidebarDetail from '@/views/private/components/comments-sidebar-detail';
import useItem from '@/composables/use-item';
import SaveOptions from '@/views/private/components/save-options';
import i18n from '@/lang';
import marked from 'marked';
import useShortcut from '../../../composables/use-shortcut';
import useShortcut from '@/composables/use-shortcut';
import { NavigationGuard } from 'vue-router';
import { useUserStore, usePermissionsStore } from '../../../stores';
import generateJoi from '../../../utils/generate-joi';
import { cloneDeep } from 'lodash';
import { Field } from '../../../types';
import { usePermissions } from '../../../composables/use-permissions';
type Values = {
[field: string]: any;
};
import { usePermissionsStore } from '@/stores';
import { usePermissions } from '@/composables/use-permissions';
export default defineComponent({
name: 'collections-item',
@@ -241,9 +234,14 @@ export default defineComponent({
},
setup(props) {
const form = ref<HTMLElement>();
const userStore = useUserStore();
const permissionsStore = usePermissionsStore();
const hasRevisionsPermissions = computed(() => {
return !!permissionsStore.state.permissions.find(
(permission) => permission.collection === 'directus_revisions' && permission.action === 'read'
);
});
const { collection, primaryKey } = toRefs(props);
const { breadcrumb } = useBreadcrumb();
@@ -382,6 +380,7 @@ export default defineComponent({
fields,
isSingleton,
_primaryKey,
hasRevisionsPermissions,
};
function useBreadcrumb() {
@@ -488,7 +487,7 @@ export default defineComponent({
</script>
<style lang="scss" scoped>
@import '../../../styles/mixins/breakpoint';
@import '@/styles/mixins/breakpoint';
.action-delete {
--v-button-background-color: var(--danger-25);

View File

@@ -157,7 +157,7 @@
<template #sidebar>
<file-info-sidebar-detail :file="item" />
<revisions-drawer-detail
v-if="isBatch === false && isNew === false"
v-if="isBatch === false && isNew === false && hasRevisionsPermissions"
collection="directus_files"
:primary-key="primaryKey"
ref="revisionsDrawerDetail"
@@ -176,33 +176,26 @@
<script lang="ts">
import { defineComponent, computed, toRefs, ref, watch } from '@vue/composition-api';
import FilesNavigation from '../components/navigation.vue';
import { i18n } from '../../../lang';
import router from '../../../router';
import RevisionsDrawerDetail from '../../../views/private/components/revisions-drawer-detail';
import CommentsSidebarDetail from '../../../views/private/components/comments-sidebar-detail';
import useItem from '../../../composables/use-item';
import SaveOptions from '../../../views/private/components/save-options';
import FilePreview from '../../../views/private/components/file-preview';
import ImageEditor from '../../../views/private/components/image-editor';
import { nanoid } from 'nanoid';
import FileLightbox from '../../../views/private/components/file-lightbox';
import { useFieldsStore } from '../../../stores/';
import { Field } from '../../../types';
import { i18n } from '@/lang';
import router from '@/router';
import RevisionsDrawerDetail from '@/views/private/components/revisions-drawer-detail';
import CommentsSidebarDetail from '@/views/private/components/comments-sidebar-detail';
import useItem from '@/composables/use-item';
import SaveOptions from '@/views/private/components/save-options';
import FilePreview from '@/views/private/components/file-preview';
import ImageEditor from '@/views/private/components/image-editor';
import { Field } from '@/types';
import FileInfoSidebarDetail from '../components/file-info-sidebar-detail.vue';
import useFormFields from '../../../composables/use-form-fields';
import FolderPicker from '../components/folder-picker.vue';
import api, { addTokenToURL } from '../../../api';
import { getRootPath } from '../../../utils/get-root-path';
import api, { addTokenToURL } from '@/api';
import { getRootPath } from '@/utils/get-root-path';
import FilesNotFound from './not-found.vue';
import useShortcut from '../../../composables/use-shortcut';
import useShortcut from '@/composables/use-shortcut';
import ReplaceFile from '../components/replace-file.vue';
import { usePermissions } from '../../../composables/use-permissions';
import { notify } from '../../../utils/notify';
import { unexpectedError } from '../../../utils/unexpected-error';
type Values = {
[field: string]: any;
};
import { usePermissions } from '@/composables/use-permissions';
import { notify } from '@/utils/notify';
import { unexpectedError } from '@/utils/unexpected-error';
import { usePermissionsStore } from '@/stores';
export default defineComponent({
name: 'files-item',
@@ -240,9 +233,16 @@ export default defineComponent({
const form = ref<HTMLElement>();
const { primaryKey } = toRefs(props);
const { breadcrumb } = useBreadcrumb();
const fieldsStore = useFieldsStore();
const replaceFileDialogActive = ref(false);
const permissionsStore = usePermissionsStore();
const hasRevisionsPermissions = computed(() => {
return !!permissionsStore.state.permissions.find(
(permission) => permission.collection === 'directus_revisions' && permission.action === 'read'
);
});
const revisionsDrawerDetail = ref<Vue | null>(null);
const {
@@ -344,6 +344,7 @@ export default defineComponent({
updateAllowed,
fields,
fieldsFiltered,
hasRevisionsPermissions,
};
function useBreadcrumb() {

View File

@@ -33,7 +33,7 @@
import { defineComponent, ref } from '@vue/composition-api';
import api from '@/api';
import router from '@/router';
import { permissions } from './app-required-permissions';
import { appRecommendedPermissions } from './app-recommended-permissions';
import { unexpectedError } from '@/utils/unexpected-error';
export default defineComponent({
@@ -64,7 +64,7 @@ export default defineComponent({
if (appAccess.value === true && adminAccess.value === false) {
await api.post(
'/permissions',
permissions.map((permission) => ({
appRecommendedPermissions.map((permission) => ({
...permission,
role: roleResponse.data.data.id,
}))

View File

@@ -0,0 +1,56 @@
import { Permission } from '@/types';
export const appRecommendedPermissions: Partial<Permission>[] = [
{
collection: 'directus_files',
action: 'create',
permissions: {},
fields: ['*'],
},
{
collection: 'directus_files',
action: 'read',
permissions: {},
fields: ['*'],
},
{
collection: 'directus_files',
action: 'update',
permissions: {},
fields: ['*'],
},
{
collection: 'directus_files',
action: 'delete',
permissions: {},
fields: ['*'],
},
{
collection: 'directus_folders',
action: 'create',
permissions: {},
fields: ['*'],
},
{
collection: 'directus_folders',
action: 'read',
permissions: {},
fields: ['*'],
},
{
collection: 'directus_folders',
action: 'update',
permissions: {},
fields: ['*'],
},
{
collection: 'directus_folders',
action: 'delete',
permissions: {},
},
{
collection: 'directus_users',
action: 'read',
permissions: {},
},
];

View File

@@ -1,129 +0,0 @@
import { Permission } from '@/types';
export const appRequiredPermissions: Partial<Permission>[] = [
{
collection: 'directus_activity',
action: 'read',
permissions: {},
fields: ['*'],
},
{
collection: 'directus_collections',
action: 'read',
permissions: {},
fields: ['*'],
},
{
collection: 'directus_fields',
action: 'read',
permissions: {},
fields: ['*'],
},
{
collection: 'directus_presets',
action: 'create',
permissions: {},
fields: ['*'],
},
{
collection: 'directus_presets',
action: 'read',
permissions: {},
fields: ['*'],
},
{
collection: 'directus_presets',
action: 'update',
permissions: {},
fields: ['*'],
},
{
collection: 'directus_presets',
action: 'delete',
permissions: {},
fields: ['*'],
},
{
collection: 'directus_relations',
action: 'read',
permissions: {},
fields: ['*'],
},
{
collection: 'directus_revisions',
action: 'read',
permissions: {},
fields: ['*'],
},
{
collection: 'directus_users',
action: 'read',
permissions: {},
fields: ['*'],
},
{
collection: 'directus_roles',
action: 'read',
permissions: {},
fields: ['*'],
},
{
collection: 'directus_settings',
action: 'read',
permissions: {},
fields: ['*'],
},
];
export const appRecommendedPermissions: Partial<Permission>[] = [
{
collection: 'directus_files',
action: 'create',
permissions: {},
fields: ['*'],
},
{
collection: 'directus_files',
action: 'read',
permissions: {},
fields: ['*'],
},
{
collection: 'directus_files',
action: 'update',
permissions: {},
fields: ['*'],
},
{
collection: 'directus_files',
action: 'delete',
permissions: {},
fields: ['*'],
},
{
collection: 'directus_folders',
action: 'create',
permissions: {},
fields: ['*'],
},
{
collection: 'directus_folders',
action: 'read',
permissions: {},
fields: ['*'],
},
{
collection: 'directus_folders',
action: 'update',
permissions: {},
fields: ['*'],
},
{
collection: 'directus_folders',
action: 'delete',
permissions: {},
fields: ['*'],
},
];
export const permissions = [...appRequiredPermissions, ...appRecommendedPermissions];

View File

@@ -15,6 +15,7 @@
:role="role"
:permissions="permissions"
:loading="isLoading('create')"
:app-minimal="appMinimal && appMinimal.find((p) => p.action === 'create')"
/>
<permissions-overview-toggle
action="read"
@@ -22,6 +23,7 @@
:role="role"
:permissions="permissions"
:loading="isLoading('read')"
:app-minimal="appMinimal && appMinimal.find((p) => p.action === 'read')"
/>
<permissions-overview-toggle
action="update"
@@ -29,6 +31,7 @@
:role="role"
:permissions="permissions"
:loading="isLoading('update')"
:app-minimal="appMinimal && appMinimal.find((p) => p.action === 'update')"
/>
<permissions-overview-toggle
action="delete"
@@ -36,13 +39,13 @@
:role="role"
:permissions="permissions"
:loading="isLoading('delete')"
:app-minimal="appMinimal && appMinimal.find((p) => p.action === 'delete')"
/>
</div>
</template>
<script lang="ts">
import { defineComponent, PropType, ref, inject, toRefs } from '@vue/composition-api';
import api from '@/api';
import { defineComponent, PropType, toRefs } from '@vue/composition-api';
import { Collection, Permission } from '@/types';
import PermissionsOverviewToggle from './permissions-overview-toggle.vue';
import useUpdatePermissions from '../composables/use-update-permissions';
@@ -66,6 +69,10 @@ export default defineComponent({
type: Array as PropType<number[]>,
required: true,
},
appMinimal: {
type: [Boolean, Array] as PropType<false | Partial<Permission>[]>,
default: false,
},
},
setup(props) {
const { collection, role, permissions } = toRefs(props);

View File

@@ -1,17 +1,28 @@
<template>
<div class="permissions-overview-toggle">
<v-menu show-arrow>
<div
class="permissions-overview-toggle"
:class="{ 'has-app-minimal': !!appMinimal }"
v-tooltip="appMinimal && $t('required_for_app_access')"
>
<v-icon v-if="appMinimalLevel === 'full'" name="check" class="all app-minimal" />
<v-menu show-arrow v-else>
<template #activator="{ toggle }">
<div>
<v-progress-circular indeterminate v-if="loading || saving" small />
<v-icon v-else-if="permissionLevel === 'all'" @click="toggle" name="check" class="all" />
<v-icon v-else-if="permissionLevel === 'custom'" @click="toggle" name="rule" class="custom" />
<v-icon
v-else-if="appMinimalLevel === 'partial' || permissionLevel === 'custom'"
@click="toggle"
name="rule"
class="custom"
/>
<v-icon v-else-if="permissionLevel === 'none'" @click="toggle" name="block" class="none" />
</div>
</template>
<v-list>
<v-list-item @click="setFullAccess(action)">
<v-list-item :disabled="permissionLevel === 'all'" @click="setFullAccess(action)">
<v-list-item-icon>
<v-icon name="check" />
</v-list-item-icon>
@@ -20,7 +31,11 @@
</v-list-item-content>
</v-list-item>
<v-list-item @click="setNoAccess(action)">
<v-list-item
v-if="!!appMinimalLevel === false"
:disabled="permissionLevel === 'none'"
@click="setNoAccess(action)"
>
<v-list-item-icon>
<v-icon name="block" />
</v-list-item-icon>
@@ -53,7 +68,6 @@ import { Collection, Permission } from '@/types';
import api from '@/api';
import router from '@/router';
import useUpdatePermissions from '../composables/use-update-permissions';
import { unexpectedError } from '@/utils/unexpected-error';
export default defineComponent({
props: {
@@ -77,6 +91,10 @@ export default defineComponent({
type: Boolean,
default: false,
},
appMinimal: {
type: [Boolean, Object] as PropType<false | Partial<Permission>>,
default: false,
},
},
setup(props) {
const { collection, role, permissions } = toRefs(props);
@@ -104,7 +122,13 @@ export default defineComponent({
const refresh = inject<() => Promise<void>>('refresh-permissions');
return { permissionLevel, saving, setFullAccess, setNoAccess, openPermissions };
const appMinimalLevel = computed(() => {
if (props.appMinimal === false) return null;
if (Object.keys(props.appMinimal).length === 2) return 'full';
return 'partial';
});
return { permissionLevel, saving, setFullAccess, setNoAccess, openPermissions, appMinimalLevel };
async function openPermissions() {
// If this collection isn't "managed" yet, make sure to add it to directus_collections first
@@ -139,6 +163,17 @@ export default defineComponent({
<style lang="scss" scoped>
.permissions-overview-toggle {
position: relative;
&.has-app-minimal::before {
position: absolute;
top: -4px;
left: -4px;
width: calc(100% + 8px);
height: calc(100% + 8px);
background-color: var(--background-highlight);
border-radius: 50%;
content: '';
}
}
.all {
@@ -152,4 +187,8 @@ export default defineComponent({
.none {
--v-icon-color: var(--danger);
}
.app-minimal {
cursor: not-allowed;
}
</style>

View File

@@ -31,29 +31,31 @@
:role="role"
:permissions="permissions.filter((p) => p.collection === collection.collection)"
:refreshing="refreshing"
:app-minimal="appAccess && appMinimalPermissions.filter((p) => p.collection === collection.collection)"
/>
</div>
</transition-expand>
<button v-if="systemVisible" class="reset-toggle" @click="resetActive = true">
{{ $t('reset_system_permissions') }}
</button>
<span class="reset-toggle" v-if="systemVisible && appAccess">
{{ $t('reset_system_permissions_to') }}
<button @click="resetActive = 'minimum'">{{ $t('app_access_minimum') }}</button>
/
<button @click="resetActive = 'recommended'">{{ $t('recommended_defaults') }}</button>
</span>
</div>
<router-view
name="permissionsDetail"
:role-key="role"
:permission-key="permission"
@refresh="refreshPermission"
/>
<router-view name="permissionsDetail" :role-key="role" :permission-key="permission" @refresh="refreshPermission" />
<v-dialog v-model="resetActive" @esc="resetActive = false">
<v-dialog @toggle="resetActive = false" :active="!!resetActive" @esc="resetActive = false">
<v-card>
<v-card-title>{{ $t('reset_system_permissions') }}</v-card-title>
<v-card-text>{{ $t('reset_system_permissions_copy') }}</v-card-text>
<v-card-title>
{{ $t('reset_system_permissions_copy') }}
</v-card-title>
<v-card-actions>
<v-button @click="resetActive = false" secondary>{{ $t('cancel') }}</v-button>
<v-button @click="resetSystemPermissions" :loading="resetting">{{ $t('reset') }}</v-button>
<v-button @click="resetSystemPermissions(resetActive === 'recommended')" :loading="resetting">
{{ $t('reset') }}
</v-button>
</v-card-actions>
</v-card>
</v-dialog>
@@ -67,9 +69,11 @@ import PermissionsOverviewHeader from './permissions-overview-header.vue';
import PermissionsOverviewRow from './permissions-overview-row.vue';
import { Permission } from '@/types';
import api from '@/api';
import { permissions as appRequiredPermissions } from '../../app-required-permissions';
import { appRecommendedPermissions } from '../../app-recommended-permissions';
import { unexpectedError } from '@/utils/unexpected-error';
import appMinimalPermissions from 'directus/dist/database/system-data/app-access-permissions/app-access-permissions.yaml';
export default defineComponent({
components: { PermissionsOverviewHeader, PermissionsOverviewRow },
props: {
@@ -91,15 +95,11 @@ export default defineComponent({
const collectionsStore = useCollectionsStore();
const regularCollections = computed(() =>
collectionsStore.state.collections.filter(
(collection) => collection.collection.startsWith('directus_') === false
)
collectionsStore.state.collections.filter((collection) => collection.collection.startsWith('directus_') === false)
);
const systemCollections = computed(() =>
collectionsStore.state.collections.filter(
(collection) => collection.collection.startsWith('directus_') === true
)
collectionsStore.state.collections.filter((collection) => collection.collection.startsWith('directus_') === true)
);
const systemVisible = ref(false);
@@ -125,6 +125,7 @@ export default defineComponent({
resetSystemPermissions,
resetting,
resetError,
appMinimalPermissions,
};
function usePermissions() {
@@ -177,13 +178,13 @@ export default defineComponent({
}
function useReset() {
const resetActive = ref(false);
const resetActive = ref<string | boolean>(false);
const resetting = ref(false);
const resetError = ref<any>(null);
return { resetActive, resetSystemPermissions, resetting, resetError };
async function resetSystemPermissions() {
async function resetSystemPermissions(useRecommended: boolean) {
resetting.value = true;
const toBeDeleted = permissions.value
@@ -195,10 +196,10 @@ export default defineComponent({
await api.delete(`/permissions/${toBeDeleted.join(',')}`);
}
if (props.role !== null && props.appAccess === true) {
if (props.role !== null && props.appAccess === true && useRecommended === true) {
await api.post(
'/permissions',
appRequiredPermissions.map((permission) => ({
appRecommendedPermissions.map((permission) => ({
...permission,
role: props.role,
}))
@@ -255,10 +256,15 @@ export default defineComponent({
display: block;
margin: 8px auto;
color: var(--foreground-subdued);
transition: color var(--fast) var(--transition);
text-align: center;
&:hover {
color: var(--foreground);
button {
color: var(--primary) !important;
transition: color var(--fast) var(--transition);
}
button:hover {
color: var(--foreground-normal) !important;
}
}
</style>

View File

@@ -7,11 +7,7 @@
</v-button>
</template>
<template #actions>
<v-dialog
v-model="confirmDelete"
v-if="[1, 2].includes(+primaryKey) === false"
@esc="confirmDelete = false"
>
<v-dialog v-model="confirmDelete" v-if="[1, 2].includes(+primaryKey) === false" @esc="confirmDelete = false">
<template #activator="{ on }">
<v-button
rounded
@@ -71,6 +67,7 @@
<v-notice v-if="adminEnabled" type="info">
{{ $t('admins_have_all_permissions') }}
</v-notice>
<permissions-overview v-else :role="primaryKey" :permission="permissionKey" :app-access="appAccess" />
<v-form
@@ -102,10 +99,6 @@ import RoleInfoSidebarDetail from './components/role-info-sidebar-detail.vue';
import PermissionsOverview from './components/permissions-overview.vue';
import UsersInvite from '@/views/private/components/users-invite';
type Values = {
[field: string]: any;
};
export default defineComponent({
name: 'roles-item',
components: { SettingsNavigation, RevisionsDrawerDetail, RoleInfoSidebarDetail, PermissionsOverview, UsersInvite },

View File

@@ -11,6 +11,12 @@
<p class="type-label">{{ $tc('field', 0) }}</p>
<interface-checkboxes v-model="fields" type="json" :choices="fieldsInCollection" />
<div v-if="appMinimal" class="app-minimal">
<v-divider />
<v-notice type="warning">{{ $t('the_following_are_minimum_permissions') }}</v-notice>
<pre class="app-minimal-preview">{{ appMinimal }}</pre>
</div>
</div>
</template>
@@ -30,6 +36,10 @@ export default defineComponent({
type: Object as PropType<Role>,
default: null,
},
appMinimal: {
type: Object as PropType<Partial<Permission>>,
default: undefined,
},
},
setup(props, { emit }) {
const fieldsStore = useFieldsStore();
@@ -92,4 +102,21 @@ export default defineComponent({
.v-notice {
margin-bottom: 36px;
}
.app-minimal {
.v-divider {
margin: 24px 0;
}
.v-notice {
margin-bottom: 24px;
}
.app-minimal-preview {
padding: 16px;
font-family: var(--family-monospace);
background-color: var(--background-subdued);
border-radius: var(--border-radius);
}
}
</style>

View File

@@ -10,6 +10,12 @@
</v-notice>
<interface-code v-model="permissions" language="json" type="json" />
<div v-if="appMinimal" class="app-minimal">
<v-divider />
<v-notice type="warning">{{ $t('the_following_are_minimum_permissions') }}</v-notice>
<pre class="app-minimal-preview">{{ appMinimal }}</pre>
</div>
</div>
</template>
@@ -28,6 +34,10 @@ export default defineComponent({
type: Object as PropType<Role>,
default: null,
},
appMinimal: {
type: Object as PropType<Partial<Permission>>,
default: undefined,
},
},
setup(props, { emit }) {
const _permission = useSync(props, 'permission', emit);
@@ -53,4 +63,21 @@ export default defineComponent({
.v-notice {
margin-bottom: 36px;
}
.app-minimal {
.v-divider {
margin: 24px 0;
}
.v-notice {
margin-bottom: 24px;
}
.app-minimal-preview {
padding: 16px;
font-family: var(--family-monospace);
background-color: var(--background-subdued);
border-radius: var(--border-radius);
}
}
</style>

View File

@@ -11,10 +11,30 @@
</template>
<div class="content" v-if="!loading">
<permissions v-if="currentTab[0] === 'permissions'" :permission.sync="permission" :role="role" />
<fields v-if="currentTab[0] === 'fields'" :permission.sync="permission" :role="role" />
<validation v-if="currentTab[0] === 'validation'" :permission.sync="permission" :role="role" />
<presets v-if="currentTab[0] === 'presets'" :permission.sync="permission" :role="role" />
<permissions
v-if="currentTab[0] === 'permissions'"
:permission.sync="permission"
:role="role"
:app-minimal="appMinimal && appMinimal.permissions"
/>
<fields
v-if="currentTab[0] === 'fields'"
:permission.sync="permission"
:role="role"
:app-minimal="appMinimal && appMinimal.fields"
/>
<validation
v-if="currentTab[0] === 'validation'"
:permission.sync="permission"
:role="role"
:app-minimal="appMinimal && appMinimal.validation"
/>
<presets
v-if="currentTab[0] === 'presets'"
:permission.sync="permission"
:role="role"
:app-minimal="appMinimal && appMinimal.presets"
/>
</div>
<template #actions v-if="!loading">
@@ -24,10 +44,10 @@
</template>
<script lang="ts">
import { defineComponent, ref, reactive, computed, watch } from '@vue/composition-api';
import { defineComponent, ref, computed, watch } from '@vue/composition-api';
import api from '@/api';
import { Permission, Role } from '@/types';
import { useFieldsStore, useCollectionsStore } from '@/stores/';
import { useCollectionsStore } from '@/stores/';
import router from '@/router';
import i18n from '@/lang';
import Actions from './components/actions.vue';
@@ -39,6 +59,8 @@ import Validation from './components/validation.vue';
import Presets from './components/presets.vue';
import { unexpectedError } from '@/utils/unexpected-error';
import appMinimalPermissions from 'directus/dist/database/system-data/app-access-permissions/app-access-permissions.yaml';
export default defineComponent({
components: { Actions, Tabs, Permissions, Fields, Validation, Presets },
props: {
@@ -88,8 +110,7 @@ export default defineComponent({
tabs.push({
text: i18n.t('item_permissions'),
value: 'permissions',
hasValue:
permission.value.permissions !== null && Object.keys(permission.value.permissions).length > 0,
hasValue: permission.value.permissions !== null && Object.keys(permission.value.permissions).length > 0,
});
}
@@ -105,8 +126,7 @@ export default defineComponent({
tabs.push({
text: i18n.t('field_validation'),
value: 'validation',
hasValue:
permission.value.validation !== null && Object.keys(permission.value.validation).length > 0,
hasValue: permission.value.validation !== null && Object.keys(permission.value.validation).length > 0,
});
}
@@ -138,7 +158,15 @@ export default defineComponent({
{ immediate: true }
);
return { permission, role, loading, modalTitle, tabs, currentTab, currentTabInfo };
const appMinimal = computed(() => {
if (!permission.value) return null;
return appMinimalPermissions.find(
(p: Partial<Permission>) =>
p.collection === permission.value!.collection && p.action === permission.value!.action
);
});
return { permission, role, loading, modalTitle, tabs, currentTab, currentTabInfo, appMinimal };
async function load() {
loading.value = true;

View File

@@ -18,10 +18,9 @@
</template>
<script lang="ts">
import { defineComponent, computed, toRefs, ref } from '@vue/composition-api';
import { defineComponent } from '@vue/composition-api';
import SettingsNavigation from '../../components/navigation.vue';
import router from '@/router';
import PermissionsOverview from './item/components/permissions-overview.vue';
export default defineComponent({

View File

@@ -34,6 +34,7 @@ export default defineModule(({ i18n }) => ({
const permission = permissions.find(
(permission) => permission.collection === 'directus_users' && permission.action === 'read'
);
return !!permission;
},
}));

View File

@@ -11,15 +11,15 @@
</template>
<template #actions>
<v-dialog v-model="confirmDelete" @esc="confirmDelete = false">
<v-dialog v-model="confirmDelete" @esc="confirmDelete = false" :disabled="deleteAllowed === false">
<template #activator="{ on }">
<v-button
rounded
icon
class="action-delete"
:disabled="item === null"
v-tooltip.bottom="deleteAllowed ? $t('delete') : $t('not_allowed')"
:disabled="item === null || deleteAllowed !== true"
@click="on"
v-tooltip.bottom="$t('delete')"
>
<v-icon name="delete" outline />
</v-button>
@@ -77,9 +77,9 @@
rounded
icon
:loading="saving"
:disabled="hasEdits === false"
:disabled="hasEdits === false || saveAllowed === false"
v-tooltip.bottom="saveAllowed ? $t('save') : $t('not_allowed')"
@click="saveAndQuit"
v-tooltip.bottom="$t('save')"
>
<v-icon name="check" />
@@ -125,6 +125,7 @@
<v-form
ref="form"
:disabled="isNew ? createAllowed === false : updateAllowed === false"
:fields="formFields"
:loading="loading"
:initial-values="item"
@@ -150,7 +151,7 @@
<template #sidebar>
<user-info-sidebar-detail :is-new="isNew" :user="item" />
<revisions-drawer-detail
v-if="isBatch === false && isNew === false"
v-if="isBatch === false && isNew === false && hasRevisionsPermissions"
collection="directus_users"
:primary-key="primaryKey"
ref="revisionsDrawerDetail"
@@ -168,28 +169,25 @@
import { defineComponent, computed, toRefs, ref, watch } from '@vue/composition-api';
import UsersNavigation from '../components/navigation.vue';
import { i18n, setLanguage } from '../../../lang';
import router from '../../../router';
import RevisionsDrawerDetail from '../../../views/private/components/revisions-drawer-detail';
import CommentsSidebarDetail from '../../../views/private/components/comments-sidebar-detail';
import useItem from '../../../composables/use-item';
import SaveOptions from '../../../views/private/components/save-options';
import api from '../../../api';
import { useFieldsStore, useCollectionsStore, useUserStore } from '../../../stores/';
import useFormFields from '../../../composables/use-form-fields';
import { Field } from '../../../types';
import { i18n, setLanguage } from '@/lang';
import router from '@/router';
import RevisionsDrawerDetail from '@/views/private/components/revisions-drawer-detail';
import CommentsSidebarDetail from '@/views/private/components/comments-sidebar-detail';
import useItem from '@/composables/use-item';
import SaveOptions from '@/views/private/components/save-options';
import api from '@/api';
import { useFieldsStore, useCollectionsStore, useUserStore } from '@/stores/';
import useFormFields from '@/composables/use-form-fields';
import { Field } from '@/types';
import UserInfoSidebarDetail from '../components/user-info-sidebar-detail.vue';
import { getRootPath } from '../../../utils/get-root-path';
import useShortcut from '../../../composables/use-shortcut';
import useCollection from '../../../composables/use-collection';
import { userName } from '../../../utils/user-name';
import { usePermissions } from '../../../composables/use-permissions';
import { unexpectedError } from '../../../utils/unexpected-error';
import { addTokenToURL } from '../../../api';
type Values = {
[field: string]: any;
};
import { getRootPath } from '@/utils/get-root-path';
import useShortcut from '@/composables/use-shortcut';
import useCollection from '@/composables/use-collection';
import { userName } from '@/utils/user-name';
import { usePermissions } from '@/composables/use-permissions';
import { unexpectedError } from '@/utils/unexpected-error';
import { addTokenToURL } from '@/api';
import { usePermissionsStore } from '@/stores';
export default defineComponent({
name: 'users-item',
@@ -221,6 +219,13 @@ export default defineComponent({
const fieldsStore = useFieldsStore();
const collectionsStore = useCollectionsStore();
const userStore = useUserStore();
const permissionsStore = usePermissionsStore();
const hasRevisionsPermissions = computed(() => {
return !!permissionsStore.state.permissions.find(
(permission) => permission.collection === 'directus_revisions' && permission.action === 'read'
);
});
const { primaryKey } = toRefs(props);
const { breadcrumb } = useBreadcrumb();
@@ -346,6 +351,7 @@ export default defineComponent({
archiveTooltip,
form,
userName,
hasRevisionsPermissions,
};
function useBreadcrumb() {

View File

@@ -11,7 +11,11 @@ export const usePermissionsStore = createStore({
}),
actions: {
async hydrate() {
const response = await api.get('/permissions/me', { params: { limit: -1 } });
const userStore = useUserStore();
const response = await api.get('/permissions', {
params: { limit: -1, filter: { role: { _eq: userStore.state.currentUser!.role.id } } },
});
this.state.permissions = response.data.data.map((rawPermission: any) => {
if (rawPermission.permissions) {

View File

@@ -12,8 +12,7 @@
html {
font-size: 14px;
letter-spacing: -0.15px;
-webkit-tap-highlight-color: rgba(0,0,0,0);
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
body {

View File

@@ -7,7 +7,6 @@
font-family: var(--family-sans-serif);
font-style: normal;
line-height: 34px;
letter-spacing: -0.32px;
@include breakpoint(small) {
font-size: 24px;
@@ -21,7 +20,6 @@
font-family: var(--family-sans-serif);
font-style: normal;
line-height: 19px;
letter-spacing: -0.32px;
}
@mixin type-text {
@@ -31,5 +29,4 @@
font-family: var(--family-sans-serif);
font-style: normal;
line-height: 22px;
letter-spacing: -0.15px;
}

View File

@@ -31,11 +31,7 @@
<v-icon name="rotate_90_degrees_ccw" @click="rotate" v-tooltip.top.inverted="$t('rotate')" />
<v-icon
name="flip_horizontal"
@click="flip('horizontal')"
v-tooltip.top.inverted="$t('flip_horizontal')"
/>
<v-icon name="flip_horizontal" @click="flip('horizontal')" v-tooltip.top.inverted="$t('flip_horizontal')" />
<v-icon name="flip_vertical" @click="flip('vertical')" v-tooltip.top.inverted="$t('flip_vertical')" />
@@ -84,9 +80,7 @@
<div class="dimensions" v-if="imageData">
{{ $n(imageData.width) }}x{{ $n(imageData.height) }}
<template
v-if="imageData.width !== newDimensions.width || imageData.height !== newDimensions.height"
>
<template v-if="imageData.width !== newDimensions.width || imageData.height !== newDimensions.height">
->
{{ $n(newDimensions.width) }}x{{ $n(newDimensions.height) }}
</template>
@@ -474,7 +468,6 @@ export default defineComponent({
.dimensions {
margin-right: 12px;
color: var(--foreground-subdued);
letter-spacing: 0;
font-feature-settings: 'tnum';
}