mirror of
https://github.com/directus/directus.git
synced 2026-04-25 03:00:53 -04:00
Merge branch 'main' into relational-updates
This commit is contained in:
13
api/package-lock.json
generated
13
api/package-lock.json
generated
@@ -5706,6 +5706,14 @@
|
||||
"mimic-fn": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"openapi3-ts": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-2.0.0.tgz",
|
||||
"integrity": "sha512-q4p8OX/mD7qXeDKkhdLhpEz1Zh/IxPBDWmuq7f07fQJpo7exUW20sMrHfws1xzihYPktTXVV5MDOZkG/1uguEg==",
|
||||
"requires": {
|
||||
"yaml": "^1.10.0"
|
||||
}
|
||||
},
|
||||
"ora": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ora/-/ora-4.1.1.tgz",
|
||||
@@ -7940,6 +7948,11 @@
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
|
||||
},
|
||||
"yaml": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.0.tgz",
|
||||
"integrity": "sha512-yr2icI4glYaNG+KWONODapy2/jDdMSDnrONSjblABjD9B4Z5LgiircSt8m8sRZFNi08kG9Sm0uSHtEmP3zaEGg=="
|
||||
},
|
||||
"yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
|
||||
@@ -109,6 +109,7 @@
|
||||
"nanoid": "^3.1.12",
|
||||
"node-machine-id": "^1.1.12",
|
||||
"nodemailer": "^6.4.11",
|
||||
"openapi3-ts": "^2.0.0",
|
||||
"ora": "^4.1.1",
|
||||
"otplib": "^12.0.1",
|
||||
"pino": "^6.4.1",
|
||||
|
||||
@@ -38,6 +38,7 @@ import graphqlRouter from './controllers/graphql';
|
||||
|
||||
import notFoundHandler from './controllers/not-found';
|
||||
import sanitizeQuery from './middleware/sanitize-query';
|
||||
import { checkIP } from './middleware/check-ip';
|
||||
import { WebhooksService } from './services/webhooks';
|
||||
import { InvalidPayloadException } from './exceptions';
|
||||
|
||||
@@ -98,6 +99,9 @@ app.use(sanitizeQuery);
|
||||
app.use('/auth', authRouter);
|
||||
|
||||
app.use(authenticate);
|
||||
|
||||
app.use(checkIP);
|
||||
|
||||
app.use(cache);
|
||||
|
||||
app.use('/graphql', graphqlRouter);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AST, NestedCollectionAST } from '../types/ast';
|
||||
import { AST, NestedCollectionNode, FieldNode, M2ONode, O2MNode } from '../types/ast';
|
||||
import { clone, cloneDeep, uniq, pick } from 'lodash';
|
||||
import database from './index';
|
||||
import SchemaInspector from 'knex-schema-inspector';
|
||||
@@ -14,80 +14,106 @@ type RunASTOptions = {
|
||||
};
|
||||
|
||||
export default async function runAST(
|
||||
originalAST: AST,
|
||||
originalAST: AST | NestedCollectionNode,
|
||||
options?: RunASTOptions
|
||||
): Promise<null | Item | Item[]> {
|
||||
const ast = cloneDeep(originalAST);
|
||||
|
||||
const query = options?.query || ast.query;
|
||||
const knex = options?.knex || database;
|
||||
|
||||
// Retrieve the database columns to select in the current AST
|
||||
const { columnsToSelect, primaryKeyField, nestedCollectionASTs } = await parseCurrentLevel(
|
||||
ast,
|
||||
knex
|
||||
);
|
||||
if (ast.type === 'm2a') {
|
||||
const results: { [collection: string]: null | Item | Item[] } = {};
|
||||
|
||||
// The actual knex query builder instance. This is a promise that resolves with the raw items from the db
|
||||
const dbQuery = await getDBQuery(knex, ast.name, columnsToSelect, query, primaryKeyField);
|
||||
|
||||
const rawItems: Item | Item[] = await dbQuery;
|
||||
|
||||
if (!rawItems) return null;
|
||||
|
||||
// Run the items through the special transforms
|
||||
const payloadService = new PayloadService(ast.name, { knex });
|
||||
let items = await payloadService.processValues('read', rawItems);
|
||||
|
||||
if (!items || items.length === 0) return items;
|
||||
|
||||
// Apply the `_in` filters to the nested collection batches
|
||||
const nestedASTs = applyParentFilters(nestedCollectionASTs, items);
|
||||
|
||||
for (const nestedAST of nestedASTs) {
|
||||
let tempLimit: number | null = null;
|
||||
|
||||
// Nested o2m-items are fetched from the db in a single query. This means that we're fetching
|
||||
// all nested items for all parent items at once. Because of this, we can't limit that query
|
||||
// to the "standard" item limit. Instead of _n_ nested items per parent item, it would mean
|
||||
// that there's _n_ items, which are then divided on the parent items. (no good)
|
||||
if (isO2M(nestedAST) && typeof nestedAST.query.limit === 'number') {
|
||||
tempLimit = nestedAST.query.limit;
|
||||
nestedAST.query.limit = -1;
|
||||
for (const collection of ast.names) {
|
||||
results[collection] = await run(
|
||||
collection,
|
||||
ast.children[collection],
|
||||
ast.query[collection]
|
||||
);
|
||||
}
|
||||
|
||||
let nestedItems = await runAST(nestedAST, { knex, child: true });
|
||||
return results;
|
||||
} else {
|
||||
return await run(ast.name, ast.children, options?.query || ast.query);
|
||||
}
|
||||
|
||||
if (nestedItems) {
|
||||
// Merge all fetched nested records with the parent items
|
||||
items = mergeWithParentItems(nestedItems, items, nestedAST, tempLimit);
|
||||
async function run(
|
||||
collection: string,
|
||||
children: (NestedCollectionNode | FieldNode)[],
|
||||
query: Query
|
||||
) {
|
||||
// Retrieve the database columns to select in the current AST
|
||||
const { columnsToSelect, primaryKeyField, nestedCollectionNodes } = await parseCurrentLevel(
|
||||
collection,
|
||||
children,
|
||||
knex
|
||||
);
|
||||
|
||||
// The actual knex query builder instance. This is a promise that resolves with the raw items from the db
|
||||
const dbQuery = await getDBQuery(knex, collection, columnsToSelect, query, primaryKeyField);
|
||||
|
||||
const rawItems: Item | Item[] = await dbQuery;
|
||||
|
||||
if (!rawItems) return null;
|
||||
|
||||
// Run the items through the special transforms
|
||||
const payloadService = new PayloadService(collection, { knex });
|
||||
let items: null | Item | Item[] = await payloadService.processValues('read', rawItems);
|
||||
|
||||
if (!items || items.length === 0) return items;
|
||||
|
||||
// Apply the `_in` filters to the nested collection batches
|
||||
const nestedNodes = applyParentFilters(nestedCollectionNodes, items);
|
||||
|
||||
for (const nestedNode of nestedNodes) {
|
||||
let tempLimit: number | null = null;
|
||||
|
||||
// Nested o2m-items are fetched from the db in a single query. This means that we're fetching
|
||||
// all nested items for all parent items at once. Because of this, we can't limit that query
|
||||
// to the "standard" item limit. Instead of _n_ nested items per parent item, it would mean
|
||||
// that there's _n_ items, which are then divided on the parent items. (no good)
|
||||
if (nestedNode.type === 'o2m' && typeof nestedNode.query.limit === 'number') {
|
||||
tempLimit = nestedNode.query.limit;
|
||||
nestedNode.query.limit = -1;
|
||||
}
|
||||
|
||||
let nestedItems = await runAST(nestedNode, { knex, child: true });
|
||||
|
||||
if (nestedItems) {
|
||||
// Merge all fetched nested records with the parent items
|
||||
items = mergeWithParentItems(nestedItems, items, nestedNode, tempLimit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// During the fetching of data, we have to inject a couple of required fields for the child nesting
|
||||
// to work (primary / foreign keys) even if they're not explicitly requested. After all fetching
|
||||
// and nesting is done, we parse through the output structure, and filter out all non-requested
|
||||
// fields
|
||||
if (options?.child !== true) {
|
||||
items = removeTemporaryFields(items, originalAST, primaryKeyField);
|
||||
}
|
||||
// During the fetching of data, we have to inject a couple of required fields for the child nesting
|
||||
// to work (primary / foreign keys) even if they're not explicitly requested. After all fetching
|
||||
// and nesting is done, we parse through the output structure, and filter out all non-requested
|
||||
// fields
|
||||
if (options?.child !== true) {
|
||||
items = removeTemporaryFields(items, originalAST, primaryKeyField);
|
||||
}
|
||||
|
||||
return items;
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
async function parseCurrentLevel(ast: AST, knex: Knex) {
|
||||
async function parseCurrentLevel(
|
||||
collection: string,
|
||||
children: (NestedCollectionNode | FieldNode)[],
|
||||
knex: Knex
|
||||
) {
|
||||
const schemaInspector = SchemaInspector(knex);
|
||||
|
||||
const primaryKeyField = await schemaInspector.primary(ast.name);
|
||||
const primaryKeyField = await schemaInspector.primary(collection);
|
||||
|
||||
const columnsInCollection = (await schemaInspector.columns(ast.name)).map(
|
||||
const columnsInCollection = (await schemaInspector.columns(collection)).map(
|
||||
({ column }) => column
|
||||
);
|
||||
|
||||
const columnsToSelect: string[] = [];
|
||||
const nestedCollectionASTs: NestedCollectionAST[] = [];
|
||||
const nestedCollectionNodes: NestedCollectionNode[] = [];
|
||||
|
||||
for (const child of ast.children) {
|
||||
for (const child of children) {
|
||||
if (child.type === 'field') {
|
||||
if (columnsInCollection.includes(child.name) || child.name === '*') {
|
||||
columnsToSelect.push(child.name);
|
||||
@@ -98,13 +124,16 @@ async function parseCurrentLevel(ast: AST, knex: Knex) {
|
||||
|
||||
if (!child.relation) continue;
|
||||
|
||||
const m2o = isM2O(child);
|
||||
|
||||
if (m2o) {
|
||||
if (child.type === 'm2o') {
|
||||
columnsToSelect.push(child.relation.many_field);
|
||||
}
|
||||
|
||||
nestedCollectionASTs.push(child);
|
||||
if (child.type === 'm2a') {
|
||||
columnsToSelect.push(child.relation.many_field);
|
||||
columnsToSelect.push(child.relation.one_collection_field!);
|
||||
}
|
||||
|
||||
nestedCollectionNodes.push(child);
|
||||
}
|
||||
|
||||
/** Always fetch primary key in case there's a nested relation that needs it */
|
||||
@@ -112,7 +141,7 @@ async function parseCurrentLevel(ast: AST, knex: Knex) {
|
||||
columnsToSelect.push(primaryKeyField);
|
||||
}
|
||||
|
||||
return { columnsToSelect, nestedCollectionASTs, primaryKeyField };
|
||||
return { columnsToSelect, nestedCollectionNodes, primaryKeyField };
|
||||
}
|
||||
|
||||
async function getDBQuery(
|
||||
@@ -140,92 +169,132 @@ async function getDBQuery(
|
||||
}
|
||||
|
||||
function applyParentFilters(
|
||||
nestedCollectionASTs: NestedCollectionAST[],
|
||||
nestedCollectionNodes: NestedCollectionNode[],
|
||||
parentItem: Item | Item[]
|
||||
) {
|
||||
const parentItems = Array.isArray(parentItem) ? parentItem : [parentItem];
|
||||
|
||||
for (const nestedAST of nestedCollectionASTs) {
|
||||
if (!nestedAST.relation) continue;
|
||||
for (const nestedNode of nestedCollectionNodes) {
|
||||
if (!nestedNode.relation) continue;
|
||||
|
||||
if (isM2O(nestedAST)) {
|
||||
nestedAST.query = {
|
||||
...nestedAST.query,
|
||||
if (nestedNode.type === 'm2o') {
|
||||
nestedNode.query = {
|
||||
...nestedNode.query,
|
||||
filter: {
|
||||
...(nestedAST.query.filter || {}),
|
||||
[nestedAST.relation.one_primary]: {
|
||||
...(nestedNode.query.filter || {}),
|
||||
[nestedNode.relation.one_primary!]: {
|
||||
_in: uniq(
|
||||
parentItems.map((res) => res[nestedAST.relation.many_field])
|
||||
parentItems.map((res) => res[nestedNode.relation.many_field])
|
||||
).filter((id) => id),
|
||||
},
|
||||
},
|
||||
};
|
||||
} else {
|
||||
const relatedM2OisFetched = !!nestedAST.children.find((child) => {
|
||||
return child.type === 'field' && child.name === nestedAST.relation.many_field;
|
||||
} else if (nestedNode.type === 'o2m') {
|
||||
const relatedM2OisFetched = !!nestedNode.children.find((child) => {
|
||||
return child.type === 'field' && child.name === nestedNode.relation.many_field;
|
||||
});
|
||||
|
||||
if (relatedM2OisFetched === false) {
|
||||
nestedAST.children.push({ type: 'field', name: nestedAST.relation.many_field });
|
||||
nestedNode.children.push({ type: 'field', name: nestedNode.relation.many_field });
|
||||
}
|
||||
|
||||
nestedAST.query = {
|
||||
...nestedAST.query,
|
||||
nestedNode.query = {
|
||||
...nestedNode.query,
|
||||
filter: {
|
||||
...(nestedAST.query.filter || {}),
|
||||
[nestedAST.relation.many_field]: {
|
||||
_in: uniq(parentItems.map((res) => res[nestedAST.parentKey])).filter(
|
||||
...(nestedNode.query.filter || {}),
|
||||
[nestedNode.relation.many_field]: {
|
||||
_in: uniq(parentItems.map((res) => res[nestedNode.parentKey])).filter(
|
||||
(id) => id
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
} else if (nestedNode.type === 'm2a') {
|
||||
const keysPerCollection: { [collection: string]: (string | number)[] } = {};
|
||||
|
||||
for (const parentItem of parentItems) {
|
||||
const collection = parentItem[nestedNode.relation.one_collection_field!];
|
||||
if (!keysPerCollection[collection]) keysPerCollection[collection] = [];
|
||||
keysPerCollection[collection].push(parentItem[nestedNode.relation.many_field]);
|
||||
}
|
||||
|
||||
for (const relatedCollection of nestedNode.names) {
|
||||
nestedNode.query[relatedCollection] = {
|
||||
...nestedNode.query[relatedCollection],
|
||||
filter: {
|
||||
_and: [
|
||||
nestedNode.query[relatedCollection].filter,
|
||||
{
|
||||
[nestedNode.relatedKey[relatedCollection]]: {
|
||||
_in: uniq(keysPerCollection[relatedCollection]),
|
||||
},
|
||||
},
|
||||
].filter((f) => f),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nestedCollectionASTs;
|
||||
return nestedCollectionNodes;
|
||||
}
|
||||
|
||||
function mergeWithParentItems(
|
||||
nestedItem: Item | Item[],
|
||||
parentItem: Item | Item[],
|
||||
nestedAST: NestedCollectionAST,
|
||||
nestedNode: NestedCollectionNode,
|
||||
o2mLimit?: number | null
|
||||
) {
|
||||
const nestedItems = Array.isArray(nestedItem) ? nestedItem : [nestedItem];
|
||||
const parentItems = clone(Array.isArray(parentItem) ? parentItem : [parentItem]);
|
||||
|
||||
if (isM2O(nestedAST)) {
|
||||
if (nestedNode.type === 'm2o') {
|
||||
for (const parentItem of parentItems) {
|
||||
const itemChild = nestedItems.find((nestedItem) => {
|
||||
return (
|
||||
nestedItem[nestedAST.relation.one_primary] === parentItem[nestedAST.fieldKey]
|
||||
nestedItem[nestedNode.relation.one_primary!] === parentItem[nestedNode.fieldKey]
|
||||
);
|
||||
});
|
||||
|
||||
parentItem[nestedAST.fieldKey] = itemChild || null;
|
||||
parentItem[nestedNode.fieldKey] = itemChild || null;
|
||||
}
|
||||
} else {
|
||||
} else if (nestedNode.type === 'o2m') {
|
||||
for (const parentItem of parentItems) {
|
||||
let itemChildren = nestedItems.filter((nestedItem) => {
|
||||
if (nestedItem === null) return false;
|
||||
if (Array.isArray(nestedItem[nestedAST.relation.many_field])) return true;
|
||||
if (Array.isArray(nestedItem[nestedNode.relation.many_field])) return true;
|
||||
|
||||
return (
|
||||
nestedItem[nestedAST.relation.many_field] ===
|
||||
parentItem[nestedAST.relation.one_primary] ||
|
||||
nestedItem[nestedAST.relation.many_field]?.[nestedAST.relation.many_primary] ===
|
||||
parentItem[nestedAST.relation.one_primary]
|
||||
nestedItem[nestedNode.relation.many_field] ===
|
||||
parentItem[nestedNode.relation.one_primary!] ||
|
||||
nestedItem[nestedNode.relation.many_field]?.[
|
||||
nestedNode.relation.many_primary
|
||||
] === parentItem[nestedNode.relation.one_primary!]
|
||||
);
|
||||
});
|
||||
|
||||
// We re-apply the requested limit here. This forces the _n_ nested items per parent concept
|
||||
if (o2mLimit !== null) {
|
||||
itemChildren = itemChildren.slice(0, o2mLimit);
|
||||
nestedAST.query.limit = o2mLimit;
|
||||
nestedNode.query.limit = o2mLimit;
|
||||
}
|
||||
|
||||
parentItem[nestedAST.fieldKey] = itemChildren.length > 0 ? itemChildren : null;
|
||||
parentItem[nestedNode.fieldKey] = itemChildren.length > 0 ? itemChildren : null;
|
||||
}
|
||||
} else if (nestedNode.type === 'm2a') {
|
||||
for (const parentItem of parentItems) {
|
||||
const relatedCollection = parentItem[nestedNode.relation.one_collection_field!];
|
||||
|
||||
const itemChild = (nestedItem as Record<string, any[]>)[relatedCollection].find(
|
||||
(nestedItem) => {
|
||||
return (
|
||||
nestedItem[nestedNode.relatedKey[relatedCollection]] ===
|
||||
parentItem[nestedNode.fieldKey]
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
parentItem[nestedNode.fieldKey] = itemChild || null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,50 +303,89 @@ function mergeWithParentItems(
|
||||
|
||||
function removeTemporaryFields(
|
||||
rawItem: Item | Item[],
|
||||
ast: AST | NestedCollectionAST,
|
||||
primaryKeyField: string
|
||||
): Item | Item[] {
|
||||
const rawItems: Item[] = Array.isArray(rawItem) ? rawItem : [rawItem];
|
||||
|
||||
ast: AST | NestedCollectionNode,
|
||||
primaryKeyField: string,
|
||||
parentItem?: Item
|
||||
): null | Item | Item[] {
|
||||
const rawItems = cloneDeep(Array.isArray(rawItem) ? rawItem : [rawItem]);
|
||||
const items: Item[] = [];
|
||||
|
||||
const fields = ast.children
|
||||
.filter((child) => child.type === 'field')
|
||||
.map((child) => child.name);
|
||||
if (ast.type === 'm2a') {
|
||||
const fields: Record<string, string[]> = {};
|
||||
const nestedCollectionNodes: Record<string, NestedCollectionNode[]> = {};
|
||||
|
||||
const nestedCollections = ast.children.filter(
|
||||
(child) => child.type === 'collection'
|
||||
) as NestedCollectionAST[];
|
||||
for (const relatedCollection of ast.names) {
|
||||
if (!fields[relatedCollection]) fields[relatedCollection] = [];
|
||||
if (!nestedCollectionNodes[relatedCollection])
|
||||
nestedCollectionNodes[relatedCollection] = [];
|
||||
|
||||
fields.push(...nestedCollections.map((nestedNode) => nestedNode.fieldKey));
|
||||
|
||||
for (const rawItem of rawItems) {
|
||||
if (rawItem === null) return rawItem;
|
||||
|
||||
const item = fields.length > 0 ? pick(rawItem, fields) : rawItem[primaryKeyField];
|
||||
|
||||
for (const nestedCollection of nestedCollections) {
|
||||
if (item[nestedCollection.fieldKey] !== null) {
|
||||
item[nestedCollection.fieldKey] = removeTemporaryFields(
|
||||
rawItem[nestedCollection.fieldKey],
|
||||
nestedCollection,
|
||||
nestedCollection.relatedKey
|
||||
);
|
||||
for (const child of ast.children[relatedCollection]) {
|
||||
if (child.type === 'field') {
|
||||
fields[relatedCollection].push(child.name);
|
||||
} else {
|
||||
fields[relatedCollection].push(child.fieldKey);
|
||||
nestedCollectionNodes[relatedCollection].push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items.push(item);
|
||||
for (const rawItem of rawItems) {
|
||||
const relatedCollection: string = parentItem?.[ast.relation.one_collection_field!];
|
||||
|
||||
if (rawItem === null || rawItem === undefined) return rawItem;
|
||||
|
||||
let item = rawItem;
|
||||
|
||||
for (const nestedNode of nestedCollectionNodes[relatedCollection]) {
|
||||
item[nestedNode.fieldKey] = removeTemporaryFields(
|
||||
item[nestedNode.fieldKey],
|
||||
nestedNode,
|
||||
nestedNode.relation.many_primary,
|
||||
item
|
||||
);
|
||||
}
|
||||
|
||||
item =
|
||||
fields[relatedCollection].length > 0
|
||||
? pick(rawItem, fields[relatedCollection])
|
||||
: rawItem[primaryKeyField];
|
||||
|
||||
items.push(item);
|
||||
}
|
||||
} else {
|
||||
const fields: string[] = [];
|
||||
const nestedCollectionNodes: NestedCollectionNode[] = [];
|
||||
|
||||
for (const child of ast.children) {
|
||||
if (child.type === 'field') {
|
||||
fields.push(child.name);
|
||||
} else {
|
||||
fields.push(child.fieldKey);
|
||||
nestedCollectionNodes.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
for (const rawItem of rawItems) {
|
||||
if (rawItem === null || rawItem === undefined) return rawItem;
|
||||
|
||||
let item = rawItem;
|
||||
|
||||
for (const nestedNode of nestedCollectionNodes) {
|
||||
item[nestedNode.fieldKey] = removeTemporaryFields(
|
||||
item[nestedNode.fieldKey],
|
||||
nestedNode,
|
||||
nestedNode.type === 'm2o'
|
||||
? nestedNode.relation.one_primary!
|
||||
: nestedNode.relation.many_primary,
|
||||
item
|
||||
);
|
||||
}
|
||||
|
||||
item = fields.length > 0 ? pick(rawItem, fields) : rawItem[primaryKeyField];
|
||||
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.isArray(rawItem) ? items : items[0];
|
||||
}
|
||||
|
||||
function isM2O(child: NestedCollectionAST) {
|
||||
return (
|
||||
child.relation.one_collection === child.name && child.relation.many_field === child.fieldKey
|
||||
);
|
||||
}
|
||||
|
||||
function isO2M(child: NestedCollectionAST) {
|
||||
return isM2O(child) === false;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ columns:
|
||||
description:
|
||||
type: text
|
||||
ip_access:
|
||||
type: json
|
||||
type: text
|
||||
enforce_tfa:
|
||||
type: boolean
|
||||
nullable: false
|
||||
|
||||
@@ -21,7 +21,6 @@ columns:
|
||||
one_collection:
|
||||
type: string
|
||||
length: 64
|
||||
nullable: false
|
||||
references:
|
||||
table: directus_collections
|
||||
column: collection
|
||||
@@ -31,7 +30,11 @@ columns:
|
||||
one_primary:
|
||||
type: string
|
||||
length: 64
|
||||
nullable: false
|
||||
one_collection_field:
|
||||
type: string
|
||||
length: 64
|
||||
one_allowed_collections:
|
||||
type: text
|
||||
junction_field:
|
||||
type: string
|
||||
length: 64
|
||||
|
||||
@@ -44,3 +44,5 @@ columns:
|
||||
default: all
|
||||
storage_asset_presets:
|
||||
type: json
|
||||
custom_css:
|
||||
type: text
|
||||
|
||||
@@ -14,4 +14,4 @@ data:
|
||||
- collection: directus_settings
|
||||
action: read
|
||||
permissions: {}
|
||||
fields: 'project_name,project_logo,project_color,public_foreground,public_background,public_note'
|
||||
fields: 'project_name,project_logo,project_color,public_foreground,public_background,public_note,custom_css'
|
||||
|
||||
@@ -57,7 +57,7 @@ data:
|
||||
many_primary: id
|
||||
one_collection: directus_collections
|
||||
one_field: fields
|
||||
one_primary: id
|
||||
one_primary: collection
|
||||
- many_collection: directus_activity
|
||||
many_field: user
|
||||
many_primary: id
|
||||
|
||||
@@ -67,9 +67,9 @@ fields:
|
||||
special: json
|
||||
interface: repeater
|
||||
options:
|
||||
template: '{{ translation }} ({{ locale }})'
|
||||
template: '{{ translation }} ({{ language }})'
|
||||
fields:
|
||||
- field: locale
|
||||
- field: language
|
||||
name: Language
|
||||
type: string
|
||||
schema:
|
||||
|
||||
@@ -50,7 +50,7 @@ fields:
|
||||
options:
|
||||
placeholder: Add allowed IP addresses, leave empty to allow all...
|
||||
locked: true
|
||||
special: json
|
||||
special: csv
|
||||
sort: 6
|
||||
width: full
|
||||
- collection: directus_roles
|
||||
|
||||
@@ -10,7 +10,7 @@ fields:
|
||||
placeholder: My project...
|
||||
sort: 1
|
||||
translations:
|
||||
locale: en-US
|
||||
language: en-US
|
||||
translations: Name
|
||||
width: half
|
||||
- collection: directus_settings
|
||||
@@ -22,7 +22,7 @@ fields:
|
||||
placeholder: https://example.com
|
||||
sort: 2
|
||||
translations:
|
||||
locale: en-US
|
||||
language: en-US
|
||||
translations: Website
|
||||
width: half
|
||||
- collection: directus_settings
|
||||
@@ -32,7 +32,7 @@ fields:
|
||||
note: Login & Logo Background
|
||||
sort: 3
|
||||
translations:
|
||||
locale: en-US
|
||||
language: en-US
|
||||
translations: Brand Color
|
||||
width: half
|
||||
- collection: directus_settings
|
||||
@@ -42,7 +42,7 @@ fields:
|
||||
note: White 40x40 SVG/PNG
|
||||
sort: 4
|
||||
translations:
|
||||
locale: en-US
|
||||
language: en-US
|
||||
translations: Brand Logo
|
||||
width: half
|
||||
- collection: directus_settings
|
||||
@@ -62,7 +62,7 @@ fields:
|
||||
locked: true
|
||||
sort: 6
|
||||
translations:
|
||||
locale: en-US
|
||||
language: en-US
|
||||
translations: Login Foreground
|
||||
width: half
|
||||
- collection: directus_settings
|
||||
@@ -71,7 +71,7 @@ fields:
|
||||
locked: true
|
||||
sort: 7
|
||||
translations:
|
||||
locale: en-US
|
||||
language: en-US
|
||||
translations: Login Background
|
||||
width: half
|
||||
- collection: directus_settings
|
||||
@@ -203,3 +203,23 @@ fields:
|
||||
field: id
|
||||
hidden: true
|
||||
locked: true
|
||||
- collection: directus_settings
|
||||
field: overrides_divider
|
||||
interface: divider
|
||||
locked: true
|
||||
options:
|
||||
icon: brush
|
||||
title: App Overrides
|
||||
color: '#2F80ED'
|
||||
special: alias
|
||||
sort: 15
|
||||
width: full
|
||||
- collection: directus_settings
|
||||
field: custom_css
|
||||
interface: code
|
||||
locked: true
|
||||
options:
|
||||
language: css
|
||||
lineNumber: true
|
||||
sort: 16
|
||||
width: full
|
||||
|
||||
@@ -68,6 +68,7 @@ fields:
|
||||
locked: true
|
||||
options:
|
||||
label: Send Event Data
|
||||
special: boolean
|
||||
sort: 5
|
||||
width: half
|
||||
- collection: directus_webhooks
|
||||
|
||||
7
api/src/database/seeds/03-fields/12-relations.yaml
Normal file
7
api/src/database/seeds/03-fields/12-relations.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
table: directus_relations
|
||||
|
||||
fields:
|
||||
- collection: directus_relations
|
||||
field: one_allowed_collections
|
||||
locked: true
|
||||
special: csv
|
||||
@@ -62,6 +62,7 @@ function processValues(env: Record<string, any>) {
|
||||
if (value === 'true') env[key] = true;
|
||||
if (value === 'false') env[key] = false;
|
||||
if (value === 'null') env[key] = null;
|
||||
if (isNaN(value) === false && value.length > 0) env[key] = Number(value);
|
||||
}
|
||||
|
||||
return env;
|
||||
|
||||
@@ -3,8 +3,10 @@ export * from './failed-validation';
|
||||
export * from './forbidden';
|
||||
export * from './hit-rate-limit';
|
||||
export * from './invalid-credentials';
|
||||
export * from './invalid-ip';
|
||||
export * from './invalid-otp';
|
||||
export * from './invalid-payload';
|
||||
export * from './invalid-query';
|
||||
export * from './route-not-found';
|
||||
export * from './service-unavailable';
|
||||
export * from './unprocessable-entity';
|
||||
|
||||
7
api/src/exceptions/invalid-ip.ts
Normal file
7
api/src/exceptions/invalid-ip.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { BaseException } from './base';
|
||||
|
||||
export class InvalidIPException extends BaseException {
|
||||
constructor(message = 'Invalid IP address.') {
|
||||
super(message, 401, 'INVALID_IP');
|
||||
}
|
||||
}
|
||||
7
api/src/exceptions/unprocessable-entity.ts
Normal file
7
api/src/exceptions/unprocessable-entity.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { BaseException } from './base';
|
||||
|
||||
export class UnprocessableEntityException extends BaseException {
|
||||
constructor(message: string) {
|
||||
super(message, 422, 'UNPROCESSABLE_ENTITY');
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ const authenticate: RequestHandler = asyncHandler(async (req, res, next) => {
|
||||
user: null,
|
||||
role: null,
|
||||
admin: false,
|
||||
ip: req.ip,
|
||||
ip: req.ip.startsWith('::ffff:') ? req.ip.substring(7) : req.ip,
|
||||
userAgent: req.get('user-agent'),
|
||||
};
|
||||
|
||||
@@ -74,7 +74,9 @@ const authenticate: RequestHandler = asyncHandler(async (req, res, next) => {
|
||||
}
|
||||
|
||||
if (req.accountability?.user) {
|
||||
await database('directus_users').update({ last_access: new Date() }).where({ id: req.accountability.user });
|
||||
await database('directus_users')
|
||||
.update({ last_access: new Date() })
|
||||
.where({ id: req.accountability.user });
|
||||
}
|
||||
|
||||
return next();
|
||||
|
||||
17
api/src/middleware/check-ip.ts
Normal file
17
api/src/middleware/check-ip.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { RequestHandler } from 'express';
|
||||
import asyncHandler from 'express-async-handler';
|
||||
import database from '../database';
|
||||
import { InvalidIPException } from '../exceptions';
|
||||
|
||||
export const checkIP: RequestHandler = asyncHandler(async (req, res, next) => {
|
||||
const role = await database
|
||||
.select('ip_access')
|
||||
.from('directus_roles')
|
||||
.where({ id: req.accountability!.role })
|
||||
.first();
|
||||
const ipAllowlist = (role.ip_access || '').split(',').filter((ip: string) => ip);
|
||||
|
||||
if (ipAllowlist.length > 0 && ipAllowlist.includes(req.accountability!.ip) === false)
|
||||
throw new InvalidIPException();
|
||||
return next();
|
||||
});
|
||||
@@ -3,8 +3,8 @@ import {
|
||||
Accountability,
|
||||
AbstractServiceOptions,
|
||||
AST,
|
||||
NestedCollectionAST,
|
||||
FieldAST,
|
||||
NestedCollectionNode,
|
||||
FieldNode,
|
||||
Query,
|
||||
Permission,
|
||||
PermissionsAction,
|
||||
@@ -74,30 +74,35 @@ export class AuthorizationService {
|
||||
* Traverses the AST and returns an array of all collections that are being fetched
|
||||
*/
|
||||
function getCollectionsFromAST(
|
||||
ast: AST | NestedCollectionAST
|
||||
ast: AST | NestedCollectionNode
|
||||
): { collection: string; field: string }[] {
|
||||
const collections = [];
|
||||
|
||||
if (ast.type === 'collection') {
|
||||
if (ast.type === 'm2a') {
|
||||
collections.push(
|
||||
...ast.names.map((name) => ({ collection: name, field: ast.fieldKey }))
|
||||
);
|
||||
|
||||
/** @TODO add nestedNode */
|
||||
} else {
|
||||
collections.push({
|
||||
collection: ast.name,
|
||||
field: (ast as NestedCollectionAST).fieldKey
|
||||
? (ast as NestedCollectionAST).fieldKey
|
||||
: null,
|
||||
field: ast.type === 'root' ? null : ast.fieldKey,
|
||||
});
|
||||
}
|
||||
|
||||
for (const subAST of ast.children) {
|
||||
if (subAST.type === 'collection') {
|
||||
collections.push(...getCollectionsFromAST(subAST));
|
||||
for (const nestedNode of ast.children) {
|
||||
if (nestedNode.type !== 'field') {
|
||||
collections.push(...getCollectionsFromAST(nestedNode));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return collections as { collection: string; field: string }[];
|
||||
}
|
||||
|
||||
function validateFields(ast: AST | NestedCollectionAST) {
|
||||
if (ast.type === 'collection') {
|
||||
function validateFields(ast: AST | NestedCollectionNode | FieldNode) {
|
||||
if (ast.type !== 'field' && ast.type !== 'm2a') {
|
||||
/** @TODO remove m2a check */
|
||||
const collection = ast.name;
|
||||
|
||||
// We check the availability of the permissions in the step before this is run
|
||||
@@ -107,15 +112,15 @@ export class AuthorizationService {
|
||||
|
||||
const allowedFields = permissions.fields?.split(',') || [];
|
||||
|
||||
for (const childAST of ast.children) {
|
||||
if (childAST.type === 'collection') {
|
||||
validateFields(childAST);
|
||||
for (const childNode of ast.children) {
|
||||
if (childNode.type !== 'field') {
|
||||
validateFields(childNode);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (allowedFields.includes('*')) continue;
|
||||
|
||||
const fieldKey = childAST.name;
|
||||
const fieldKey = childNode.name;
|
||||
|
||||
if (allowedFields.includes(fieldKey) === false) {
|
||||
throw new ForbiddenException(
|
||||
@@ -127,10 +132,11 @@ export class AuthorizationService {
|
||||
}
|
||||
|
||||
function applyFilters(
|
||||
ast: AST | NestedCollectionAST | FieldAST,
|
||||
ast: AST | NestedCollectionNode | FieldNode,
|
||||
accountability: Accountability | null
|
||||
): AST | NestedCollectionAST | FieldAST {
|
||||
if (ast.type === 'collection') {
|
||||
): AST | NestedCollectionNode | FieldNode {
|
||||
if (ast.type !== 'field' && ast.type !== 'm2a') {
|
||||
/** @TODO remove m2a check */
|
||||
const collection = ast.name;
|
||||
|
||||
// We check the availability of the permissions in the step before this is run
|
||||
@@ -164,8 +170,8 @@ export class AuthorizationService {
|
||||
}
|
||||
|
||||
ast.children = ast.children.map((child) => applyFilters(child, accountability)) as (
|
||||
| NestedCollectionAST
|
||||
| FieldAST
|
||||
| NestedCollectionNode
|
||||
| FieldNode
|
||||
)[];
|
||||
}
|
||||
|
||||
@@ -198,7 +204,17 @@ export class AuthorizationService {
|
||||
let permission: Permission | undefined;
|
||||
|
||||
if (this.accountability?.admin === true) {
|
||||
permission = { id: 0, role: this.accountability?.role, collection, action, permissions: {}, validation: {}, limit: null, fields: '*', presets: {}, }
|
||||
permission = {
|
||||
id: 0,
|
||||
role: this.accountability?.role,
|
||||
collection,
|
||||
action,
|
||||
permissions: {},
|
||||
validation: {},
|
||||
limit: null,
|
||||
fields: '*',
|
||||
presets: {},
|
||||
};
|
||||
} else {
|
||||
permission = await this.knex
|
||||
.select<Permission>('*')
|
||||
@@ -238,10 +254,23 @@ export class AuthorizationService {
|
||||
let requiredColumns: string[] = [];
|
||||
|
||||
for (const column of columns) {
|
||||
const field = await this.knex.select<{ special: string }>('special').from('directus_fields').where({ collection, field: column.name }).first();
|
||||
const field = await this.knex
|
||||
.select<{ special: string }>('special')
|
||||
.from('directus_fields')
|
||||
.where({ collection, field: column.name })
|
||||
.first();
|
||||
const specials = (field?.special || '').split(',');
|
||||
const hasGenerateSpecial = ['uuid', 'date-created', 'role-created', 'user-created'].some((name) => specials.includes(name));
|
||||
const isRequired = column.is_nullable === false && column.has_auto_increment === false && column.default_value === null && hasGenerateSpecial === false;
|
||||
const hasGenerateSpecial = [
|
||||
'uuid',
|
||||
'date-created',
|
||||
'role-created',
|
||||
'user-created',
|
||||
].some((name) => specials.includes(name));
|
||||
const isRequired =
|
||||
column.is_nullable === false &&
|
||||
column.has_auto_increment === false &&
|
||||
column.default_value === null &&
|
||||
hasGenerateSpecial === false;
|
||||
|
||||
if (isRequired) {
|
||||
requiredColumns.push(column.name);
|
||||
@@ -250,23 +279,20 @@ export class AuthorizationService {
|
||||
|
||||
if (requiredColumns.length > 0) {
|
||||
permission.validation = {
|
||||
_and: [
|
||||
permission.validation,
|
||||
{}
|
||||
]
|
||||
}
|
||||
_and: [permission.validation, {}],
|
||||
};
|
||||
|
||||
if (action === 'create') {
|
||||
for (const name of requiredColumns) {
|
||||
permission.validation._and[1][name] = {
|
||||
_required: true
|
||||
}
|
||||
_required: true,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
for (const name of requiredColumns) {
|
||||
permission.validation._and[1][name] = {
|
||||
_nnull: true
|
||||
}
|
||||
_nnull: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -282,7 +308,10 @@ export class AuthorizationService {
|
||||
}
|
||||
}
|
||||
|
||||
validateJoi(validation: Record<string, any>, payloads: Partial<Record<string, any>>[]): FailedValidationException[] {
|
||||
validateJoi(
|
||||
validation: Record<string, any>,
|
||||
payloads: Partial<Record<string, any>>[]
|
||||
): FailedValidationException[] {
|
||||
const errors: FailedValidationException[] = [];
|
||||
|
||||
/**
|
||||
@@ -291,13 +320,21 @@ export class AuthorizationService {
|
||||
|
||||
if (Object.keys(validation)[0] === '_and') {
|
||||
const subValidation = Object.values(validation)[0];
|
||||
const nestedErrors = flatten<FailedValidationException>(subValidation.map((subObj: Record<string, any>) => this.validateJoi(subObj, payloads))).filter((err?: FailedValidationException) => err);
|
||||
const nestedErrors = flatten<FailedValidationException>(
|
||||
subValidation.map((subObj: Record<string, any>) =>
|
||||
this.validateJoi(subObj, payloads)
|
||||
)
|
||||
).filter((err?: FailedValidationException) => err);
|
||||
errors.push(...nestedErrors);
|
||||
}
|
||||
|
||||
if (Object.keys(validation)[0] === '_or') {
|
||||
const subValidation = Object.values(validation)[0];
|
||||
const nestedErrors = flatten<FailedValidationException>(subValidation.map((subObj: Record<string, any>) => this.validateJoi(subObj, payloads)));
|
||||
const nestedErrors = flatten<FailedValidationException>(
|
||||
subValidation.map((subObj: Record<string, any>) =>
|
||||
this.validateJoi(subObj, payloads)
|
||||
)
|
||||
);
|
||||
const allErrored = nestedErrors.every((err?: FailedValidationException) => err);
|
||||
|
||||
if (allErrored) {
|
||||
@@ -311,7 +348,9 @@ export class AuthorizationService {
|
||||
const { error } = schema.validate(payload, { abortEarly: false });
|
||||
|
||||
if (error) {
|
||||
errors.push(...error.details.map((details) => new FailedValidationException(details)));
|
||||
errors.push(
|
||||
...error.details.map((details) => new FailedValidationException(details))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -132,9 +132,9 @@ export class CollectionsService {
|
||||
|
||||
const tablesInDatabase = await schemaInspector.tableInfo();
|
||||
const tables = tablesInDatabase.filter((table) => collectionKeys.includes(table.name));
|
||||
const meta = await collectionItemsService.readByQuery({
|
||||
const meta = (await collectionItemsService.readByQuery({
|
||||
filter: { collection: { _in: collectionKeys } },
|
||||
}) as Collection['meta'][];
|
||||
})) as Collection['meta'][];
|
||||
|
||||
const collections: Collection[] = [];
|
||||
|
||||
@@ -170,9 +170,9 @@ export class CollectionsService {
|
||||
}
|
||||
|
||||
const tablesToFetchInfoFor = tablesInDatabase.map((table) => table.name);
|
||||
const meta = await collectionItemsService.readByQuery({
|
||||
const meta = (await collectionItemsService.readByQuery({
|
||||
filter: { collection: { _in: tablesToFetchInfoFor } },
|
||||
}) as Collection['meta'][];
|
||||
})) as Collection['meta'][];
|
||||
|
||||
const collections: Collection[] = [];
|
||||
|
||||
@@ -287,11 +287,14 @@ export class CollectionsService {
|
||||
for (const relation of relations) {
|
||||
const isM2O = relation.many_collection === collection;
|
||||
|
||||
/** @TODO M2A — Handle m2a case here */
|
||||
|
||||
if (isM2O) {
|
||||
await this.knex('directus_relations')
|
||||
.delete()
|
||||
.where({ many_collection: collection, many_field: relation.many_field });
|
||||
await fieldsService.deleteField(relation.one_collection, relation.one_field);
|
||||
|
||||
await fieldsService.deleteField(relation.one_collection!, relation.one_field!);
|
||||
} else {
|
||||
await this.knex('directus_relations')
|
||||
.update({ one_field: null })
|
||||
|
||||
@@ -52,9 +52,9 @@ export class FieldsService {
|
||||
});
|
||||
|
||||
const columnsWithSystem = columns.map((column) => {
|
||||
const field = fields.find(
|
||||
(field) => field.field === column.name && field.collection === column.table
|
||||
);
|
||||
const field = fields.find((field) => {
|
||||
return field.field === column.name && field.collection === column.table;
|
||||
});
|
||||
|
||||
const data = {
|
||||
collection: column.table,
|
||||
@@ -325,11 +325,13 @@ export class FieldsService {
|
||||
for (const relation of relations) {
|
||||
const isM2O = relation.many_collection === collection && relation.many_field === field;
|
||||
|
||||
/** @TODO M2A — Handle m2a case here */
|
||||
|
||||
if (isM2O) {
|
||||
await this.knex('directus_relations')
|
||||
.delete()
|
||||
.where({ many_collection: collection, many_field: field });
|
||||
await this.deleteField(relation.one_collection, relation.one_field);
|
||||
await this.deleteField(relation.one_collection!, relation.one_field!);
|
||||
} else {
|
||||
await this.knex('directus_relations')
|
||||
.update({ one_field: null })
|
||||
|
||||
@@ -1,7 +1,37 @@
|
||||
import Knex from 'knex';
|
||||
import database from '../database';
|
||||
import { AbstractServiceOptions, Accountability, Collection, Field, Relation, Query, AbstractService } from '../types';
|
||||
import { GraphQLString, GraphQLSchema, GraphQLObjectType, GraphQLList, GraphQLResolveInfo, GraphQLInputObjectType, ObjectFieldNode, GraphQLID, ValueNode, FieldNode, GraphQLFieldConfigMap, GraphQLInt, IntValueNode, StringValueNode, BooleanValueNode, ArgumentNode, GraphQLScalarType, GraphQLBoolean, ObjectValueNode } from 'graphql';
|
||||
import {
|
||||
AbstractServiceOptions,
|
||||
Accountability,
|
||||
Collection,
|
||||
Field,
|
||||
Relation,
|
||||
Query,
|
||||
AbstractService,
|
||||
} from '../types';
|
||||
import {
|
||||
GraphQLString,
|
||||
GraphQLSchema,
|
||||
GraphQLObjectType,
|
||||
GraphQLList,
|
||||
GraphQLResolveInfo,
|
||||
GraphQLInputObjectType,
|
||||
ObjectFieldNode,
|
||||
GraphQLID,
|
||||
ValueNode,
|
||||
FieldNode,
|
||||
GraphQLFieldConfigMap,
|
||||
GraphQLInt,
|
||||
IntValueNode,
|
||||
StringValueNode,
|
||||
BooleanValueNode,
|
||||
ArgumentNode,
|
||||
GraphQLScalarType,
|
||||
GraphQLBoolean,
|
||||
ObjectValueNode,
|
||||
GraphQLUnionType,
|
||||
GraphQLUnionTypeConfig,
|
||||
} from 'graphql';
|
||||
import { getGraphQLType } from '../utils/get-graphql-type';
|
||||
import { RelationsService } from './relations';
|
||||
import { ItemsService } from './items';
|
||||
@@ -21,6 +51,8 @@ import { SettingsService } from './settings';
|
||||
import { UsersService } from './users';
|
||||
import { WebhooksService } from './webhooks';
|
||||
|
||||
import { getRelationType } from '../utils/get-relation-type';
|
||||
|
||||
export class GraphQLService {
|
||||
accountability: Accountability | null;
|
||||
knex: Knex;
|
||||
@@ -38,7 +70,7 @@ export class GraphQLService {
|
||||
|
||||
args = {
|
||||
sort: {
|
||||
type: GraphQLString
|
||||
type: GraphQLString,
|
||||
},
|
||||
limit: {
|
||||
type: GraphQLInt,
|
||||
@@ -51,15 +83,19 @@ export class GraphQLService {
|
||||
},
|
||||
search: {
|
||||
type: GraphQLString,
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
async getSchema() {
|
||||
const collectionsInSystem = await this.collectionsService.readByQuery();
|
||||
const fieldsInSystem = await this.fieldsService.readAll();
|
||||
const relationsInSystem = await this.relationsService.readByQuery({}) as Relation[];
|
||||
const relationsInSystem = (await this.relationsService.readByQuery({})) as Relation[];
|
||||
|
||||
const schema = this.getGraphQLSchema(collectionsInSystem, fieldsInSystem, relationsInSystem);
|
||||
const schema = this.getGraphQLSchema(
|
||||
collectionsInSystem,
|
||||
fieldsInSystem,
|
||||
relationsInSystem
|
||||
);
|
||||
|
||||
return schema;
|
||||
}
|
||||
@@ -77,27 +113,46 @@ export class GraphQLService {
|
||||
description: collection.meta?.note,
|
||||
fields: () => {
|
||||
const fieldsObject: GraphQLFieldConfigMap<any, any> = {};
|
||||
const fieldsInCollection = fields.filter((field) => field.collection === collection.collection);
|
||||
const fieldsInCollection = fields.filter(
|
||||
(field) => field.collection === collection.collection
|
||||
);
|
||||
|
||||
for (const field of fieldsInCollection) {
|
||||
const relationForField = relations.find((relation) => {
|
||||
return relation.many_collection === collection.collection && relation.many_field === field.field ||
|
||||
relation.one_collection === collection.collection && relation.one_field === field.field;
|
||||
return (
|
||||
(relation.many_collection === collection.collection &&
|
||||
relation.many_field === field.field) ||
|
||||
(relation.one_collection === collection.collection &&
|
||||
relation.one_field === field.field)
|
||||
);
|
||||
});
|
||||
|
||||
if (relationForField) {
|
||||
const isM2O = relationForField.many_collection === collection.collection && relationForField.many_field === field.field;
|
||||
const relationType = getRelationType({
|
||||
relation: relationForField,
|
||||
collection: collection.collection,
|
||||
field: field.field,
|
||||
});
|
||||
|
||||
if (isM2O) {
|
||||
const relatedIsSystem = relationForField.one_collection.startsWith('directus_');
|
||||
const relatedType = relatedIsSystem ? schema[relationForField.one_collection.substring(9)].type : schema.items[relationForField.one_collection].type;
|
||||
if (relationType === 'm2o') {
|
||||
const relatedIsSystem = relationForField.one_collection!.startsWith(
|
||||
'directus_'
|
||||
);
|
||||
const relatedType = relatedIsSystem
|
||||
? schema[relationForField.one_collection!.substring(9)].type
|
||||
: schema.items[relationForField.one_collection!].type;
|
||||
|
||||
fieldsObject[field.field] = {
|
||||
type: relatedType,
|
||||
}
|
||||
} else {
|
||||
const relatedIsSystem = relationForField.many_collection.startsWith('directus_');
|
||||
const relatedType = relatedIsSystem ? schema[relationForField.many_collection.substring(9)].type : schema.items[relationForField.many_collection].type;
|
||||
};
|
||||
} else if (relationType === 'o2m') {
|
||||
const relatedIsSystem = relationForField.many_collection.startsWith(
|
||||
'directus_'
|
||||
);
|
||||
|
||||
const relatedType = relatedIsSystem
|
||||
? schema[relationForField.many_collection.substring(9)].type
|
||||
: schema.items[relationForField.many_collection].type;
|
||||
|
||||
fieldsObject[field.field] = {
|
||||
type: new GraphQLList(relatedType),
|
||||
@@ -105,14 +160,44 @@ export class GraphQLService {
|
||||
...this.args,
|
||||
filter: {
|
||||
type: filterTypes[relationForField.many_collection],
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
} else if (relationType === 'm2a') {
|
||||
const relatedCollections = relationForField.one_allowed_collections!;
|
||||
|
||||
const types: any = [];
|
||||
|
||||
for (const relatedCollection of relatedCollections) {
|
||||
const relatedType = relatedCollection.startsWith(
|
||||
'directus_'
|
||||
)
|
||||
? schema[relatedCollection.substring(9)].type
|
||||
: schema.items[relatedCollection].type;
|
||||
|
||||
types.push(relatedType);
|
||||
}
|
||||
|
||||
fieldsObject[field.field] = {
|
||||
type: new GraphQLUnionType({
|
||||
name: field.field,
|
||||
types,
|
||||
resolveType(value, _, info) {
|
||||
/**
|
||||
* @TODO figure out a way to reach the parent level
|
||||
* to be able to read one_collection_field
|
||||
*/
|
||||
return types[0];
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
fieldsObject[field.field] = {
|
||||
type: field.schema?.is_primary_key ? GraphQLID : getGraphQLType(field.type),
|
||||
}
|
||||
type: field.schema?.is_primary_key
|
||||
? GraphQLID
|
||||
: getGraphQLType(field.type),
|
||||
};
|
||||
}
|
||||
|
||||
fieldsObject[field.field].description = field.meta?.note;
|
||||
@@ -121,14 +206,15 @@ export class GraphQLService {
|
||||
return fieldsObject;
|
||||
},
|
||||
}),
|
||||
resolve: (source: any, args: any, context: any, info: GraphQLResolveInfo) => this.resolve(info),
|
||||
resolve: (source: any, args: any, context: any, info: GraphQLResolveInfo) =>
|
||||
this.resolve(info),
|
||||
args: {
|
||||
...this.args,
|
||||
filter: {
|
||||
name: `${collection.collection}_filter`,
|
||||
type: filterTypes[collection.collection],
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (systemCollection) {
|
||||
@@ -145,9 +231,13 @@ export class GraphQLService {
|
||||
const systemCollection = collection.collection.startsWith('directus_');
|
||||
|
||||
if (systemCollection) {
|
||||
schemaWithLists[collection.collection.substring(9)].type = new GraphQLList(schemaWithLists[collection.collection.substring(9)].type);
|
||||
schemaWithLists[collection.collection.substring(9)].type = new GraphQLList(
|
||||
schemaWithLists[collection.collection.substring(9)].type
|
||||
);
|
||||
} else {
|
||||
schemaWithLists.items[collection.collection].type = new GraphQLList(schemaWithLists.items[collection.collection].type);
|
||||
schemaWithLists.items[collection.collection].type = new GraphQLList(
|
||||
schemaWithLists.items[collection.collection].type
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -177,39 +267,55 @@ export class GraphQLService {
|
||||
fields: () => {
|
||||
const filterFields: any = {
|
||||
_and: {
|
||||
type: new GraphQLList(filterTypes[collection.collection])
|
||||
type: new GraphQLList(filterTypes[collection.collection]),
|
||||
},
|
||||
_or: {
|
||||
type: new GraphQLList(filterTypes[collection.collection])
|
||||
type: new GraphQLList(filterTypes[collection.collection]),
|
||||
},
|
||||
};
|
||||
|
||||
const fieldsInCollection = fields.filter((field) => field.collection === collection.collection);
|
||||
const fieldsInCollection = fields.filter(
|
||||
(field) => field.collection === collection.collection
|
||||
);
|
||||
|
||||
for (const field of fieldsInCollection) {
|
||||
const relationForField = relations.find((relation) => {
|
||||
return relation.many_collection === collection.collection && relation.many_field === field.field ||
|
||||
relation.one_collection === collection.collection && relation.one_field === field.field;
|
||||
return (
|
||||
(relation.many_collection === collection.collection &&
|
||||
relation.many_field === field.field) ||
|
||||
(relation.one_collection === collection.collection &&
|
||||
relation.one_field === field.field)
|
||||
);
|
||||
});
|
||||
|
||||
if (relationForField) {
|
||||
const isM2O = relationForField.many_collection === collection.collection && relationForField.many_field === field.field;
|
||||
const relationType = getRelationType({
|
||||
relation: relationForField,
|
||||
collection: collection.collection,
|
||||
field: field.field,
|
||||
});
|
||||
|
||||
if (isM2O) {
|
||||
const relatedType = filterTypes[relationForField.one_collection];
|
||||
if (relationType === 'm2o') {
|
||||
const relatedType = filterTypes[relationForField.one_collection!];
|
||||
|
||||
filterFields[field.field] = {
|
||||
type: relatedType,
|
||||
}
|
||||
} else {
|
||||
};
|
||||
} else if (relationType === 'o2m') {
|
||||
const relatedType = filterTypes[relationForField.many_collection];
|
||||
|
||||
filterFields[field.field] = {
|
||||
type: relatedType
|
||||
}
|
||||
type: relatedType,
|
||||
};
|
||||
}
|
||||
/** @TODO M2A — Handle m2a case here */
|
||||
/** @TODO
|
||||
* Figure out how to setup filter fields for a union type output
|
||||
*/
|
||||
} else {
|
||||
const fieldType = field.schema?.is_primary_key ? GraphQLID : getGraphQLType(field.type);
|
||||
const fieldType = field.schema?.is_primary_key
|
||||
? GraphQLID
|
||||
: getGraphQLType(field.type);
|
||||
|
||||
filterFields[field.field] = {
|
||||
type: new GraphQLInputObjectType({
|
||||
@@ -220,7 +326,7 @@ export class GraphQLService {
|
||||
type: fieldType,
|
||||
},
|
||||
_neq: {
|
||||
type: fieldType
|
||||
type: fieldType,
|
||||
},
|
||||
_contains: {
|
||||
type: fieldType,
|
||||
@@ -257,10 +363,10 @@ export class GraphQLService {
|
||||
},
|
||||
_nempty: {
|
||||
type: GraphQLBoolean,
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,20 +375,26 @@ export class GraphQLService {
|
||||
});
|
||||
}
|
||||
|
||||
return filterTypes
|
||||
return filterTypes;
|
||||
}
|
||||
|
||||
async resolve(info: GraphQLResolveInfo) {
|
||||
const systemField = info.path.prev?.key !== 'items';
|
||||
|
||||
const collection = systemField ? `directus_${info.fieldName}` : info.fieldName;
|
||||
const selections = info.fieldNodes[0]?.selectionSet?.selections?.filter((node) => node.kind === 'Field') as FieldNode[] | undefined;
|
||||
const selections = info.fieldNodes[0]?.selectionSet?.selections?.filter(
|
||||
(node) => node.kind === 'Field'
|
||||
) as FieldNode[] | undefined;
|
||||
if (!selections) return null;
|
||||
|
||||
return await this.getData(collection, selections, info.fieldNodes[0].arguments);
|
||||
}
|
||||
|
||||
async getData(collection: string, selections: FieldNode[], argsArray?: readonly ArgumentNode[]) {
|
||||
async getData(
|
||||
collection: string,
|
||||
selections: FieldNode[],
|
||||
argsArray?: readonly ArgumentNode[]
|
||||
) {
|
||||
const args: Record<string, any> = this.parseArgs(argsArray);
|
||||
|
||||
const query: Query = sanitizeQuery(args, this.accountability);
|
||||
@@ -296,7 +408,12 @@ export class GraphQLService {
|
||||
if (selection.selectionSet === undefined) {
|
||||
fields.push(current);
|
||||
} else {
|
||||
const children = parseFields(selection.selectionSet.selections.filter((selection) => selection.kind === 'Field') as FieldNode[], current);
|
||||
const children = parseFields(
|
||||
selection.selectionSet.selections.filter(
|
||||
(selection) => selection.kind === 'Field'
|
||||
) as FieldNode[],
|
||||
current
|
||||
);
|
||||
fields.push(...children);
|
||||
}
|
||||
|
||||
@@ -309,47 +426,95 @@ export class GraphQLService {
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
};
|
||||
|
||||
query.fields = parseFields(selections.filter((selection) => selection.kind === 'Field') as FieldNode[]);
|
||||
query.fields = parseFields(
|
||||
selections.filter((selection) => selection.kind === 'Field') as FieldNode[]
|
||||
);
|
||||
|
||||
let service: ItemsService;
|
||||
|
||||
switch (collection) {
|
||||
case 'directus_activity':
|
||||
service = new ActivityService({ knex: this.knex, accountability: this.accountability });
|
||||
service = new ActivityService({
|
||||
knex: this.knex,
|
||||
accountability: this.accountability,
|
||||
});
|
||||
// case 'directus_collections':
|
||||
// service = new CollectionsService({ knex: this.knex, accountability: this.accountability });
|
||||
// case 'directus_fields':
|
||||
// service = new FieldsService({ knex: this.knex, accountability: this.accountability });
|
||||
case 'directus_files':
|
||||
service = new FilesService({ knex: this.knex, accountability: this.accountability });
|
||||
service = new FilesService({
|
||||
knex: this.knex,
|
||||
accountability: this.accountability,
|
||||
});
|
||||
case 'directus_folders':
|
||||
service = new FoldersService({ knex: this.knex, accountability: this.accountability });
|
||||
service = new FoldersService({
|
||||
knex: this.knex,
|
||||
accountability: this.accountability,
|
||||
});
|
||||
case 'directus_folders':
|
||||
service = new FoldersService({ knex: this.knex, accountability: this.accountability });
|
||||
service = new FoldersService({
|
||||
knex: this.knex,
|
||||
accountability: this.accountability,
|
||||
});
|
||||
case 'directus_permissions':
|
||||
service = new PermissionsService({ knex: this.knex, accountability: this.accountability });
|
||||
service = new PermissionsService({
|
||||
knex: this.knex,
|
||||
accountability: this.accountability,
|
||||
});
|
||||
case 'directus_presets':
|
||||
service = new PresetsService({ knex: this.knex, accountability: this.accountability });
|
||||
service = new PresetsService({
|
||||
knex: this.knex,
|
||||
accountability: this.accountability,
|
||||
});
|
||||
case 'directus_relations':
|
||||
service = new RelationsService({ knex: this.knex, accountability: this.accountability });
|
||||
service = new RelationsService({
|
||||
knex: this.knex,
|
||||
accountability: this.accountability,
|
||||
});
|
||||
case 'directus_revisions':
|
||||
service = new RevisionsService({ knex: this.knex, accountability: this.accountability });
|
||||
service = new RevisionsService({
|
||||
knex: this.knex,
|
||||
accountability: this.accountability,
|
||||
});
|
||||
case 'directus_roles':
|
||||
service = new RolesService({ knex: this.knex, accountability: this.accountability });
|
||||
service = new RolesService({
|
||||
knex: this.knex,
|
||||
accountability: this.accountability,
|
||||
});
|
||||
case 'directus_settings':
|
||||
service = new SettingsService({ knex: this.knex, accountability: this.accountability });
|
||||
service = new SettingsService({
|
||||
knex: this.knex,
|
||||
accountability: this.accountability,
|
||||
});
|
||||
case 'directus_users':
|
||||
service = new UsersService({ knex: this.knex, accountability: this.accountability });
|
||||
service = new UsersService({
|
||||
knex: this.knex,
|
||||
accountability: this.accountability,
|
||||
});
|
||||
case 'directus_webhooks':
|
||||
service = new WebhooksService({ knex: this.knex, accountability: this.accountability });
|
||||
service = new WebhooksService({
|
||||
knex: this.knex,
|
||||
accountability: this.accountability,
|
||||
});
|
||||
default:
|
||||
service = new ItemsService(collection, { knex: this.knex, accountability: this.accountability });
|
||||
service = new ItemsService(collection, {
|
||||
knex: this.knex,
|
||||
accountability: this.accountability,
|
||||
});
|
||||
}
|
||||
|
||||
const collectionInfo = await this.knex.select('singleton').from('directus_collections').where({ collection: collection }).first();
|
||||
const result = collectionInfo?.singleton === true ? await service.readSingleton(query) : await service.readByQuery(query);
|
||||
const collectionInfo = await this.knex
|
||||
.select('singleton')
|
||||
.from('directus_collections')
|
||||
.where({ collection: collection })
|
||||
.first();
|
||||
const result =
|
||||
collectionInfo?.singleton === true
|
||||
? await service.readSingleton(query)
|
||||
: await service.readByQuery(query);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -359,7 +524,7 @@ export class GraphQLService {
|
||||
|
||||
const parseObjectValue = (arg: ObjectValueNode) => {
|
||||
return this.parseArgs(arg.fields);
|
||||
}
|
||||
};
|
||||
|
||||
const argsObject: any = {};
|
||||
|
||||
@@ -379,11 +544,13 @@ export class GraphQLService {
|
||||
|
||||
argsObject[argument.name.value] = values;
|
||||
} else {
|
||||
argsObject[argument.name.value] = (argument.value as IntValueNode | StringValueNode | BooleanValueNode).value;
|
||||
argsObject[argument.name.value] = (argument.value as
|
||||
| IntValueNode
|
||||
| StringValueNode
|
||||
| BooleanValueNode).value;
|
||||
}
|
||||
}
|
||||
|
||||
return argsObject;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,4 +19,4 @@ export * from './settings';
|
||||
export * from './users';
|
||||
export * from './utils';
|
||||
export * from './webhooks';
|
||||
export * from './specifications'
|
||||
export * from './specifications';
|
||||
|
||||
@@ -302,6 +302,8 @@ export class PayloadService {
|
||||
});
|
||||
|
||||
for (const relation of relationsToProcess) {
|
||||
if (!relation.one_collection || !relation.one_primary) continue;
|
||||
|
||||
const itemsService = new ItemsService(relation.one_collection, {
|
||||
accountability: this.accountability,
|
||||
knex: this.knex,
|
||||
@@ -343,6 +345,8 @@ export class PayloadService {
|
||||
|
||||
// Only process related records that are actually in the payload
|
||||
const relationsToProcess = relations.filter((relation) => {
|
||||
if (!relation.one_field) return false;
|
||||
|
||||
return (
|
||||
payload.hasOwnProperty(relation.one_field) &&
|
||||
Array.isArray(payload[relation.one_field])
|
||||
@@ -357,7 +361,7 @@ export class PayloadService {
|
||||
|
||||
const relatedRecords: Partial<Item>[] = [];
|
||||
|
||||
for (const relatedRecord of payload[relation.one_field]) {
|
||||
for (const relatedRecord of payload[relation.one_field!]) {
|
||||
let record = cloneDeep(relatedRecord);
|
||||
|
||||
if (typeof relatedRecord === 'string' || typeof relatedRecord === 'number') {
|
||||
@@ -380,7 +384,7 @@ export class PayloadService {
|
||||
|
||||
relatedRecords.push({
|
||||
...record,
|
||||
[relation.many_field]: parent || payload[relation.one_primary],
|
||||
[relation.many_field]: parent || payload[relation.one_primary!],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AbstractServiceOptions, PermissionsAction } from '../types';
|
||||
import { AbstractServiceOptions, PermissionsAction, Item, PrimaryKey } from '../types';
|
||||
import { ItemsService } from '../services/items';
|
||||
|
||||
export class PermissionsService extends ItemsService {
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import { ItemsService } from './items';
|
||||
import {
|
||||
AbstractServiceOptions,
|
||||
Query,
|
||||
Item,
|
||||
PrimaryKey,
|
||||
PermissionsAction,
|
||||
Relation,
|
||||
} from '../types';
|
||||
import { AbstractServiceOptions, Query, PrimaryKey, PermissionsAction, Relation } from '../types';
|
||||
import { PermissionsService } from './permissions';
|
||||
|
||||
/**
|
||||
* @TODO update foreign key constraints when relations are updated
|
||||
*/
|
||||
|
||||
type ParsedRelation = Relation & {
|
||||
one_allowed_collections: string[] | null;
|
||||
};
|
||||
|
||||
export class RelationsService extends ItemsService {
|
||||
permissionsService: PermissionsService;
|
||||
|
||||
@@ -22,7 +19,11 @@ export class RelationsService extends ItemsService {
|
||||
}
|
||||
|
||||
async readByQuery(query: Query): Promise<null | Relation | Relation[]> {
|
||||
const results = (await super.readByQuery(query)) as Relation | Relation[] | null;
|
||||
const service = new ItemsService('directus_relations', { knex: this.knex });
|
||||
const results = (await service.readByQuery(query)) as
|
||||
| ParsedRelation
|
||||
| ParsedRelation[]
|
||||
| null;
|
||||
const filteredResults = await this.filterForbidden(results);
|
||||
return filteredResults;
|
||||
}
|
||||
@@ -38,15 +39,17 @@ export class RelationsService extends ItemsService {
|
||||
query: Query = {},
|
||||
action: PermissionsAction = 'read'
|
||||
): Promise<null | Relation | Relation[]> {
|
||||
const results = (await super.readByKey(key as any, query, action)) as
|
||||
| Relation
|
||||
| Relation[]
|
||||
const service = new ItemsService('directus_relations', { knex: this.knex });
|
||||
const results = (await service.readByKey(key as any, query, action)) as
|
||||
| ParsedRelation
|
||||
| ParsedRelation[]
|
||||
| null;
|
||||
|
||||
const filteredResults = await this.filterForbidden(results);
|
||||
return filteredResults;
|
||||
}
|
||||
|
||||
private async filterForbidden(relations: Relation | Relation[] | null) {
|
||||
private async filterForbidden(relations: ParsedRelation | ParsedRelation[] | null) {
|
||||
if (relations === null) return null;
|
||||
if (this.accountability === null || this.accountability?.admin === true) return relations;
|
||||
|
||||
@@ -62,17 +65,47 @@ export class RelationsService extends ItemsService {
|
||||
relations = Array.isArray(relations) ? relations : [relations];
|
||||
|
||||
return relations.filter((relation) => {
|
||||
const collectionsAllowed =
|
||||
allowedCollections.includes(relation.many_collection) &&
|
||||
allowedCollections.includes(relation.one_collection);
|
||||
let collectionsAllowed = true;
|
||||
let fieldsAllowed = true;
|
||||
|
||||
const fieldsAllowed =
|
||||
allowedFields[relation.one_collection] &&
|
||||
allowedFields[relation.many_collection] &&
|
||||
(allowedFields[relation.many_collection].includes('*') ||
|
||||
allowedFields[relation.many_collection].includes(relation.many_field)) &&
|
||||
(allowedFields[relation.one_collection].includes('*') ||
|
||||
allowedFields[relation.one_collection].includes(relation.one_field));
|
||||
if (allowedCollections.includes(relation.many_collection) === false) {
|
||||
collectionsAllowed = false;
|
||||
}
|
||||
|
||||
if (
|
||||
relation.one_collection &&
|
||||
allowedCollections.includes(relation.one_collection) === false
|
||||
) {
|
||||
collectionsAllowed = false;
|
||||
}
|
||||
|
||||
if (
|
||||
relation.one_allowed_collections &&
|
||||
relation.one_allowed_collections.every((collection) =>
|
||||
allowedCollections.includes(collection)
|
||||
) === false
|
||||
) {
|
||||
collectionsAllowed = false;
|
||||
}
|
||||
|
||||
if (
|
||||
!allowedFields[relation.many_collection] ||
|
||||
(allowedFields[relation.many_collection].includes('*') === false &&
|
||||
allowedFields[relation.many_collection].includes(relation.many_field) === false)
|
||||
) {
|
||||
fieldsAllowed = false;
|
||||
}
|
||||
|
||||
if (
|
||||
relation.one_collection &&
|
||||
relation.one_field &&
|
||||
(!allowedFields[relation.one_collection] ||
|
||||
(allowedFields[relation.one_collection].includes('*') === false &&
|
||||
allowedFields[relation.one_collection].includes(relation.one_field) ===
|
||||
false))
|
||||
) {
|
||||
fieldsAllowed = false;
|
||||
}
|
||||
|
||||
return collectionsAllowed && fieldsAllowed;
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { AbstractServiceOptions, PrimaryKey } from '../types';
|
||||
import { PermissionsService } from './permissions';
|
||||
import { UsersService } from './users';
|
||||
import { PresetsService } from './presets';
|
||||
import { UnprocessableEntityException } from '../exceptions';
|
||||
|
||||
export class RolesService extends ItemsService {
|
||||
constructor(options?: AbstractServiceOptions) {
|
||||
@@ -14,15 +15,28 @@ export class RolesService extends ItemsService {
|
||||
async delete(key: PrimaryKey | PrimaryKey[]): Promise<PrimaryKey | PrimaryKey[]> {
|
||||
const keys = Array.isArray(key) ? key : [key];
|
||||
|
||||
// Make sure there's at least one admin role left after this deletion is done
|
||||
const otherAdminRoles = await this.knex
|
||||
.count('*', { as: 'count' })
|
||||
.from('directus_roles')
|
||||
.whereNotIn('id', keys)
|
||||
.andWhere({ admin_access: true })
|
||||
.first();
|
||||
const otherAdminRolesCount = +(otherAdminRoles?.count || 0);
|
||||
if (otherAdminRolesCount === 0)
|
||||
throw new UnprocessableEntityException(`You can't delete the last admin role.`);
|
||||
|
||||
// Remove all permissions associated with this role
|
||||
const permissionsService = new PermissionsService({
|
||||
knex: this.knex,
|
||||
accountability: this.accountability,
|
||||
});
|
||||
const permissionsForRole = await permissionsService.readByQuery({
|
||||
|
||||
const permissionsForRole = (await permissionsService.readByQuery({
|
||||
fields: ['id'],
|
||||
filter: { role: { _in: keys } },
|
||||
}) as { id: number }[];
|
||||
})) as { id: number }[];
|
||||
|
||||
const permissionIDs = permissionsForRole.map((permission) => permission.id);
|
||||
await permissionsService.delete(permissionIDs);
|
||||
|
||||
@@ -31,10 +45,12 @@ export class RolesService extends ItemsService {
|
||||
knex: this.knex,
|
||||
accountability: this.accountability,
|
||||
});
|
||||
const presetsForRole = await presetsService.readByQuery({
|
||||
|
||||
const presetsForRole = (await presetsService.readByQuery({
|
||||
fields: ['id'],
|
||||
filter: { role: { _in: keys } },
|
||||
}) as { id: string }[];
|
||||
})) as { id: string }[];
|
||||
|
||||
const presetIDs = presetsForRole.map((preset) => preset.id);
|
||||
await presetsService.delete(presetIDs);
|
||||
|
||||
@@ -43,10 +59,12 @@ export class RolesService extends ItemsService {
|
||||
knex: this.knex,
|
||||
accountability: this.accountability,
|
||||
});
|
||||
const usersInRole = await usersService.readByQuery({
|
||||
|
||||
const usersInRole = (await usersService.readByQuery({
|
||||
fields: ['id'],
|
||||
filter: { role: { _in: keys } },
|
||||
}) as { id: string }[];
|
||||
})) as { id: string }[];
|
||||
|
||||
const userIDs = usersInRole.map((user) => user.id);
|
||||
await usersService.update({ status: 'suspended', role: null }, userIDs);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Accountability,
|
||||
Collection,
|
||||
Field,
|
||||
Permission,
|
||||
Relation,
|
||||
types,
|
||||
} from '../types';
|
||||
@@ -12,17 +13,25 @@ import formatTitle from '@directus/format-title';
|
||||
import { cloneDeep, mergeWith } from 'lodash';
|
||||
import { RelationsService } from './relations';
|
||||
import env from '../env';
|
||||
import {
|
||||
OpenAPIObject,
|
||||
PathItemObject,
|
||||
OperationObject,
|
||||
TagObject,
|
||||
SchemaObject,
|
||||
} from 'openapi3-ts';
|
||||
|
||||
// @ts-ignore
|
||||
import { version } from '../../package.json';
|
||||
|
||||
// @ts-ignore
|
||||
import openapi from '@directus/specs';
|
||||
|
||||
type RelationTree = Record<string, Record<string, Relation[]>>;
|
||||
import Knex from 'knex';
|
||||
import database from '../database';
|
||||
import { getRelationType } from '../utils/get-relation-type';
|
||||
|
||||
export class SpecificationService {
|
||||
accountability: Accountability | null;
|
||||
knex: Knex;
|
||||
|
||||
fieldsService: FieldsService;
|
||||
collectionsService: CollectionsService;
|
||||
@@ -32,16 +41,20 @@ export class SpecificationService {
|
||||
|
||||
constructor(options?: AbstractServiceOptions) {
|
||||
this.accountability = options?.accountability || null;
|
||||
this.knex = options?.knex || database;
|
||||
|
||||
this.fieldsService = new FieldsService(options);
|
||||
this.collectionsService = new CollectionsService(options);
|
||||
this.relationsService = new RelationsService(options);
|
||||
|
||||
this.oas = new OASService({
|
||||
fieldsService: this.fieldsService,
|
||||
collectionsService: this.collectionsService,
|
||||
relationsService: this.relationsService,
|
||||
});
|
||||
this.oas = new OASService(
|
||||
{ knex: this.knex, accountability: this.accountability },
|
||||
{
|
||||
fieldsService: this.fieldsService,
|
||||
collectionsService: this.collectionsService,
|
||||
relationsService: this.relationsService,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,34 +63,462 @@ interface SpecificationSubService {
|
||||
}
|
||||
|
||||
class OASService implements SpecificationSubService {
|
||||
accountability: Accountability | null;
|
||||
knex: Knex;
|
||||
|
||||
fieldsService: FieldsService;
|
||||
collectionsService: CollectionsService;
|
||||
relationsService: RelationsService;
|
||||
|
||||
constructor({
|
||||
fieldsService,
|
||||
collectionsService,
|
||||
relationsService,
|
||||
}: {
|
||||
fieldsService: FieldsService;
|
||||
collectionsService: CollectionsService;
|
||||
relationsService: RelationsService;
|
||||
}) {
|
||||
constructor(
|
||||
options: AbstractServiceOptions,
|
||||
{
|
||||
fieldsService,
|
||||
collectionsService,
|
||||
relationsService,
|
||||
}: {
|
||||
fieldsService: FieldsService;
|
||||
collectionsService: CollectionsService;
|
||||
relationsService: RelationsService;
|
||||
}
|
||||
) {
|
||||
this.accountability = options.accountability || null;
|
||||
this.knex = options?.knex || database;
|
||||
|
||||
this.fieldsService = fieldsService;
|
||||
this.collectionsService = collectionsService;
|
||||
this.relationsService = relationsService;
|
||||
}
|
||||
|
||||
private collectionsDenyList = [
|
||||
'directus_collections',
|
||||
'directus_fields',
|
||||
'directus_migrations',
|
||||
'directus_sessions',
|
||||
];
|
||||
async generate() {
|
||||
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 tags = await this.generateTags(collections);
|
||||
const paths = await this.generatePaths(permissions, tags);
|
||||
const components = await this.generateComponents(collections, fields, relations, tags);
|
||||
|
||||
const spec: OpenAPIObject = {
|
||||
openapi: '3.0.1',
|
||||
info: {
|
||||
title: 'Dynamic API Specification',
|
||||
description:
|
||||
'This is a dynamicly generated API specification for all endpoints existing on the current .',
|
||||
version: version,
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: env.PUBLIC_URL,
|
||||
description: 'Your current Directus instance.',
|
||||
},
|
||||
],
|
||||
tags,
|
||||
paths,
|
||||
components,
|
||||
};
|
||||
|
||||
return spec;
|
||||
}
|
||||
|
||||
private async generateTags(collections: Collection[]): Promise<OpenAPIObject['tags']> {
|
||||
const systemTags = cloneDeep(openapi.tags)!;
|
||||
|
||||
const tags: OpenAPIObject['tags'] = [];
|
||||
|
||||
// System tags that don't have an associated collection are always readable to the user
|
||||
for (const systemTag of systemTags) {
|
||||
if (!systemTag['x-collection']) {
|
||||
tags.push(systemTag);
|
||||
}
|
||||
}
|
||||
|
||||
for (const collection of collections) {
|
||||
const isSystem = collection.collection.startsWith('directus_');
|
||||
|
||||
// If the collection is one of the system collections, pull the tag from the static spec
|
||||
if (isSystem) {
|
||||
for (const tag of openapi.tags!) {
|
||||
if (tag['x-collection'] === collection.collection) {
|
||||
tags.push(tag);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tags.push({
|
||||
name: 'Items' + formatTitle(collection.collection).replace(/ /g, ''),
|
||||
description: collection.meta?.note || undefined,
|
||||
'x-collection': collection.collection,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out the generic Items information
|
||||
return tags.filter((tag) => tag.name !== 'Items');
|
||||
}
|
||||
|
||||
private async generatePaths(
|
||||
permissions: Permission[],
|
||||
tags: OpenAPIObject['tags']
|
||||
): Promise<OpenAPIObject['paths']> {
|
||||
const paths: OpenAPIObject['paths'] = {};
|
||||
|
||||
if (!tags) return paths;
|
||||
|
||||
for (const tag of tags) {
|
||||
const isSystem =
|
||||
tag.hasOwnProperty('x-collection') === false ||
|
||||
tag['x-collection'].startsWith('directus_');
|
||||
|
||||
if (isSystem) {
|
||||
for (const [path, pathItem] of Object.entries<PathItemObject>(openapi.paths)) {
|
||||
for (const [method, operation] of Object.entries<OperationObject>(pathItem)) {
|
||||
if (operation.tags?.includes(tag.name)) {
|
||||
if (!paths[path]) {
|
||||
paths[path] = {};
|
||||
}
|
||||
|
||||
const hasPermission =
|
||||
this.accountability?.admin === true ||
|
||||
tag.hasOwnProperty('x-collection') === false ||
|
||||
!!permissions.find(
|
||||
(permission) =>
|
||||
permission.collection === tag['x-collection'] &&
|
||||
permission.action === this.getActionForMethod(method)
|
||||
);
|
||||
|
||||
if (hasPermission) {
|
||||
paths[path][method] = operation;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const listBase = cloneDeep(openapi.paths['/items/{collection}']);
|
||||
const detailBase = cloneDeep(openapi.paths['/items/{collection}/{id}']);
|
||||
const collection = tag['x-collection'];
|
||||
|
||||
for (const method of ['post', 'get', 'patch', 'delete']) {
|
||||
const hasPermission =
|
||||
this.accountability?.admin === true ||
|
||||
!!permissions.find(
|
||||
(permission) =>
|
||||
permission.collection === collection &&
|
||||
permission.action === this.getActionForMethod(method)
|
||||
);
|
||||
|
||||
if (hasPermission) {
|
||||
if (!paths[`/items/${collection}`]) paths[`/items/${collection}`] = {};
|
||||
if (!paths[`/items/${collection}/{id}`])
|
||||
paths[`/items/${collection}/{id}`] = {};
|
||||
|
||||
if (listBase[method]) {
|
||||
paths[`/items/${collection}`][method] = mergeWith(
|
||||
cloneDeep(listBase[method]),
|
||||
{
|
||||
description: listBase[method].description.replace(
|
||||
'item',
|
||||
collection + ' item'
|
||||
),
|
||||
tags: [tag.name],
|
||||
operationId: `${this.getActionForMethod(method)}${tag.name}`,
|
||||
requestBody: ['get', 'delete'].includes(method)
|
||||
? undefined
|
||||
: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
oneOf: [
|
||||
{
|
||||
type: 'array',
|
||||
items: {
|
||||
$ref: `#/components/schema/${tag.name}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
$ref: `#/components/schema/${tag.name}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
'200': {
|
||||
content:
|
||||
method === 'delete'
|
||||
? undefined
|
||||
: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
properties: {
|
||||
data: {
|
||||
items: {
|
||||
$ref: `#/components/schema/${tag.name}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
(obj, src) => {
|
||||
if (Array.isArray(obj)) return obj.concat(src);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (detailBase[method]) {
|
||||
paths[`/items/${collection}/{id}`][method] = mergeWith(
|
||||
cloneDeep(detailBase[method]),
|
||||
{
|
||||
description: detailBase[method].description.replace(
|
||||
'item',
|
||||
collection + ' item'
|
||||
),
|
||||
tags: [tag.name],
|
||||
operationId: `${this.getActionForMethod(method)}Single${
|
||||
tag.name
|
||||
}`,
|
||||
requestBody: ['get', 'delete'].includes(method)
|
||||
? undefined
|
||||
: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
$ref: `#/components/schema/${tag.name}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
'200': {
|
||||
content:
|
||||
method === 'delete'
|
||||
? undefined
|
||||
: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
properties: {
|
||||
data: {
|
||||
items: {
|
||||
$ref: `#/components/schema/${tag.name}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
(obj, src) => {
|
||||
if (Array.isArray(obj)) return obj.concat(src);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
private async generateComponents(
|
||||
collections: Collection[],
|
||||
fields: Field[],
|
||||
relations: Relation[],
|
||||
tags: OpenAPIObject['tags']
|
||||
): Promise<OpenAPIObject['components']> {
|
||||
let components: OpenAPIObject['components'] = cloneDeep(openapi.components);
|
||||
|
||||
if (!components) components = {};
|
||||
|
||||
components.schemas = {};
|
||||
|
||||
if (!tags) return;
|
||||
|
||||
for (const collection of collections) {
|
||||
const tag = tags.find((tag) => tag['x-collection'] === collection.collection);
|
||||
|
||||
if (!tag) continue;
|
||||
|
||||
const isSystem = collection.collection.startsWith('directus_');
|
||||
|
||||
const fieldsInCollection = fields.filter(
|
||||
(field) => field.collection === collection.collection
|
||||
);
|
||||
|
||||
if (isSystem) {
|
||||
const schemaComponent: SchemaObject = cloneDeep(
|
||||
openapi.components!.schemas![tag.name]
|
||||
);
|
||||
|
||||
schemaComponent.properties = {};
|
||||
|
||||
for (const field of fieldsInCollection) {
|
||||
schemaComponent.properties[field.field] =
|
||||
(cloneDeep(
|
||||
(openapi.components!.schemas![tag.name] as SchemaObject).properties![
|
||||
field.field
|
||||
]
|
||||
) as SchemaObject) || this.generateField(field, relations, tags, fields);
|
||||
}
|
||||
|
||||
components.schemas[tag.name] = schemaComponent;
|
||||
} else {
|
||||
const schemaComponent: SchemaObject = {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
'x-collection': collection.collection,
|
||||
};
|
||||
|
||||
for (const field of fieldsInCollection) {
|
||||
schemaComponent.properties![field.field] = this.generateField(
|
||||
field,
|
||||
relations,
|
||||
tags,
|
||||
fields
|
||||
);
|
||||
}
|
||||
|
||||
components.schemas[tag.name] = schemaComponent;
|
||||
}
|
||||
}
|
||||
|
||||
return components;
|
||||
}
|
||||
|
||||
private getActionForMethod(method: string): 'create' | 'read' | 'update' | 'delete' {
|
||||
switch (method) {
|
||||
case 'post':
|
||||
return 'create';
|
||||
case 'patch':
|
||||
return 'update';
|
||||
case 'delete':
|
||||
return 'delete';
|
||||
case 'get':
|
||||
default:
|
||||
return 'read';
|
||||
}
|
||||
}
|
||||
|
||||
private generateField(
|
||||
field: Field,
|
||||
relations: Relation[],
|
||||
tags: TagObject[],
|
||||
fields: Field[]
|
||||
): SchemaObject {
|
||||
let propertyObject: SchemaObject = {
|
||||
nullable: field.schema?.is_nullable,
|
||||
description: field.meta?.note || undefined,
|
||||
};
|
||||
|
||||
const relation = relations.find(
|
||||
(relation) =>
|
||||
(relation.many_collection === field.collection &&
|
||||
relation.many_field === field.field) ||
|
||||
(relation.one_collection === field.collection && relation.one_field === field.field)
|
||||
);
|
||||
|
||||
if (!relation) {
|
||||
propertyObject = {
|
||||
...propertyObject,
|
||||
...this.fieldTypes[field.type],
|
||||
};
|
||||
} else {
|
||||
const relationType = getRelationType({
|
||||
relation,
|
||||
field: field.field,
|
||||
collection: field.collection,
|
||||
});
|
||||
|
||||
if (relationType === 'm2o') {
|
||||
const relatedTag = tags.find(
|
||||
(tag) => tag['x-collection'] === relation.one_collection
|
||||
);
|
||||
const relatedPrimaryKeyField = fields.find(
|
||||
(field) =>
|
||||
field.collection === relation.one_collection && field.schema?.is_primary_key
|
||||
);
|
||||
|
||||
if (!relatedTag || !relatedPrimaryKeyField) return propertyObject;
|
||||
|
||||
propertyObject.oneOf = [
|
||||
{
|
||||
...this.fieldTypes[relatedPrimaryKeyField.type],
|
||||
},
|
||||
{
|
||||
$ref: `#/components/schemas/${relatedTag.name}`,
|
||||
},
|
||||
];
|
||||
} else if (relationType === 'o2m') {
|
||||
const relatedTag = tags.find(
|
||||
(tag) => tag['x-collection'] === relation.many_collection
|
||||
);
|
||||
const relatedPrimaryKeyField = fields.find(
|
||||
(field) =>
|
||||
field.collection === relation.many_collection &&
|
||||
field.schema?.is_primary_key
|
||||
);
|
||||
|
||||
if (!relatedTag || !relatedPrimaryKeyField) return propertyObject;
|
||||
|
||||
propertyObject.type = 'array';
|
||||
propertyObject.items = {
|
||||
oneOf: [
|
||||
{
|
||||
...this.fieldTypes[relatedPrimaryKeyField.type],
|
||||
},
|
||||
{
|
||||
$ref: `#/components/schemas/${relatedTag.name}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} else if (relationType === 'm2a') {
|
||||
const relatedTags = tags.filter((tag) =>
|
||||
relation.one_allowed_collections!.includes(tag['x-collection'])
|
||||
);
|
||||
|
||||
propertyObject.type = 'array';
|
||||
propertyObject.items = {
|
||||
oneOf: [
|
||||
{
|
||||
type: 'string',
|
||||
},
|
||||
relatedTags.map((tag) => ({
|
||||
$ref: `#/components/schemas/${tag.name}`,
|
||||
})),
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return propertyObject;
|
||||
}
|
||||
|
||||
private fieldTypes: Record<
|
||||
typeof types[number],
|
||||
{ type: string; format?: string; items?: any }
|
||||
{
|
||||
type:
|
||||
| 'string'
|
||||
| 'number'
|
||||
| 'boolean'
|
||||
| 'object'
|
||||
| 'array'
|
||||
| 'integer'
|
||||
| 'null'
|
||||
| undefined;
|
||||
format?: string;
|
||||
items?: any;
|
||||
}
|
||||
> = {
|
||||
bigInteger: {
|
||||
type: 'integer',
|
||||
@@ -139,311 +580,4 @@ class OASService implements SpecificationSubService {
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
async generate() {
|
||||
const collections = await this.collectionsService.readByQuery();
|
||||
|
||||
const userCollections = collections.filter(
|
||||
(collection) =>
|
||||
collection.collection.startsWith('directus_') === false ||
|
||||
this.collectionsDenyList.includes(collection.collection) === false
|
||||
);
|
||||
|
||||
const allFields = await this.fieldsService.readAll();
|
||||
|
||||
const fields: Record<string, Field[]> = {};
|
||||
|
||||
for (const field of allFields) {
|
||||
if (
|
||||
field.collection.startsWith('directus_') === false ||
|
||||
this.collectionsDenyList.includes(field.collection) === false
|
||||
) {
|
||||
if (field.collection in fields) {
|
||||
fields[field.collection].push(field);
|
||||
} else {
|
||||
fields[field.collection] = [field];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const relationsResult = await this.relationsService.readByQuery({});
|
||||
if (relationsResult === null) return {};
|
||||
|
||||
const relations = Array.isArray(relationsResult) ? relationsResult : [relationsResult];
|
||||
|
||||
const relationsTree: RelationTree = {};
|
||||
|
||||
for (const relation of relations as Relation[]) {
|
||||
if (relation.many_collection in relationsTree === false)
|
||||
relationsTree[relation.many_collection] = {};
|
||||
if (relation.one_collection in relationsTree === false)
|
||||
relationsTree[relation.one_collection] = {};
|
||||
|
||||
if (relation.many_field in relationsTree[relation.many_collection] === false)
|
||||
relationsTree[relation.many_collection][relation.many_field] = [];
|
||||
if (relation.one_field in relationsTree[relation.one_collection] === false)
|
||||
relationsTree[relation.one_collection][relation.one_field] = [];
|
||||
|
||||
relationsTree[relation.many_collection][relation.many_field].push(relation);
|
||||
relationsTree[relation.one_collection][relation.one_field].push(relation);
|
||||
}
|
||||
|
||||
const dynOpenapi = {
|
||||
openapi: '3.0.1',
|
||||
info: {
|
||||
title: 'Dynamic Api Specification',
|
||||
description:
|
||||
'This is a dynamicly generated api specification for all endpoints existing on the api.',
|
||||
version: version,
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: env.PUBLIC_URL,
|
||||
description: 'Your current api server.',
|
||||
},
|
||||
],
|
||||
tags: this.generateTags(userCollections),
|
||||
paths: this.generatePaths(userCollections),
|
||||
components: {
|
||||
schemas: this.generateSchemas(userCollections, fields, relationsTree),
|
||||
},
|
||||
};
|
||||
|
||||
return mergeWith(cloneDeep(openapi), cloneDeep(dynOpenapi), (obj, src) => {
|
||||
if (Array.isArray(obj)) return obj.concat(src);
|
||||
});
|
||||
}
|
||||
|
||||
private getNameFormats(collection: string) {
|
||||
const isInternal = collection.startsWith('directus_');
|
||||
const schema = formatTitle(
|
||||
isInternal ? collection.replace('directus_', '').replace(/s$/, '') : collection + 'Item'
|
||||
).replace(/ /g, '');
|
||||
const tag = formatTitle(
|
||||
isInternal ? collection.replace('directus_', '') : collection + ' Collection'
|
||||
);
|
||||
const path = isInternal ? collection : '/items/' + collection;
|
||||
const objectRef = `#/components/schemas/${schema}`;
|
||||
|
||||
return { schema, tag, path, objectRef };
|
||||
}
|
||||
|
||||
private generateTags(collections: Collection[]) {
|
||||
const tags: { name: string; description?: string }[] = [];
|
||||
|
||||
for (const collection of collections) {
|
||||
if (collection.collection.startsWith('directus_')) continue;
|
||||
const { tag } = this.getNameFormats(collection.collection);
|
||||
tags.push({ name: tag, description: collection.meta?.note || undefined });
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
private generatePaths(collections: Collection[]) {
|
||||
const paths: Record<string, object> = {};
|
||||
|
||||
for (const collection of collections) {
|
||||
if (collection.collection.startsWith('directus_')) continue;
|
||||
|
||||
const { tag, schema, objectRef, path } = this.getNameFormats(collection.collection);
|
||||
|
||||
const objectSingle = {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
$ref: objectRef,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
(paths[path] = {
|
||||
get: {
|
||||
operationId: `get${schema}s`,
|
||||
description: `List all items from the ${tag}`,
|
||||
tags: [tag],
|
||||
parameters: [
|
||||
{ $ref: '#/components/parameters/Fields' },
|
||||
{ $ref: '#/components/parameters/Limit' },
|
||||
{ $ref: '#/components/parameters/Meta' },
|
||||
{ $ref: '#/components/parameters/Offset' },
|
||||
{ $ref: '#/components/parameters/Single' },
|
||||
{ $ref: '#/components/parameters/Sort' },
|
||||
{ $ref: '#/components/parameters/Filter' },
|
||||
{ $ref: '#/components/parameters/q' },
|
||||
],
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Successful request',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
data: {
|
||||
type: 'array',
|
||||
items: {
|
||||
$ref: objectRef,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'401': {
|
||||
$ref: '#/components/responses/UnauthorizedError',
|
||||
},
|
||||
},
|
||||
},
|
||||
post: {
|
||||
operationId: `create${schema}`,
|
||||
description: `Create a new item in the ${tag}`,
|
||||
tags: [tag],
|
||||
parameter: [{ $ref: '#/components/parameters/Meta' }],
|
||||
requestBody: objectSingle,
|
||||
responses: {
|
||||
'200': objectSingle,
|
||||
'401': {
|
||||
$ref: '#/components/responses/UnauthorizedError',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
(paths[path + '/{id}'] = {
|
||||
parameters: [{ $ref: '#/components/parameters/Id' }],
|
||||
get: {
|
||||
operationId: `get${schema}`,
|
||||
description: `Get a singe item from the ${tag}`,
|
||||
tags: [tag],
|
||||
parameters: [
|
||||
{ $ref: '#/components/parameters/Fields' },
|
||||
{ $ref: '#/components/parameters/Meta' },
|
||||
],
|
||||
responses: {
|
||||
'200': objectSingle,
|
||||
'401': {
|
||||
$ref: '#/components/responses/UnauthorizedError',
|
||||
},
|
||||
'404': {
|
||||
$ref: '#/components/responses/NotFoundError',
|
||||
},
|
||||
},
|
||||
},
|
||||
patch: {
|
||||
operationId: `update${schema}`,
|
||||
description: `Update an item from the ${tag}`,
|
||||
tags: [tag],
|
||||
parameters: [
|
||||
{ $ref: '#/components/parameters/Fields' },
|
||||
{ $ref: '#/components/parameters/Meta' },
|
||||
],
|
||||
requestBody: objectSingle,
|
||||
responses: {
|
||||
'200': objectSingle,
|
||||
'401': {
|
||||
$ref: '#/components/responses/UnauthorizedError',
|
||||
},
|
||||
'404': {
|
||||
$ref: '#/components/responses/NotFoundError',
|
||||
},
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
operationId: `delete${schema}`,
|
||||
description: `Delete an item from the ${tag}`,
|
||||
tags: [tag],
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Successful request',
|
||||
},
|
||||
'401': {
|
||||
$ref: '#/components/responses/UnauthorizedError',
|
||||
},
|
||||
'404': {
|
||||
$ref: '#/components/responses/NotFoundError',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
private generateSchemas(
|
||||
collections: Collection[],
|
||||
fields: Record<string, Field[]>,
|
||||
relations: RelationTree
|
||||
) {
|
||||
const schemas: Record<string, any> = {};
|
||||
|
||||
for (const collection of collections) {
|
||||
const { schema, tag } = this.getNameFormats(collection.collection);
|
||||
|
||||
if (fields === undefined) return;
|
||||
|
||||
schemas[schema] = {
|
||||
type: 'object',
|
||||
'x-tag': tag,
|
||||
properties: {},
|
||||
};
|
||||
|
||||
for (const field of fields[collection.collection]) {
|
||||
const fieldRelations =
|
||||
field.collection in relations && field.field in relations[field.collection]
|
||||
? relations[field.collection][field.field]
|
||||
: [];
|
||||
|
||||
if (fieldRelations.length !== 0) {
|
||||
const relation = fieldRelations[0];
|
||||
const isM2O =
|
||||
relation.many_collection === field.collection &&
|
||||
relation.many_field === field.field;
|
||||
|
||||
const relatedCollection = isM2O
|
||||
? relation.one_collection
|
||||
: relation.many_collection;
|
||||
if (!relatedCollection) continue;
|
||||
|
||||
const relatedPrimaryField = fields[relatedCollection].find(
|
||||
(field) => field.schema?.is_primary_key
|
||||
);
|
||||
if (relatedPrimaryField?.type === undefined) continue;
|
||||
|
||||
const relatedType = this.fieldTypes[relatedPrimaryField?.type];
|
||||
const { objectRef } = this.getNameFormats(relatedCollection);
|
||||
|
||||
const type = isM2O
|
||||
? {
|
||||
oneOf: [
|
||||
{
|
||||
...relatedType,
|
||||
nullable: field.schema?.is_nullable === true,
|
||||
},
|
||||
{ $ref: objectRef },
|
||||
],
|
||||
}
|
||||
: {
|
||||
type: 'array',
|
||||
items: { $ref: objectRef },
|
||||
nullable: field.schema?.is_nullable === true,
|
||||
};
|
||||
|
||||
schemas[schema].properties[field.field] = {
|
||||
...type,
|
||||
description: field.meta?.note || undefined,
|
||||
};
|
||||
} else {
|
||||
schemas[schema].properties[field.field] = {
|
||||
...this.fieldTypes[field.type],
|
||||
nullable: field.schema?.is_nullable === true,
|
||||
description: field.meta?.note || undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return schemas;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,11 @@ import jwt from 'jsonwebtoken';
|
||||
import { sendInviteMail, sendPasswordResetMail } from '../mail';
|
||||
import database from '../database';
|
||||
import argon2 from 'argon2';
|
||||
import { InvalidPayloadException, ForbiddenException } from '../exceptions';
|
||||
import {
|
||||
InvalidPayloadException,
|
||||
ForbiddenException,
|
||||
UnprocessableEntityException,
|
||||
} from '../exceptions';
|
||||
import { Accountability, PrimaryKey, Item, AbstractServiceOptions } from '../types';
|
||||
import Knex from 'knex';
|
||||
import env from '../env';
|
||||
@@ -50,6 +54,30 @@ export class UsersService extends ItemsService {
|
||||
return this.service.update(data, key as any);
|
||||
}
|
||||
|
||||
delete(key: PrimaryKey): Promise<PrimaryKey>;
|
||||
delete(keys: PrimaryKey[]): Promise<PrimaryKey[]>;
|
||||
async delete(key: PrimaryKey | PrimaryKey[]): Promise<PrimaryKey | PrimaryKey[]> {
|
||||
const keys = Array.isArray(key) ? key : [key];
|
||||
|
||||
// Make sure there's at least one admin user left after this deletion is done
|
||||
const otherAdminUsers = await this.knex
|
||||
.count('*', { as: 'count' })
|
||||
.from('directus_users')
|
||||
.whereNotIn('directus_users.id', keys)
|
||||
.andWhere({ 'directus_roles.admin_access': true })
|
||||
.leftJoin('directus_roles', 'directus_users.role', 'directus_roles.id')
|
||||
.first();
|
||||
|
||||
const otherAdminUsersCount = +(otherAdminUsers?.count || 0);
|
||||
|
||||
if (otherAdminUsersCount === 0)
|
||||
throw new UnprocessableEntityException(`You can't delete the last admin user.`);
|
||||
|
||||
await super.delete(keys as any);
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
async inviteUser(email: string, role: string) {
|
||||
await this.service.create({ email, role, status: 'invited' });
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Query } from './query';
|
||||
import { Relation } from './relation';
|
||||
|
||||
export type NestedCollectionAST = {
|
||||
type: 'collection';
|
||||
export type M2ONode = {
|
||||
type: 'm2o';
|
||||
name: string;
|
||||
children: (NestedCollectionAST | FieldAST)[];
|
||||
children: (NestedCollectionNode | FieldNode)[];
|
||||
query: Query;
|
||||
fieldKey: string;
|
||||
relation: Relation;
|
||||
@@ -12,14 +12,44 @@ export type NestedCollectionAST = {
|
||||
relatedKey: string;
|
||||
};
|
||||
|
||||
export type FieldAST = {
|
||||
export type M2ANode = {
|
||||
type: 'm2a';
|
||||
names: string[];
|
||||
children: {
|
||||
[collection: string]: (NestedCollectionNode | FieldNode)[];
|
||||
};
|
||||
query: {
|
||||
[collection: string]: Query;
|
||||
};
|
||||
relatedKey: {
|
||||
[collection: string]: string;
|
||||
};
|
||||
fieldKey: string;
|
||||
relation: Relation;
|
||||
parentKey: string;
|
||||
};
|
||||
|
||||
export type O2MNode = {
|
||||
type: 'o2m';
|
||||
name: string;
|
||||
children: (NestedCollectionNode | FieldNode)[];
|
||||
query: Query;
|
||||
fieldKey: string;
|
||||
relation: Relation;
|
||||
parentKey: string;
|
||||
relatedKey: string;
|
||||
};
|
||||
|
||||
export type NestedCollectionNode = M2ONode | O2MNode | M2ANode;
|
||||
|
||||
export type FieldNode = {
|
||||
type: 'field';
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type AST = {
|
||||
type: 'collection';
|
||||
type: 'root';
|
||||
name: string;
|
||||
children: (NestedCollectionAST | FieldAST)[];
|
||||
children: (NestedCollectionNode | FieldNode)[];
|
||||
query: Query;
|
||||
};
|
||||
|
||||
@@ -5,7 +5,10 @@ export type Relation = {
|
||||
many_field: string;
|
||||
many_primary: string;
|
||||
|
||||
one_collection: string;
|
||||
one_field: string;
|
||||
one_primary: string;
|
||||
one_collection: string | null;
|
||||
one_field: string | null;
|
||||
one_primary: string | null;
|
||||
|
||||
one_collection_field: string | null;
|
||||
one_allowed_collections: string | null;
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@ export default async function applyQuery(collection: string, dbQuery: QueryBuild
|
||||
dbQuery.orderBy(query.sort);
|
||||
}
|
||||
|
||||
if (typeof query.limit === 'number' && !query.offset) {
|
||||
if (typeof query.limit === 'number') {
|
||||
dbQuery.limit(query.limit);
|
||||
}
|
||||
|
||||
@@ -67,7 +67,10 @@ export async function applyFilter(dbQuery: QueryBuilder, filter: Filter, collect
|
||||
const filterPath = getFilterPath(key, value);
|
||||
const { operator: filterOperator, value: filterValue } = getOperation(key, value);
|
||||
|
||||
const column = filterPath.length > 1 ? await applyJoins(dbQuery, filterPath, collection) : `${collection}.${filterPath[0]}`;
|
||||
const column =
|
||||
filterPath.length > 1
|
||||
? await applyJoins(dbQuery, filterPath, collection)
|
||||
: `${collection}.${filterPath[0]}`;
|
||||
|
||||
applyFilterToQuery(column, filterOperator, filterValue);
|
||||
}
|
||||
@@ -167,8 +170,9 @@ function getFilterPath(key: string, value: Record<string, any>) {
|
||||
return path;
|
||||
}
|
||||
|
||||
function getOperation(key: string, value: Record<string, any>): { operator: string, value: any } {
|
||||
if (key.startsWith('_') && key !== '_and' && key !== '_or') return { operator: key as string, value };
|
||||
function getOperation(key: string, value: Record<string, any>): { operator: string; value: any } {
|
||||
if (key.startsWith('_') && key !== '_and' && key !== '_or')
|
||||
return { operator: key as string, value };
|
||||
return getOperation(Object.keys(value)[0], Object.values(value)[0]);
|
||||
}
|
||||
|
||||
@@ -191,12 +195,21 @@ async function applyJoins(dbQuery: QueryBuilder, path: string[], collection: str
|
||||
|
||||
if (!relation) return;
|
||||
|
||||
const isM2O = relation.many_collection === parentCollection && relation.many_field === pathParts[0];
|
||||
const isM2O =
|
||||
relation.many_collection === parentCollection && relation.many_field === pathParts[0];
|
||||
|
||||
if (isM2O) {
|
||||
dbQuery.leftJoin(relation.one_collection, `${parentCollection}.${relation.many_field}`, `${relation.one_collection}.${relation.one_primary}`);
|
||||
dbQuery.leftJoin(
|
||||
relation.one_collection,
|
||||
`${parentCollection}.${relation.many_field}`,
|
||||
`${relation.one_collection}.${relation.one_primary}`
|
||||
);
|
||||
} else {
|
||||
dbQuery.leftJoin(relation.many_collection, `${relation.one_collection}.${relation.one_primary}`, `${relation.many_collection}.${relation.many_field}`);
|
||||
dbQuery.leftJoin(
|
||||
relation.many_collection,
|
||||
`${relation.one_collection}.${relation.one_primary}`,
|
||||
`${relation.many_collection}.${relation.many_field}`
|
||||
);
|
||||
}
|
||||
|
||||
pathParts.shift();
|
||||
|
||||
@@ -4,17 +4,18 @@
|
||||
|
||||
import {
|
||||
AST,
|
||||
NestedCollectionAST,
|
||||
FieldAST,
|
||||
NestedCollectionNode,
|
||||
FieldNode,
|
||||
Query,
|
||||
Relation,
|
||||
PermissionsAction,
|
||||
Accountability,
|
||||
} from '../types';
|
||||
import database from '../database';
|
||||
import { clone } from 'lodash';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import Knex from 'knex';
|
||||
import SchemaInspector from 'knex-schema-inspector';
|
||||
import { getRelationType } from '../utils/get-relation-type';
|
||||
|
||||
type GetASTOptions = {
|
||||
accountability?: Accountability | null;
|
||||
@@ -27,7 +28,7 @@ export default async function getASTFromQuery(
|
||||
query: Query,
|
||||
options?: GetASTOptions
|
||||
): Promise<AST> {
|
||||
query = clone(query);
|
||||
query = cloneDeep(query);
|
||||
|
||||
const accountability = options?.accountability;
|
||||
const action = options?.action || 'read';
|
||||
@@ -49,7 +50,7 @@ export default async function getASTFromQuery(
|
||||
: null;
|
||||
|
||||
const ast: AST = {
|
||||
type: 'collection',
|
||||
type: 'root',
|
||||
name: collection,
|
||||
query: query,
|
||||
children: [],
|
||||
@@ -75,7 +76,7 @@ export default async function getASTFromQuery(
|
||||
|
||||
if (!fields) return [];
|
||||
|
||||
const children: (NestedCollectionAST | FieldAST)[] = [];
|
||||
const children: (NestedCollectionNode | FieldNode)[] = [];
|
||||
|
||||
const relationalStructure: Record<string, string[]> = {};
|
||||
|
||||
@@ -108,31 +109,68 @@ export default async function getASTFromQuery(
|
||||
|
||||
for (const [relationalField, nestedFields] of Object.entries(relationalStructure)) {
|
||||
const relatedCollection = getRelatedCollection(parentCollection, relationalField);
|
||||
|
||||
if (!relatedCollection) continue;
|
||||
|
||||
const relation = getRelation(parentCollection, relationalField);
|
||||
|
||||
if (!relation) continue;
|
||||
|
||||
const child: NestedCollectionAST = {
|
||||
type: 'collection',
|
||||
name: relatedCollection,
|
||||
fieldKey: relationalField,
|
||||
parentKey: await schemaInspector.primary(parentCollection),
|
||||
relatedKey: await schemaInspector.primary(relatedCollection),
|
||||
relation: relation,
|
||||
query: deep?.[relationalField] || {},
|
||||
children: await parseFields(relatedCollection, nestedFields),
|
||||
};
|
||||
const relationType = getRelationType({
|
||||
relation,
|
||||
collection: parentCollection,
|
||||
field: relationalField,
|
||||
});
|
||||
|
||||
children.push(child);
|
||||
if (!relationType) continue;
|
||||
|
||||
let child: NestedCollectionNode | null = null;
|
||||
|
||||
if (relationType === 'm2a') {
|
||||
const allowedCollections = relation.one_allowed_collections!.split(',');
|
||||
|
||||
child = {
|
||||
type: 'm2a',
|
||||
names: allowedCollections,
|
||||
children: {},
|
||||
query: {},
|
||||
relatedKey: {},
|
||||
parentKey: await schemaInspector.primary(parentCollection),
|
||||
fieldKey: relationalField,
|
||||
relation: relation,
|
||||
};
|
||||
|
||||
for (const relatedCollection of allowedCollections) {
|
||||
child.children[relatedCollection] = await parseFields(
|
||||
relatedCollection,
|
||||
nestedFields
|
||||
);
|
||||
child.query[relatedCollection] = {};
|
||||
child.relatedKey[relatedCollection] = await schemaInspector.primary(
|
||||
relatedCollection
|
||||
);
|
||||
}
|
||||
} else if (relatedCollection) {
|
||||
child = {
|
||||
type: relationType,
|
||||
name: relatedCollection,
|
||||
fieldKey: relationalField,
|
||||
parentKey: await schemaInspector.primary(parentCollection),
|
||||
relatedKey: await schemaInspector.primary(relatedCollection),
|
||||
relation: relation,
|
||||
query: deep?.[relationalField] || {},
|
||||
children: await parseFields(relatedCollection, nestedFields),
|
||||
};
|
||||
}
|
||||
|
||||
if (child) {
|
||||
children.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
async function convertWildcards(parentCollection: string, fields: string[]) {
|
||||
fields = cloneDeep(fields);
|
||||
|
||||
const fieldsInCollection = await getFieldsInCollection(parentCollection);
|
||||
|
||||
const allowedFields = permissions
|
||||
@@ -170,8 +208,8 @@ export default async function getASTFromQuery(
|
||||
relation.one_collection === parentCollection
|
||||
)
|
||||
.map((relation) => {
|
||||
const isM2O = relation.many_collection === parentCollection;
|
||||
return isM2O ? relation.many_field : relation.one_field;
|
||||
const isMany = relation.many_collection === parentCollection;
|
||||
return isMany ? relation.many_field : relation.one_field;
|
||||
})
|
||||
: allowedFields.filter((fieldKey) => !!getRelation(parentCollection, fieldKey));
|
||||
|
||||
@@ -206,18 +244,20 @@ export default async function getASTFromQuery(
|
||||
return relation;
|
||||
}
|
||||
|
||||
function getRelatedCollection(collection: string, field: string) {
|
||||
function getRelatedCollection(collection: string, field: string): string | null {
|
||||
const relation = getRelation(collection, field);
|
||||
|
||||
if (!relation) return null;
|
||||
|
||||
if (relation.many_collection === collection && relation.many_field === field) {
|
||||
return relation.one_collection;
|
||||
return relation.one_collection || null;
|
||||
}
|
||||
|
||||
if (relation.one_collection === collection && relation.one_field === field) {
|
||||
return relation.many_collection;
|
||||
return relation.many_collection || null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getFieldsInCollection(collection: string) {
|
||||
|
||||
30
api/src/utils/get-relation-type.ts
Normal file
30
api/src/utils/get-relation-type.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Relation } from '../types';
|
||||
|
||||
export function getRelationType(getRelationOptions: {
|
||||
relation: Relation;
|
||||
collection: string | null;
|
||||
field: string;
|
||||
}): 'm2o' | 'o2m' | 'm2a' | null {
|
||||
const { relation, collection, field } = getRelationOptions;
|
||||
|
||||
if (!relation) return null;
|
||||
|
||||
if (
|
||||
relation.many_collection === collection &&
|
||||
relation.many_field === field &&
|
||||
relation.one_collection_field &&
|
||||
relation.one_allowed_collections
|
||||
) {
|
||||
return 'm2a';
|
||||
}
|
||||
|
||||
if (relation.many_collection === collection && relation.many_field === field) {
|
||||
return 'm2o';
|
||||
}
|
||||
|
||||
if (relation.one_collection === collection && relation.one_field === field) {
|
||||
return 'o2m';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -6442,7 +6442,7 @@
|
||||
"options": {
|
||||
"fields": [
|
||||
{
|
||||
"field": "locale",
|
||||
"field": "language",
|
||||
"type": "string",
|
||||
"interface": "language",
|
||||
"options": {
|
||||
@@ -7062,7 +7062,7 @@
|
||||
"options": {
|
||||
"fields": [
|
||||
{
|
||||
"field": "locale",
|
||||
"field": "language",
|
||||
"type": "string",
|
||||
"interface": "language",
|
||||
"options": {
|
||||
@@ -10482,9 +10482,9 @@
|
||||
"group": null,
|
||||
"length": "32"
|
||||
},
|
||||
"locale": {
|
||||
"language": {
|
||||
"collection": "directus_users",
|
||||
"field": "locale",
|
||||
"field": "language",
|
||||
"datatype": "VARCHAR",
|
||||
"unique": false,
|
||||
"primary_key": false,
|
||||
@@ -10512,34 +10512,6 @@
|
||||
"group": null,
|
||||
"length": "8"
|
||||
},
|
||||
"locale_options": {
|
||||
"collection": "directus_users",
|
||||
"field": "locale_options",
|
||||
"datatype": "TEXT",
|
||||
"unique": false,
|
||||
"primary_key": false,
|
||||
"auto_increment": false,
|
||||
"default_value": null,
|
||||
"note": "",
|
||||
"signed": true,
|
||||
"id": 857,
|
||||
"type": "json",
|
||||
"sort": 16,
|
||||
"interface": "json",
|
||||
"display": null,
|
||||
"display_options": null,
|
||||
"hidden_detail": true,
|
||||
"hidden_browse": true,
|
||||
"required": false,
|
||||
"options": null,
|
||||
"locked": 1,
|
||||
"translation": null,
|
||||
"readonly": false,
|
||||
"width": null,
|
||||
"validation": null,
|
||||
"group": null,
|
||||
"length": null
|
||||
},
|
||||
"avatar": {
|
||||
"collection": "directus_users",
|
||||
"field": "avatar",
|
||||
|
||||
@@ -1788,7 +1788,7 @@
|
||||
"options": {
|
||||
"fields": [
|
||||
{
|
||||
"field": "locale",
|
||||
"field": "language",
|
||||
"type": "string",
|
||||
"interface": "language",
|
||||
"options": {
|
||||
@@ -2736,7 +2736,7 @@
|
||||
"options": {
|
||||
"fields": [
|
||||
{
|
||||
"field": "locale",
|
||||
"field": "language",
|
||||
"type": "string",
|
||||
"interface": "language",
|
||||
"options": {
|
||||
@@ -5639,7 +5639,7 @@
|
||||
},
|
||||
{
|
||||
"collection": "directus_users",
|
||||
"field": "locale",
|
||||
"field": "language",
|
||||
"datatype": "VARCHAR",
|
||||
"unique": false,
|
||||
"primary_key": false,
|
||||
@@ -5666,7 +5666,7 @@
|
||||
"validation": null,
|
||||
"group": null,
|
||||
"length": "8",
|
||||
"name": "Locale"
|
||||
"name": "Language"
|
||||
},
|
||||
{
|
||||
"collection": "directus_users",
|
||||
@@ -5740,35 +5740,6 @@
|
||||
"length": "16",
|
||||
"name": "Status"
|
||||
},
|
||||
{
|
||||
"collection": "directus_users",
|
||||
"field": "locale_options",
|
||||
"datatype": "TEXT",
|
||||
"unique": false,
|
||||
"primary_key": false,
|
||||
"auto_increment": false,
|
||||
"default_value": null,
|
||||
"note": "",
|
||||
"signed": true,
|
||||
"id": 857,
|
||||
"type": "json",
|
||||
"sort": 16,
|
||||
"interface": "json",
|
||||
"display": null,
|
||||
"display_options": null,
|
||||
"hidden_detail": true,
|
||||
"hidden_browse": true,
|
||||
"required": false,
|
||||
"options": null,
|
||||
"locked": 1,
|
||||
"translation": null,
|
||||
"readonly": false,
|
||||
"width": null,
|
||||
"validation": null,
|
||||
"group": null,
|
||||
"length": null,
|
||||
"name": "Locale Options"
|
||||
},
|
||||
{
|
||||
"collection": "directus_users",
|
||||
"field": "avatar",
|
||||
@@ -11001,7 +10972,7 @@
|
||||
{
|
||||
"id": 826,
|
||||
"collection": "directus_settings",
|
||||
"field": "default_locale",
|
||||
"field": "default_language",
|
||||
"type": "string",
|
||||
"interface": "language",
|
||||
"options": {
|
||||
@@ -11018,9 +10989,9 @@
|
||||
"sort": 8,
|
||||
"width": "half",
|
||||
"group": null,
|
||||
"note": "Default locale for Directus Users",
|
||||
"note": "Default language for Directus Users",
|
||||
"translation": null,
|
||||
"name": "Default Locale"
|
||||
"name": "Default Language"
|
||||
},
|
||||
{
|
||||
"id": 827,
|
||||
|
||||
17
app/package-lock.json
generated
17
app/package-lock.json
generated
@@ -4934,7 +4934,8 @@
|
||||
"clone": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
||||
"integrity": "sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18="
|
||||
"integrity": "sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18=",
|
||||
"dev": true
|
||||
},
|
||||
"co": {
|
||||
"version": "4.6.0",
|
||||
@@ -5685,15 +5686,6 @@
|
||||
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
|
||||
"dev": true
|
||||
},
|
||||
"csslint": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/csslint/-/csslint-1.0.5.tgz",
|
||||
"integrity": "sha1-Gcw+2jIhYP0/cjKvHLKjYOiYouk=",
|
||||
"requires": {
|
||||
"clone": "~2.1.0",
|
||||
"parserlib": "~1.1.1"
|
||||
}
|
||||
},
|
||||
"cssnano": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/cssnano/-/cssnano-4.1.10.tgz",
|
||||
@@ -12288,11 +12280,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"parserlib": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/parserlib/-/parserlib-1.1.1.tgz",
|
||||
"integrity": "sha1-pkz6ckBiQ0/fw1HJpOwtkrlMBvQ="
|
||||
},
|
||||
"parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
|
||||
@@ -51,7 +51,6 @@
|
||||
"color": "^3.1.2",
|
||||
"color-string": "^1.5.3",
|
||||
"cropperjs": "^1.5.7",
|
||||
"csslint": "^1.0.5",
|
||||
"date-fns": "^2.14.0",
|
||||
"diff": "^4.0.2",
|
||||
"highlight.js": "^10.2.0",
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<link rel="shortcut icon" href="/favicon.ico">
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<title>Loading...</title>
|
||||
<title>Loading...</title>
|
||||
<style id="custom-css"></style>
|
||||
</head>
|
||||
<body class="light">
|
||||
<noscript>
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
|
||||
<portal-target name="dialog-outlet" transition="transition-dialog" multiple />
|
||||
<portal-target name="menu-outlet" transition="transition-bounce" multiple />
|
||||
|
||||
<mounting-portal mount-to="#custom-css" target-tag="style">{{ customCSS }}</mounting-portal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -102,6 +104,10 @@ export default defineComponent({
|
||||
}
|
||||
);
|
||||
|
||||
const customCSS = computed(() => {
|
||||
return settingsStore.state?.settings?.custom_css || '';
|
||||
});
|
||||
|
||||
const appAccess = computed(() => {
|
||||
if (!userStore.state.currentUser) return true;
|
||||
return userStore.state.currentUser?.role?.app_access || false;
|
||||
@@ -118,7 +124,7 @@ export default defineComponent({
|
||||
axios,
|
||||
});
|
||||
|
||||
return { hydrating, brandStyle, appAccess, error };
|
||||
return { hydrating, brandStyle, appAccess, error, customCSS };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
<template>
|
||||
<div class="v-list-group">
|
||||
<v-list-item :active="active" class="activator" :to="to" :exact="exact" @click="onClick" :disabled="disabled" :dense="dense">
|
||||
<v-list-item
|
||||
:active="active"
|
||||
class="activator"
|
||||
:to="to"
|
||||
:exact="exact"
|
||||
@click="onClick"
|
||||
:disabled="disabled"
|
||||
:dense="dense"
|
||||
>
|
||||
<slot name="activator" :active="groupActive" />
|
||||
|
||||
<v-list-item-icon class="activator-icon" :class="{ active: groupActive }" v-if="$slots.default">
|
||||
@@ -50,11 +58,11 @@ export default defineComponent({
|
||||
},
|
||||
dense: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props, { listeners, emit }) {
|
||||
const {multiple} = toRefs(props)
|
||||
const { multiple } = toRefs(props);
|
||||
|
||||
const { active: groupActive, toggle, activate, deactivate } = useGroupable({
|
||||
group: props.scope,
|
||||
|
||||
@@ -68,7 +68,7 @@ export default defineComponent({
|
||||
value: {
|
||||
type: [String, Number],
|
||||
default: undefined,
|
||||
}
|
||||
},
|
||||
},
|
||||
setup(props, { listeners }) {
|
||||
const component = computed<string>(() => {
|
||||
|
||||
@@ -11,12 +11,12 @@ import { useGroupableParent } from '@/composables/groupable';
|
||||
export default defineComponent({
|
||||
model: {
|
||||
prop: 'activeItems',
|
||||
event: 'input'
|
||||
event: 'input',
|
||||
},
|
||||
props: {
|
||||
activeItems: {
|
||||
type: Array as PropType<(number | string)[]>,
|
||||
default: () => []
|
||||
default: () => [],
|
||||
},
|
||||
large: {
|
||||
type: Boolean,
|
||||
@@ -31,18 +31,18 @@ export default defineComponent({
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
setup(props, {emit}) {
|
||||
const {activeItems, multiple, mandatory} = toRefs(props)
|
||||
setup(props, { emit }) {
|
||||
const { activeItems, multiple, mandatory } = toRefs(props);
|
||||
useGroupableParent(
|
||||
{
|
||||
selection: activeItems,
|
||||
onSelectionChange: (newSelection) => {
|
||||
emit('input', newSelection)
|
||||
}
|
||||
emit('input', newSelection);
|
||||
},
|
||||
},
|
||||
{
|
||||
mandatory,
|
||||
multiple
|
||||
multiple,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -83,13 +83,13 @@ function mapKeys(key: string) {
|
||||
|
||||
function callHandlers(event: KeyboardEvent) {
|
||||
Object.entries(handlers).forEach(([key, value]) => {
|
||||
const keys = key.split('+')
|
||||
const keys = key.split('+');
|
||||
|
||||
for(key of keysdown) {
|
||||
if(keys.includes(key) === false) return;
|
||||
for (key of keysdown) {
|
||||
if (keys.includes(key) === false) return;
|
||||
}
|
||||
for(key of keys) {
|
||||
if(keysdown.has(key) === false) return;
|
||||
for (key of keys) {
|
||||
if (keysdown.has(key) === false) return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
|
||||
@@ -55,7 +55,7 @@ export default defineComponent({
|
||||
if (props.value.avatar?.id) {
|
||||
return `${getRootPath()}assets/${props.value.avatar.id}?key=system-small-cover`;
|
||||
}
|
||||
return null
|
||||
return null;
|
||||
});
|
||||
|
||||
return { src };
|
||||
|
||||
@@ -57,7 +57,7 @@ export async function hydrate(stores = useStores()) {
|
||||
*/
|
||||
await userStore.hydrate();
|
||||
|
||||
setLanguage((userStore.state.currentUser?.locale as Language) || 'en-US');
|
||||
setLanguage((userStore.state.currentUser?.language as Language) || 'en-US');
|
||||
|
||||
await Promise.all(stores.filter(({ id }) => id !== 'userStore').map((store) => store.hydrate?.()));
|
||||
|
||||
|
||||
@@ -147,7 +147,7 @@ export default defineComponent({
|
||||
}
|
||||
return found;
|
||||
});
|
||||
} else if (lang === 'javascript' || lang === 'htmlmixed' || lang === 'css' || lang === 'yaml') {
|
||||
} else if (lang === 'javascript' || lang === 'htmlmixed' || lang === 'yaml') {
|
||||
let linter = lang;
|
||||
if (lang === 'javascript') {
|
||||
const jshint = await import('jshint');
|
||||
@@ -156,9 +156,6 @@ export default defineComponent({
|
||||
linter = 'html';
|
||||
const htmlhint = await import('htmlhint');
|
||||
(window as any).HTMLHint = htmlhint;
|
||||
} else if (lang === 'css') {
|
||||
const csslint = await import('csslint');
|
||||
(window as any).CSSLint = csslint;
|
||||
} else if (lang === 'yaml') {
|
||||
const jsyaml = await import('js-yaml');
|
||||
(window as any).jsyaml = jsyaml;
|
||||
@@ -166,6 +163,9 @@ export default defineComponent({
|
||||
await import(`codemirror/mode/${lang}/${lang}.js`);
|
||||
await import(`codemirror/addon/lint/${linter}-lint.js`);
|
||||
codemirror.value.setOption('lint', (CodeMirror as any).lint[linter]);
|
||||
|
||||
await import(`codemirror/mode/${lang}/${lang}.js`);
|
||||
codemirror.value.setOption('mode', { name: lang });
|
||||
} else if (lang === 'text/plain') {
|
||||
codemirror.value.setOption('mode', { name: null });
|
||||
} else {
|
||||
|
||||
@@ -49,8 +49,8 @@ export default defineComponent({
|
||||
},
|
||||
headerPlaceholder: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -41,7 +41,7 @@ export default defineComponent({
|
||||
},
|
||||
template: {
|
||||
type: String,
|
||||
default: null
|
||||
default: null,
|
||||
},
|
||||
addLabel: {
|
||||
type: String,
|
||||
@@ -58,15 +58,15 @@ export default defineComponent({
|
||||
headerPlaceholder: {
|
||||
type: String,
|
||||
default: i18n.t('empty_item'),
|
||||
}
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const selection = ref<number[]>([]);
|
||||
|
||||
const _template = computed(() => {
|
||||
if(props.template === null) return props.fields.length > 0 ? `{{${ props.fields[0].field}}}` : ''
|
||||
return props.template
|
||||
})
|
||||
if (props.template === null) return props.fields.length > 0 ? `{{${props.fields[0].field}}}` : '';
|
||||
return props.template;
|
||||
});
|
||||
|
||||
const showAddNew = computed(() => {
|
||||
if (props.disabled) return false;
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
"company": "Company",
|
||||
"title": "Title",
|
||||
"timezone": "Timezone",
|
||||
"locale": "Locale",
|
||||
"language": "Language",
|
||||
"avatar": "Avatar",
|
||||
"theme": "Theme",
|
||||
"2fa_secret": "Two-Factor Authentication"
|
||||
|
||||
@@ -372,7 +372,7 @@
|
||||
"placeholder": "Placeholder",
|
||||
"placeholder_comment": "Enter placeholder text",
|
||||
"localized": "Localized",
|
||||
"localized_comment": "Localize the output to the user's locale",
|
||||
"localized_comment": "Localize the output to the user's language",
|
||||
"icon_left": "Icon Left",
|
||||
"icon_left_comment": "Choose an optional icon to display on the left of the input",
|
||||
"icon_right": "Icon Right",
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
"company": "Company",
|
||||
"title": "Title",
|
||||
"timezone": "Timezone",
|
||||
"locale": "Locale",
|
||||
"language": "Language",
|
||||
"avatar": "Avatar",
|
||||
"theme": "Theme",
|
||||
"2fa_secret": "Two-Factor Authentication"
|
||||
|
||||
@@ -372,7 +372,7 @@
|
||||
"placeholder": "Placeholder",
|
||||
"placeholder_comment": "Enter placeholder text",
|
||||
"localized": "Localized",
|
||||
"localized_comment": "Localize the output to the user's locale",
|
||||
"localized_comment": "Localize the output to the user's language",
|
||||
"icon_left": "Icon Left",
|
||||
"icon_left_comment": "Choose an optional icon to display on the left of the input",
|
||||
"icon_right": "Icon Right",
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
"company": "Company",
|
||||
"title": "Title",
|
||||
"timezone": "Timezone",
|
||||
"locale": "Locale",
|
||||
"language": "language",
|
||||
"avatar": "Avatar",
|
||||
"theme": "Theme",
|
||||
"2fa_secret": "Two-Factor Authentication"
|
||||
|
||||
@@ -372,7 +372,7 @@
|
||||
"placeholder": "Placeholder",
|
||||
"placeholder_comment": "Enter placeholder text",
|
||||
"localized": "Localized",
|
||||
"localized_comment": "Localize the output to the user's locale",
|
||||
"localized_comment": "Localize the output to the user's language",
|
||||
"icon_left": "Icon Left",
|
||||
"icon_left_comment": "Choose an optional icon to display on the left of the input",
|
||||
"icon_right": "Icon Right",
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
"company": "Společnost",
|
||||
"title": "Title",
|
||||
"timezone": "Časové pásmo",
|
||||
"locale": "Lokalizace",
|
||||
"language": "Lokalizace",
|
||||
"avatar": "Avatar",
|
||||
"theme": "Téma",
|
||||
"2fa_secret": "Dvoufázové ověření"
|
||||
|
||||
@@ -372,7 +372,7 @@
|
||||
"placeholder": "Placeholder",
|
||||
"placeholder_comment": "Enter placeholder text",
|
||||
"localized": "Localized",
|
||||
"localized_comment": "Localize the output to the user's locale",
|
||||
"localized_comment": "Localize the output to the user's language",
|
||||
"icon_left": "Icon Left",
|
||||
"icon_left_comment": "Choose an optional icon to display on the left of the input",
|
||||
"icon_right": "Icon Right",
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
"company": "Company",
|
||||
"title": "Title",
|
||||
"timezone": "Timezone",
|
||||
"locale": "Locale",
|
||||
"language": "language",
|
||||
"avatar": "Avatar",
|
||||
"theme": "Theme",
|
||||
"2fa_secret": "Two-Factor Authentication"
|
||||
|
||||
@@ -372,7 +372,7 @@
|
||||
"placeholder": "Placeholder",
|
||||
"placeholder_comment": "Enter placeholder text",
|
||||
"localized": "Localized",
|
||||
"localized_comment": "Localize the output to the user's locale",
|
||||
"localized_comment": "Localize the output to the user's language",
|
||||
"icon_left": "Icon Left",
|
||||
"icon_left_comment": "Choose an optional icon to display on the left of the input",
|
||||
"icon_right": "Icon Right",
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
"company": "Firma",
|
||||
"title": "Titel",
|
||||
"timezone": "Zeitzone",
|
||||
"locale": "Sprache",
|
||||
"language": "Sprache",
|
||||
"avatar": "Avatar",
|
||||
"theme": "Design",
|
||||
"2fa_secret": "Zwei-Faktor-Authentifizierung"
|
||||
|
||||
@@ -372,7 +372,7 @@
|
||||
"placeholder": "Placeholder",
|
||||
"placeholder_comment": "Enter placeholder text",
|
||||
"localized": "Lokalisiert",
|
||||
"localized_comment": "Localize the output to the user's locale",
|
||||
"localized_comment": "Localize the output to the user's language",
|
||||
"icon_left": "Icon links",
|
||||
"icon_left_comment": "Optionales Icon, für Anzeige links neben der Eingabe",
|
||||
"icon_right": "Icon rechts",
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
"company": "Εταιρεία",
|
||||
"title": "Τίτλος",
|
||||
"timezone": "Ζώνη ώρας",
|
||||
"locale": "Γλώσσα",
|
||||
"language": "Γλώσσα",
|
||||
"avatar": "Avatar",
|
||||
"theme": "Θέμα",
|
||||
"2fa_secret": "Έλεγχος ταυτότητας δυο παραγόντων"
|
||||
|
||||
@@ -372,7 +372,7 @@
|
||||
"placeholder": "Placeholder",
|
||||
"placeholder_comment": "Enter placeholder text",
|
||||
"localized": "Localized",
|
||||
"localized_comment": "Localize the output to the user's locale",
|
||||
"localized_comment": "Localize the output to the user's language",
|
||||
"icon_left": "Icon Left",
|
||||
"icon_left_comment": "Choose an optional icon to display on the left of the input",
|
||||
"icon_right": "Icon Right",
|
||||
|
||||
@@ -1022,7 +1022,7 @@
|
||||
"company": "Company",
|
||||
"title": "Title",
|
||||
"timezone": "Timezone",
|
||||
"locale": "Locale",
|
||||
"language": "language",
|
||||
"avatar": "Avatar",
|
||||
"theme": "Theme",
|
||||
"tfa_secret": "Two-Factor Authentication"
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
"company": "Empresa",
|
||||
"title": "Título",
|
||||
"timezone": "Zona horaria",
|
||||
"locale": "Localización",
|
||||
"language": "Localización",
|
||||
"avatar": "Avatar",
|
||||
"theme": "Tema",
|
||||
"2fa_secret": "Autenticación de dos factores"
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
"company": "Empresa",
|
||||
"title": "Título",
|
||||
"timezone": "Zona horaria",
|
||||
"locale": "Localización",
|
||||
"language": "Localización",
|
||||
"avatar": "Avatar",
|
||||
"theme": "Tema",
|
||||
"2fa_secret": "Autenticación de dos factores"
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
"company": "Empresa",
|
||||
"title": "Título",
|
||||
"timezone": "Zona horaria",
|
||||
"locale": "Localización",
|
||||
"language": "Localización",
|
||||
"avatar": "Avatar",
|
||||
"theme": "Tema",
|
||||
"2fa_secret": "Autenticación de dos factores"
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
"company": "Company",
|
||||
"title": "Title",
|
||||
"timezone": "Timezone",
|
||||
"locale": "Locale",
|
||||
"language": "language",
|
||||
"avatar": "Avatar",
|
||||
"theme": "Theme",
|
||||
"2fa_secret": "Two-Factor Authentication"
|
||||
|
||||
@@ -372,7 +372,7 @@
|
||||
"placeholder": "Placeholder",
|
||||
"placeholder_comment": "Enter placeholder text",
|
||||
"localized": "Localized",
|
||||
"localized_comment": "Localize the output to the user's locale",
|
||||
"localized_comment": "Localize the output to the user's language",
|
||||
"icon_left": "Icon Left",
|
||||
"icon_left_comment": "Choose an optional icon to display on the left of the input",
|
||||
"icon_right": "Icon Right",
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
"company": "Entreprise",
|
||||
"title": "Titre",
|
||||
"timezone": "Fuseau horaire",
|
||||
"locale": "Langue",
|
||||
"language": "Langue",
|
||||
"avatar": "Avatar",
|
||||
"theme": "Thème",
|
||||
"2fa_secret": "Authentification à deux facteurs"
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
"company": "Company",
|
||||
"title": "Title",
|
||||
"timezone": "Timezone",
|
||||
"locale": "Locale",
|
||||
"language": "language",
|
||||
"avatar": "Avatar",
|
||||
"theme": "Theme",
|
||||
"2fa_secret": "Two-Factor Authentication"
|
||||
|
||||
@@ -372,7 +372,7 @@
|
||||
"placeholder": "Placeholder",
|
||||
"placeholder_comment": "Enter placeholder text",
|
||||
"localized": "Localized",
|
||||
"localized_comment": "Localize the output to the user's locale",
|
||||
"localized_comment": "Localize the output to the user's language",
|
||||
"icon_left": "Icon Left",
|
||||
"icon_left_comment": "Choose an optional icon to display on the left of the input",
|
||||
"icon_right": "Icon Right",
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
"company": "Company",
|
||||
"title": "Title",
|
||||
"timezone": "Timezone",
|
||||
"locale": "Locale",
|
||||
"language": "language",
|
||||
"avatar": "Avatar",
|
||||
"theme": "Theme",
|
||||
"2fa_secret": "Two-Factor Authentication"
|
||||
|
||||
@@ -372,7 +372,7 @@
|
||||
"placeholder": "Placeholder",
|
||||
"placeholder_comment": "Enter placeholder text",
|
||||
"localized": "Localized",
|
||||
"localized_comment": "Localize the output to the user's locale",
|
||||
"localized_comment": "Localize the output to the user's language",
|
||||
"icon_left": "Icon Left",
|
||||
"icon_left_comment": "Choose an optional icon to display on the left of the input",
|
||||
"icon_right": "Icon Right",
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
"company": "Company",
|
||||
"title": "Title",
|
||||
"timezone": "Timezone",
|
||||
"locale": "Locale",
|
||||
"language": "language",
|
||||
"avatar": "Avatar",
|
||||
"theme": "Theme",
|
||||
"2fa_secret": "Two-Factor Authentication"
|
||||
|
||||
@@ -372,7 +372,7 @@
|
||||
"placeholder": "Placeholder",
|
||||
"placeholder_comment": "Enter placeholder text",
|
||||
"localized": "Localized",
|
||||
"localized_comment": "Localize the output to the user's locale",
|
||||
"localized_comment": "Localize the output to the user's language",
|
||||
"icon_left": "Icon Left",
|
||||
"icon_left_comment": "Choose an optional icon to display on the left of the input",
|
||||
"icon_right": "Icon Right",
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
"company": "Company",
|
||||
"title": "Title",
|
||||
"timezone": "Timezone",
|
||||
"locale": "Locale",
|
||||
"language": "language",
|
||||
"avatar": "Avatar",
|
||||
"theme": "Theme",
|
||||
"2fa_secret": "Two-Factor Authentication"
|
||||
|
||||
@@ -372,7 +372,7 @@
|
||||
"placeholder": "Placeholder",
|
||||
"placeholder_comment": "Enter placeholder text",
|
||||
"localized": "Localized",
|
||||
"localized_comment": "Localize the output to the user's locale",
|
||||
"localized_comment": "Localize the output to the user's language",
|
||||
"icon_left": "Icon Left",
|
||||
"icon_left_comment": "Choose an optional icon to display on the left of the input",
|
||||
"icon_right": "Icon Right",
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
"company": "Azienda",
|
||||
"title": "Titolo",
|
||||
"timezone": "Fuso orario",
|
||||
"locale": "Localizzazione",
|
||||
"language": "Localizzazione",
|
||||
"avatar": "Avatar",
|
||||
"theme": "Tema",
|
||||
"2fa_secret": "Autenticazione a due fattori"
|
||||
|
||||
@@ -372,7 +372,7 @@
|
||||
"placeholder": "Placeholder",
|
||||
"placeholder_comment": "Enter placeholder text",
|
||||
"localized": "Localizzato",
|
||||
"localized_comment": "Localize the output to the user's locale",
|
||||
"localized_comment": "Localize the output to the user's language",
|
||||
"icon_left": "Icona a sinistra",
|
||||
"icon_left_comment": "Scegli un'icona facoltativa da visualizzare sulla sinistra dell'input",
|
||||
"icon_right": "Icona a destra",
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
"company": "Company",
|
||||
"title": "標題",
|
||||
"timezone": "Timezone",
|
||||
"locale": "Locale",
|
||||
"language": "language",
|
||||
"avatar": "Avatar",
|
||||
"theme": "Theme",
|
||||
"2fa_secret": "Two-Factor Authentication"
|
||||
|
||||
@@ -372,7 +372,7 @@
|
||||
"placeholder": "Placeholder",
|
||||
"placeholder_comment": "Enter placeholder text",
|
||||
"localized": "Localized",
|
||||
"localized_comment": "Localize the output to the user's locale",
|
||||
"localized_comment": "Localize the output to the user's language",
|
||||
"icon_left": "Icon Left",
|
||||
"icon_left_comment": "Choose an optional icon to display on the left of the input",
|
||||
"icon_right": "Icon Right",
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
"company": "Company",
|
||||
"title": "Title",
|
||||
"timezone": "Timezone",
|
||||
"locale": "Locale",
|
||||
"language": "language",
|
||||
"avatar": "Avatar",
|
||||
"theme": "Theme",
|
||||
"2fa_secret": "Two-Factor Authentication"
|
||||
|
||||
@@ -372,7 +372,7 @@
|
||||
"placeholder": "Placeholder",
|
||||
"placeholder_comment": "Enter placeholder text",
|
||||
"localized": "Localized",
|
||||
"localized_comment": "Localize the output to the user's locale",
|
||||
"localized_comment": "Localize the output to the user's language",
|
||||
"icon_left": "Icon Left",
|
||||
"icon_left_comment": "Choose an optional icon to display on the left of the input",
|
||||
"icon_right": "Icon Right",
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
"company": "Company",
|
||||
"title": "Title",
|
||||
"timezone": "Timezone",
|
||||
"locale": "Locale",
|
||||
"language": "language",
|
||||
"avatar": "Avatar",
|
||||
"theme": "Theme",
|
||||
"2fa_secret": "Two-Factor Authentication"
|
||||
|
||||
@@ -372,7 +372,7 @@
|
||||
"placeholder": "Placeholder",
|
||||
"placeholder_comment": "Enter placeholder text",
|
||||
"localized": "Localized",
|
||||
"localized_comment": "Localize the output to the user's locale",
|
||||
"localized_comment": "Localize the output to the user's language",
|
||||
"icon_left": "Icon Left",
|
||||
"icon_left_comment": "Choose an optional icon to display on the left of the input",
|
||||
"icon_right": "Icon Right",
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
"company": "Company",
|
||||
"title": "Title",
|
||||
"timezone": "Timezone",
|
||||
"locale": "Locale",
|
||||
"language": "language",
|
||||
"avatar": "Avatar",
|
||||
"theme": "Theme",
|
||||
"2fa_secret": "Two-Factor Authentication"
|
||||
|
||||
@@ -372,7 +372,7 @@
|
||||
"placeholder": "Placeholder",
|
||||
"placeholder_comment": "Enter placeholder text",
|
||||
"localized": "Localized",
|
||||
"localized_comment": "Localize the output to the user's locale",
|
||||
"localized_comment": "Localize the output to the user's language",
|
||||
"icon_left": "Icon Left",
|
||||
"icon_left_comment": "Choose an optional icon to display on the left of the input",
|
||||
"icon_right": "Icon Right",
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
"company": "Bedrijf",
|
||||
"title": "Titel",
|
||||
"timezone": "Tijdszone",
|
||||
"locale": "Taal",
|
||||
"language": "Taal",
|
||||
"avatar": "Avatar",
|
||||
"theme": "Thema",
|
||||
"2fa_secret": "2FA (Twee-Factor Authenticatie)"
|
||||
|
||||
@@ -372,7 +372,7 @@
|
||||
"placeholder": "Placeholder",
|
||||
"placeholder_comment": "Voer placeholder tekst in",
|
||||
"localized": "Gelokaliseerd",
|
||||
"localized_comment": "Localize the output to the user's locale",
|
||||
"localized_comment": "Localize the output to the user's language",
|
||||
"icon_left": "Icon Left",
|
||||
"icon_left_comment": "Choose an optional icon to display on the left of the input",
|
||||
"icon_right": "Icon Right",
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
"company": "Company",
|
||||
"title": "Title",
|
||||
"timezone": "Timezone",
|
||||
"locale": "Locale",
|
||||
"language": "language",
|
||||
"avatar": "Avatar",
|
||||
"theme": "Theme",
|
||||
"2fa_secret": "Two-Factor Authentication"
|
||||
|
||||
@@ -372,7 +372,7 @@
|
||||
"placeholder": "Placeholder",
|
||||
"placeholder_comment": "Enter placeholder text",
|
||||
"localized": "Localized",
|
||||
"localized_comment": "Localize the output to the user's locale",
|
||||
"localized_comment": "Localize the output to the user's language",
|
||||
"icon_left": "Icon Left",
|
||||
"icon_left_comment": "Choose an optional icon to display on the left of the input",
|
||||
"icon_right": "Icon Right",
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
"company": "Firma",
|
||||
"title": "Title",
|
||||
"timezone": "Strefa czasowa",
|
||||
"locale": "Język",
|
||||
"language": "Język",
|
||||
"avatar": "Avatar",
|
||||
"theme": "Motyw",
|
||||
"2fa_secret": "Uwierzytelnianie dwuetapowe"
|
||||
|
||||
@@ -372,7 +372,7 @@
|
||||
"placeholder": "Placeholder",
|
||||
"placeholder_comment": "Enter placeholder text",
|
||||
"localized": "Localized",
|
||||
"localized_comment": "Localize the output to the user's locale",
|
||||
"localized_comment": "Localize the output to the user's language",
|
||||
"icon_left": "Icon Left",
|
||||
"icon_left_comment": "Choose an optional icon to display on the left of the input",
|
||||
"icon_right": "Icon Right",
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
"company": "Empresa",
|
||||
"title": "Título",
|
||||
"timezone": "Fuso Horário",
|
||||
"locale": "Localidade",
|
||||
"language": "Localidade",
|
||||
"avatar": "Imagem de Perfil",
|
||||
"theme": "Tema",
|
||||
"2fa_secret": "Autenticação em Duas Etapas"
|
||||
|
||||
@@ -372,7 +372,7 @@
|
||||
"placeholder": "Placeholder",
|
||||
"placeholder_comment": "Enter placeholder text",
|
||||
"localized": "Localizado",
|
||||
"localized_comment": "Localize the output to the user's locale",
|
||||
"localized_comment": "Localize the output to the user's language",
|
||||
"icon_left": "Ícone à Esquerda",
|
||||
"icon_left_comment": "Escolha um ícone opcional para exibir à esquerda da entrada",
|
||||
"icon_right": "Ícone à Direita",
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
"company": "Company",
|
||||
"title": "Title",
|
||||
"timezone": "Timezone",
|
||||
"locale": "Locale",
|
||||
"language": "language",
|
||||
"avatar": "Avatar",
|
||||
"theme": "Theme",
|
||||
"2fa_secret": "Two-Factor Authentication"
|
||||
|
||||
@@ -372,7 +372,7 @@
|
||||
"placeholder": "Placeholder",
|
||||
"placeholder_comment": "Enter placeholder text",
|
||||
"localized": "Localized",
|
||||
"localized_comment": "Localize the output to the user's locale",
|
||||
"localized_comment": "Localize the output to the user's language",
|
||||
"icon_left": "Icon Left",
|
||||
"icon_left_comment": "Choose an optional icon to display on the left of the input",
|
||||
"icon_right": "Icon Right",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user