mirror of
https://github.com/directus/directus.git
synced 2026-04-03 03:00:39 -04:00
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:
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
17
api/src/database/system-data/app-access-permissions/index.ts
Normal file
17
api/src/database/system-data/app-access-permissions/index.ts
Normal 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));
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -2,6 +2,7 @@ export type Accountability = {
|
||||
role: string | null;
|
||||
user?: string | null;
|
||||
admin?: boolean;
|
||||
app?: boolean;
|
||||
|
||||
ip?: string;
|
||||
userAgent?: string;
|
||||
|
||||
5
api/src/types/express.d.ts
vendored
5
api/src/types/express.d.ts
vendored
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
38
api/src/utils/filter-items.ts
Normal file
38
api/src/utils/filter-items.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]];
|
||||
|
||||
69
api/src/utils/get-schema.ts
Normal file
69
api/src/utils/get-schema.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
88
api/src/utils/merge-permissions.ts
Normal file
88
api/src/utils/merge-permissions.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -34,6 +34,7 @@
|
||||
},
|
||||
"gitHead": "4476da28dbbc2824e680137aa28b2b91b5afabec",
|
||||
"dependencies": {
|
||||
"directus": "file:../api",
|
||||
"@directus/docs": "file:../docs",
|
||||
"@directus/format-title": "file:../packages/format-title"
|
||||
},
|
||||
|
||||
@@ -120,7 +120,7 @@ hr {
|
||||
margin-bottom: 56px;
|
||||
text-align: center;
|
||||
}
|
||||
hr: after {
|
||||
hr:after {
|
||||
content: "...";
|
||||
font-size: 28px;
|
||||
letter-spacing: 16px;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
|
||||
@@ -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: {},
|
||||
},
|
||||
];
|
||||
@@ -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];
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -34,6 +34,7 @@ export default defineModule(({ i18n }) => ({
|
||||
const permission = permissions.find(
|
||||
(permission) => permission.collection === 'directus_users' && permission.action === 'read'
|
||||
);
|
||||
|
||||
return !!permission;
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user