Merge branch 'main' into feature-rate-limiting

This commit is contained in:
rijkvanzanten
2020-09-01 11:15:01 -04:00
232 changed files with 12026 additions and 9720 deletions

16193
api/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "directus",
"version": "9.0.0-alpha.20",
"version": "9.0.0-alpha.25",
"license": "GPL-3.0-only",
"homepage": "https://github.com/directus/next#readme",
"description": "Directus is a real-time API and App dashboard for managing SQL database content.",
@@ -64,7 +64,7 @@
"example.env"
],
"dependencies": {
"@directus/app": "^9.0.0-alpha.20",
"@directus/app": "^9.0.0-alpha.25",
"@directus/format-title": "^3.2.0",
"@slynova/flydrive": "^1.0.2",
"@slynova/flydrive-gcs": "^1.0.2",
@@ -115,8 +115,8 @@
"mssql": "^6.2.0",
"mysql": "^2.18.1",
"oracledb": "^5.0.0",
"pg": "^8.3.0",
"sqlite3": "^5.0.0",
"pg": "^8.3.2",
"redis": "^3.0.2"
},
"devDependencies": {
@@ -164,5 +164,5 @@
"prettier --write"
]
},
"gitHead": "9092740d708c1e9ed1575b0932a01f531d224f77"
"gitHead": "c22537b6d0dd7dc6cc651a9200a68f6b060353d4"
}

View File

@@ -3,7 +3,7 @@ import asyncHandler from 'express-async-handler';
import FieldsService from '../services/fields';
import validateCollection from '../middleware/collection-exists';
import { schemaInspector } from '../database';
import { FieldNotFoundException, InvalidPayloadException } from '../exceptions';
import { FieldNotFoundException, InvalidPayloadException, ForbiddenException } from '../exceptions';
import Joi from 'joi';
import { Field } from '../types/field';
import useCollection from '../middleware/use-collection';
@@ -48,7 +48,7 @@ router.get(
const service = new FieldsService({ accountability: req.accountability });
const exists = await schemaInspector.hasColumn(req.collection, req.params.field);
if (exists === false) throw new FieldNotFoundException(req.collection, req.params.field);
if (exists === false) throw new ForbiddenException();
const field = await service.readOne(req.params.collection, req.params.field);
return res.json({ data: field || null });
@@ -60,11 +60,11 @@ const newFieldSchema = Joi.object({
field: Joi.string().required(),
type: Joi.string().valid(...types),
schema: Joi.object({
comment: Joi.string(),
comment: Joi.string().allow(null),
default_value: Joi.any(),
max_length: [Joi.number(), Joi.string()],
is_nullable: Joi.bool(),
}),
}).unknown(),
/** @todo base this on default validation */
meta: Joi.any(),
});
@@ -105,7 +105,7 @@ router.patch(
let results: any = [];
for (const field of req.body) {
await service.updateField(req.params.collection, field, req.accountability);
await service.updateField(req.params.collection, field);
const updatedField = await service.readOne(req.params.collection, field.field);
@@ -120,15 +120,13 @@ router.patch(
'/:collection/:field',
validateCollection,
useCollection('directus_fields'),
// @todo: validate field
asyncHandler(async (req, res) => {
const service = new FieldsService({ accountability: req.accountability });
const fieldData: Partial<Field> & { field: string; type: typeof types[number] } = req.body;
if (!fieldData.field) fieldData.field = req.params.field;
await service.updateField(req.params.collection, fieldData, req.accountability);
await service.updateField(req.params.collection, fieldData);
const updatedField = await service.readOne(req.params.collection, req.params.field);
@@ -142,8 +140,7 @@ router.delete(
useCollection('directus_fields'),
asyncHandler(async (req, res) => {
const service = new FieldsService({ accountability: req.accountability });
await service.deleteField(req.params.collection, req.params.field, req.accountability);
await service.deleteField(req.params.collection, req.params.field);
res.status(200).end();
})

View File

@@ -79,7 +79,9 @@ router.patch(
return res.json({ data: item || null });
}
throw new RouteNotFoundException(req.path);
const primaryKeys = await service.update(req.body);
const result = await service.readByKey(primaryKeys, req.sanitizedQuery);
return res.json({ data: result || null });
})
);
@@ -96,7 +98,6 @@ router.patch(
const primaryKey = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk;
const updatedPrimaryKey = await service.update(req.body, primaryKey as any);
const result = await service.readByKey(updatedPrimaryKey, req.sanitizedQuery);
res.json({ data: result || null });

View File

@@ -159,9 +159,9 @@ router.post('/me/tfa/enable/', asyncHandler(async (req, res) => {
}
const service = new UsersService({ accountability: req.accountability });
const url = await service.enableTFA(req.accountability.user);
const { url, secret } = await service.enableTFA(req.accountability.user);
return res.json({ data: { otpauth_url: url }});
return res.json({ data: { secret, otpauth_url: url }});
}));
router.post('/me/tfa/disable', asyncHandler(async (req, res) => {

View File

@@ -3,6 +3,9 @@ import asyncHandler from 'express-async-handler';
import { nanoid } from 'nanoid';
import { InvalidQueryException, InvalidPayloadException } from '../exceptions';
import argon2 from 'argon2';
import collectionExists from '../middleware/collection-exists';
import UtilsService from '../services/utils';
import Joi from 'joi';
const router = Router();
@@ -48,4 +51,23 @@ router.post(
})
);
const SortSchema = Joi.object({
item: Joi.alternatives(Joi.string(), Joi.number()).required(),
to: Joi.alternatives(Joi.string(), Joi.number()).required(),
});
router.post(
'/sort/:collection',
collectionExists,
asyncHandler(async (req, res) => {
const { error } = SortSchema.validate(req.body);
if (error) throw new InvalidPayloadException(error.message);
const service = new UtilsService({ accountability: req.accountability });
await service.sort(req.collection, req.body);
return res.status(200).end();
})
)
export default router;

View File

@@ -22,6 +22,15 @@ tables:
display_template:
type: string
length: 255
sort_field:
type: string
length: 64
soft_delete_field:
type: string
length: 64
soft_delete_value:
type: string
length: 255
directus_roles:
id:
@@ -84,6 +93,8 @@ tables:
type: text
tags:
type: json
avatar:
type: uuid
timezone:
type: string
length: 255
@@ -115,8 +126,6 @@ tables:
length: 255
last_login:
type: timestamp
avatar:
type: uuid
last_page:
type: string
length: 255
@@ -307,12 +316,14 @@ tables:
references:
table: directus_collections
column: collection
operation:
action:
type: string
length: 10
nullable: false
permissions:
type: json
validation:
type: json
presets:
type: json
fields:
@@ -628,6 +639,8 @@ rows:
- collection: directus_roles
field: name
interface: text-input
options:
placeholder: The unique name for this role...
locked: true
sort: 1
width: half
@@ -799,7 +812,7 @@ rows:
width: half
- collection: directus_users
field: password
special: hash
special: hash, conceal
interface: hash
locked: true
options:
@@ -1234,7 +1247,9 @@ rows:
width: half
- collection: directus_users
field: tfa_secret
interface: tfa-setup
locked: true
special: conceal
sort: 14
width: half
- collection: directus_users
@@ -1780,15 +1795,16 @@ rows:
defaults:
role: null
collection: null
operation: null
action: null
permissions: null
validation: null
presets: null
fields: null
limit: null
data:
- collection: directus_settings
operation: read
action: read
permissions: {}
fields: "project_name,project_logo,project_color,public_foreground,public_background,public_note"
@@ -1822,7 +1838,7 @@ rows:
view_type: cards
view_options:
cards:
icon: person
icon: account_circle
title: '{{ first_name }} {{ last_name }}'
subtitle: '{{ title }}'
size: 4

View File

@@ -1,10 +1,13 @@
export class BaseException extends Error {
status: number;
code: string;
extensions: Record<string, any>;
constructor(message: string, status: number, code: string) {
constructor(message: string, status: number, code: string, extensions?: Record<string, any>) {
super(message);
this.status = status;
this.code = code;
this.extensions = extensions || {};
}
}

View File

@@ -1,7 +1,15 @@
import { BaseException } from './base';
import { Permission } from '../types';
type Extensions = {
field?: string;
collection?: string;
item?: string | number | (string | number)[];
action?: Permission['action'];
}
export class ForbiddenException extends BaseException {
constructor(message = `You don't have permission to access this.`) {
super(message, 403, 'NO_PERMISSION');
constructor(message = `You don't have permission to access this.`, extensions?: Extensions) {
super(message, 403, 'NO_PERMISSION', extensions);
}
}

View File

@@ -4,6 +4,7 @@ export * from './field-not-found';
export * from './forbidden';
export * from './hit-rate-limit';
export * from './invalid-credentials';
export * from './invalid-otp';
export * from './invalid-payload';
export * from './invalid-query';
export * from './item-limit';

View File

@@ -0,0 +1,7 @@
import { BaseException } from './base';
export class InvalidOTPException extends BaseException {
constructor(message = 'Invalid user OTP.') {
super(message, 401, 'INVALID_OTP');
}
}

View File

@@ -4,41 +4,48 @@ import logger from '../logger';
import env from '../env';
const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
let payload: any;
if (err instanceof BaseException) {
logger.debug(err);
res.status(err.status);
const payload: any = {
error: {
code: err.code,
message: err.message,
},
payload = {
errors: [
{
message: err.message,
extensions: {
...err.extensions,
code: err.code,
}
}
],
};
if (env.NODE_ENV === 'development') {
payload.error.stack = err.stack;
}
return res.json(payload);
} else {
logger.error(err);
res.status(500);
const payload: any = {
error: {
code: 'INTERNAL_SERVER_ERROR',
message: err.message,
},
payload = {
errors: [
{
message: err.message,
extensions: {
code: 'INTERNAL_SERVER_ERROR',
}
}
],
};
if (env.NODE_ENV === 'development') {
payload.error.stack = err.stack;
}
return res.json(payload);
}
if (env.NODE_ENV === 'development') {
payload.errors[0].extensions.exception = {
stack: err.stack
}
}
return res.json(payload);
};
export default errorHandler;

View File

@@ -3,7 +3,7 @@ import jwt from 'jsonwebtoken';
import argon2 from 'argon2';
import { nanoid } from 'nanoid';
import ms from 'ms';
import { InvalidCredentialsException, InvalidPayloadException } from '../exceptions';
import { InvalidCredentialsException, InvalidPayloadException, InvalidOTPException } from '../exceptions';
import { Session, Accountability, AbstractServiceOptions, Action } from '../types';
import Knex from 'knex';
import ActivityService from '../services/activity';
@@ -51,14 +51,14 @@ export default class AuthenticationService {
}
if (user.tfa_secret && !otp) {
throw new InvalidPayloadException(`"otp" is required`);
throw new InvalidOTPException(`"otp" is required`);
}
if (user.tfa_secret && otp) {
const otpValid = await this.verifyOTP(user.id, otp);
if (otpValid === false) {
throw new InvalidPayloadException(`"otp" is invalid`);
throw new InvalidOTPException(`"otp" is invalid`);
}
}
@@ -111,29 +111,31 @@ export default class AuthenticationService {
}
const record = await database
.select<Session & { email: string }>('directus_sessions.*', 'directus_users.email')
.select<Session & { email: string, id: string }>('directus_sessions.*', 'directus_users.email', 'directus_users.id')
.from('directus_sessions')
.where({ 'directus_sessions.token': refreshToken })
.leftJoin('directus_users', 'directus_sessions.user', 'directus_users.id')
.first();
/** @todo
* Check if it's worth checking for ip address and/or user agent. We could make this a little
* more secure by requiring the refresh token to be used from the same device / location as the
* auth session was created in the first place
*/
if (!record || !record.email || record.expires < new Date()) {
throw new InvalidCredentialsException();
}
await this.knex.delete().from('directus_sessions').where({ token: refreshToken });
return await this.authenticate({
email: record.email,
ip: record.ip,
userAgent: record.user_agent,
const accessToken = jwt.sign({ id: record.id }, env.SECRET as string, {
expiresIn: env.ACCESS_TOKEN_TTL,
});
const newRefreshToken = nanoid(64);
const refreshTokenExpiration = new Date(Date.now() + ms(env.REFRESH_TOKEN_TTL as string));
await this.knex('directus_sessions').update({ token: newRefreshToken, expires: refreshTokenExpiration }).where({ token: refreshToken });
return {
accessToken,
refreshToken: newRefreshToken,
expires: ms(env.ACCESS_TOKEN_TTL as string) / 1000,
id: record.id,
};
}
async logout(refreshToken: string) {

View File

@@ -7,7 +7,7 @@ import {
FieldAST,
Query,
Permission,
Operation,
PermissionsAction,
Item,
PrimaryKey,
} from '../types';
@@ -26,13 +26,13 @@ export default class AuthorizationService {
this.accountability = options?.accountability || null;
}
async processAST(ast: AST, operation: Operation = 'read'): Promise<AST> {
async processAST(ast: AST, action: PermissionsAction = 'read'): Promise<AST> {
const collectionsRequested = getCollectionsFromAST(ast);
const permissionsForCollections = await this.knex
.select<Permission[]>('*')
.from('directus_permissions')
.where({ operation, role: this.accountability?.role })
.where({ action, role: this.accountability?.role })
.whereIn(
'collection',
collectionsRequested.map(({ collection }) => collection)
@@ -165,18 +165,18 @@ export default class AuthorizationService {
/**
* Checks if the provided payload matches the configured permissions, and adds the presets to the payload.
*/
processValues(
operation: Operation,
validatePayload(
action: PermissionsAction,
collection: string,
payloads: Partial<Item>[]
): Promise<Partial<Item>[]>;
processValues(
operation: Operation,
validatePayload(
action: PermissionsAction,
collection: string,
payload: Partial<Item>
): Promise<Partial<Item>>;
async processValues(
operation: Operation,
async validatePayload(
action: PermissionsAction,
collection: string,
payload: Partial<Item>[] | Partial<Item>
): Promise<Partial<Item>[] | Partial<Item>> {
@@ -185,7 +185,7 @@ export default class AuthorizationService {
const permission = await this.knex
.select<Permission>('*')
.from('directus_permissions')
.where({ operation, collection, role: this.accountability?.role || null })
.where({ action, collection, role: this.accountability?.role || null })
.first();
if (!permission) throw new ForbiddenException();
@@ -200,7 +200,9 @@ export default class AuthorizationService {
);
if (invalidKeys.length > 0) {
throw new InvalidPayloadException(`Field "${invalidKeys[0]}" doesn't exist.`);
throw new ForbiddenException(
`You're not allowed to ${action} field "${invalidKeys[0]}" in collection "${collection}".`
)
}
}
}
@@ -209,7 +211,7 @@ export default class AuthorizationService {
payloads = payloads.map((payload) => merge({}, preset, payload));
const schema = generateJoi(permission.permissions);
const schema = generateJoi(permission.validation);
for (const payload of payloads) {
const { error } = schema.validate(payload);
@@ -226,7 +228,7 @@ export default class AuthorizationService {
}
}
async checkAccess(operation: Operation, collection: string, pk: PrimaryKey | PrimaryKey[]) {
async checkAccess(action: PermissionsAction, collection: string, pk: PrimaryKey | PrimaryKey[]) {
const itemsService = new ItemsService(collection, { accountability: this.accountability });
try {
@@ -234,12 +236,15 @@ export default class AuthorizationService {
fields: ['*'],
};
const result = await itemsService.readByKey(pk as any, query, operation);
const result = await itemsService.readByKey(pk as any, query, action);
if (!result) throw '';
if (Array.isArray(pk) && result.length !== pk.length) throw '';
} catch {
throw new ForbiddenException(
`You're not allowed to ${operation} item "${pk}" in collection "${collection}".`
`You're not allowed to ${action} item "${pk}" in collection "${collection}".`, {
collection, item: pk, action
}
);
}
}

View File

@@ -1,5 +1,5 @@
import database, { schemaInspector } from '../database';
import { AbstractServiceOptions, Accountability, Collection } from '../types';
import { AbstractServiceOptions, Accountability, Collection, Relation } from '../types';
import Knex from 'knex';
import { ForbiddenException, InvalidPayloadException } from '../exceptions';
import SchemaInspector from 'knex-schema-inspector';
@@ -101,7 +101,7 @@ export default class CollectionsService {
const permissions = await this.knex
.select('collection')
.from('directus_permissions')
.where({ operation: 'read' })
.where({ action: 'read' })
.where({ role: this.accountability.role })
.whereIn('collection', collectionKeys);
@@ -150,7 +150,7 @@ export default class CollectionsService {
const collectionsYouHavePermissionToRead: string[] = (
await this.knex.select('collection').from('directus_permissions').where({
role: this.accountability.role,
operation: 'read',
action: 'read',
})
).map(({ collection }) => collection);
@@ -228,6 +228,8 @@ export default class CollectionsService {
throw new ForbiddenException('Only admins can perform this action.');
}
const fieldsService = new FieldsService({ knex: this.knex, accountability: this.accountability });
const tablesInDatabase = await schemaInspector.tables();
const collectionKeys = Array.isArray(collection) ? collection : [collection];
@@ -242,11 +244,25 @@ export default class CollectionsService {
await this.knex('directus_presets').delete().whereIn('collection', collectionKeys);
await this.knex('directus_revisions').delete().whereIn('collection', collectionKeys);
await this.knex('directus_activity').delete().whereIn('collection', collectionKeys);
await this.knex('directus_permissions').delete().whereIn('collection', collectionKeys);
await this.knex('directus_relations')
.delete()
.whereIn('many_collection', collectionKeys)
.orWhereIn('one_collection', collectionKeys);
const relations = await this.knex
.select<Relation[]>('*')
.from('directus_relations')
.where({ many_collection: collection })
.orWhere({ one_collection: collection });
for (const relation of relations) {
const isM2O = relation.many_collection === collection;
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);
} else {
await this.knex('directus_relations').update({ one_field: null }).where({ one_collection: collection, field: relation.one_field });
await fieldsService.deleteField(relation.many_collection, relation.many_field);
}
}
const collectionItemsService = new ItemsService('directus_collections', {
knex: this.knex,

View File

@@ -1,12 +1,11 @@
import database, { schemaInspector } from '../database';
import { Field } from '../types/field';
import { uniq } from 'lodash';
import { Accountability, AbstractServiceOptions, FieldMeta } from '../types';
import { Accountability, AbstractServiceOptions, FieldMeta, Relation } from '../types';
import ItemsService from '../services/items';
import { ColumnBuilder } from 'knex';
import getLocalType from '../utils/get-local-type';
import { types } from '../types';
import { FieldNotFoundException } from '../exceptions';
import { FieldNotFoundException, ForbiddenException } from '../exceptions';
import Knex, { CreateTableBuilder } from 'knex';
import PayloadService from '../services/payload';
import getDefaultValue from '../utils/get-default-value';
@@ -34,24 +33,16 @@ export default class FieldsService {
this.payloadService = new PayloadService('directus_fields');
}
async fieldsInCollection(collection: string) {
const [fields, columns] = await Promise.all([
this.itemsService.readByQuery({ filter: { collection: { _eq: collection } } }),
schemaInspector.columns(collection),
]);
return uniq([...fields.map(({ field }) => field), ...columns.map(({ column }) => column)]);
}
async readAll(collection?: string) {
let fields: FieldMeta[];
const nonAuthorizedItemsService = new ItemsService('directus_fields', { knex: this.knex });
if (collection) {
fields = (await this.itemsService.readByQuery({
fields = (await nonAuthorizedItemsService.readByQuery({
filter: { collection: { _eq: collection } },
})) as FieldMeta[];
} else {
fields = (await this.itemsService.readByQuery({})) as FieldMeta[];
fields = (await nonAuthorizedItemsService.readByQuery({})) as FieldMeta[];
}
fields = (await this.payloadService.processValues('read', fields)) as FieldMeta[];
@@ -106,11 +97,50 @@ export default class FieldsService {
return data;
});
return [...columnsWithSystem, ...aliasFieldsAsField];
const result = [...columnsWithSystem, ...aliasFieldsAsField];
// Filter the result so we only return the fields you have read access to
if (this.accountability && this.accountability.admin !== true) {
const permissions = await this.knex.select('collection', 'fields').from('directus_permissions').where({ role: this.accountability.role, action: 'read' });
const allowedFieldsInCollection: Record<string, string[]> = {};
permissions.forEach((permission) => {
allowedFieldsInCollection[permission.collection] = (permission.fields || '').split(',');
});
if (collection && allowedFieldsInCollection.hasOwnProperty(collection) === false) {
throw new ForbiddenException();
}
return result.filter((field) => {
if (allowedFieldsInCollection.hasOwnProperty(field.collection) === false) return false;
const allowedFields = allowedFieldsInCollection[field.collection];
if (allowedFields[0] === '*') return true;
return allowedFields.includes(field.field);
});
}
return result;
}
/** @todo add accountability */
async readOne(collection: string, field: string) {
if (this.accountability && this.accountability.admin !== true) {
const permissions = await this.knex
.select('fields')
.from('directus_permissions')
.where({
role: this.accountability.role,
collection,
action: 'read'
}).first();
if (!permissions) throw new ForbiddenException();
if (permissions.fields !== '*') {
const allowedFields = (permissions.fields || '').split(',');
if (allowedFields.includes(field) === false) throw new ForbiddenException();
}
}
let column;
let fieldInfo = await this.knex
.select('*')
@@ -141,6 +171,10 @@ export default class FieldsService {
field: Partial<Field> & { field: string; type: typeof types[number] },
table?: CreateTableBuilder // allows collection creation to
) {
if (this.accountability && this.accountability.admin !== true) {
throw new ForbiddenException('Only admins can perform this action.');
}
/**
* @todo
* Check if table / directus_fields row already exists
@@ -167,7 +201,11 @@ export default class FieldsService {
/** @todo research how to make this happen in SQLite / Redshift */
async updateField(collection: string, field: RawField, accountability?: Accountability) {
async updateField(collection: string, field: RawField) {
if (this.accountability && this.accountability.admin !== true) {
throw new ForbiddenException('Only admins can perform this action.');
}
if (field.schema) {
await this.knex.schema.alterTable(collection, (table) => {
let column: ColumnBuilder;
@@ -208,21 +246,47 @@ export default class FieldsService {
.where({ collection, field: field.field })
.first();
if (!record) throw new FieldNotFoundException(collection, field.field);
await database('directus_fields')
.update(field.meta)
.where({ collection, field: field.field });
await this.itemsService.update({
...field.meta,
collection: collection,
field: field.field,
}, record.id);
}
return field.field;
}
/** @todo save accountability */
async deleteField(collection: string, field: string, accountability?: Accountability) {
await database('directus_fields').delete().where({ collection, field });
async deleteField(collection: string, field: string) {
if (this.accountability && this.accountability.admin !== true) {
throw new ForbiddenException('Only admins can perform this action.');
}
await database.schema.table(collection, (table) => {
table.dropColumn(field);
});
await this.knex('directus_fields').delete().where({ collection, field });
if (await schemaInspector.hasColumn(collection, field)) {
await this.knex.schema.table(collection, (table) => {
table.dropColumn(field);
});
}
const relations = await this.knex
.select<Relation[]>('*')
.from('directus_relations')
.where({ many_collection: collection, many_field: field })
.orWhere({ one_collection: collection, one_field: field });
for (const relation of relations) {
const isM2O = relation.many_collection === collection && relation.many_field === field;
if (isM2O) {
await this.knex('directus_relations').delete().where({ many_collection: collection, many_field: field });
await this.deleteField(relation.one_collection, relation.one_field);
} else {
await this.knex('directus_relations').update({ one_field: null }).where({ one_collection: collection, one_field: field });
}
}
}
public addColumnToTable(table: CreateTableBuilder, field: Field) {

View File

@@ -5,7 +5,7 @@ import getASTFromQuery from '../utils/get-ast-from-query';
import {
Action,
Accountability,
Operation,
PermissionsAction,
Item,
Query,
PrimaryKey,
@@ -69,7 +69,7 @@ export default class ItemsService implements AbstractService {
);
if (this.accountability && this.accountability.admin !== true) {
payloads = await authorizationService.processValues(
payloads = await authorizationService.validatePayload(
'create',
this.collection,
payloads
@@ -165,12 +165,12 @@ export default class ItemsService implements AbstractService {
return records;
}
readByKey(keys: PrimaryKey[], query?: Query, operation?: Operation): Promise<Item[]>;
readByKey(key: PrimaryKey, query?: Query, operation?: Operation): Promise<Item>;
readByKey(keys: PrimaryKey[], query?: Query, action?: PermissionsAction): Promise<Item[]>;
readByKey(key: PrimaryKey, query?: Query, action?: PermissionsAction): Promise<Item>;
async readByKey(
key: PrimaryKey | PrimaryKey[],
query: Query = {},
operation: Operation = 'read'
action: PermissionsAction = 'read'
): Promise<Item | Item[]> {
const schemaInspector = SchemaInspector(this.knex);
const primaryKeyField = await schemaInspector.primary(this.collection);
@@ -190,14 +190,14 @@ export default class ItemsService implements AbstractService {
this.collection,
queryWithFilter,
this.accountability,
operation
action
);
if (this.accountability && this.accountability.admin !== true) {
const authorizationService = new AuthorizationService({
accountability: this.accountability,
});
ast = await authorizationService.processAST(ast, operation);
ast = await authorizationService.processAST(ast, action);
}
const records = await runAST(ast);
@@ -226,8 +226,8 @@ export default class ItemsService implements AbstractService {
accountability: this.accountability,
});
await authorizationService.checkAccess('update', this.collection, keys);
payload = await authorizationService.processValues(
'validate',
payload = await authorizationService.validatePayload(
'update',
this.collection,
payload
);
@@ -331,7 +331,7 @@ export default class ItemsService implements AbstractService {
const schemaInspector = SchemaInspector(this.knex);
const primaryKeyField = await schemaInspector.primary(this.collection);
if (this.accountability && this.accountability.admin !== false) {
if (this.accountability && this.accountability.admin !== true) {
const authorizationService = new AuthorizationService({
accountability: this.accountability,
});

View File

@@ -14,10 +14,10 @@ import { URL } from 'url';
import Knex from 'knex';
import env from '../env';
type Operation = 'create' | 'read' | 'update';
type Action = 'create' | 'read' | 'update';
type Transformers = {
[type: string]: (operation: Operation, value: any, payload: Partial<Item>) => Promise<any>;
[type: string]: (action: Action, value: any, payload: Partial<Item>) => Promise<any>;
};
export default class PayloadService {
@@ -41,24 +41,24 @@ export default class PayloadService {
* in order to work
*/
public transformers: Transformers = {
async hash(operation, value) {
async hash(action, value) {
if (!value) return;
if (operation === 'create' || operation === 'update') {
if (action === 'create' || action === 'update') {
return await argon2.hash(String(value));
}
return value;
},
async uuid(operation, value) {
if (operation === 'create' && !value) {
async uuid(action, value) {
if (action === 'create' && !value) {
return uuidv4();
}
return value;
},
async 'file-links'(operation, value, payload) {
if (operation === 'read' && payload && payload.storage && payload.filename_disk) {
async 'file-links'(action, value, payload) {
if (action === 'read' && payload && payload.storage && payload.filename_disk) {
const publicKey = `STORAGE_${payload.storage.toUpperCase()}_PUBLIC_URL`;
return {
@@ -70,15 +70,15 @@ export default class PayloadService {
// This is an non-existing column, so there isn't any data to save
return undefined;
},
async boolean(operation, value) {
if (operation === 'read') {
async boolean(action, value) {
if (action === 'read') {
return value === true || value === 1 || value === '1';
}
return value;
},
async json(operation, value) {
if (operation === 'read') {
async json(action, value) {
if (action === 'read') {
if (typeof value === 'string') {
try {
return JSON.parse(value);
@@ -87,13 +87,19 @@ export default class PayloadService {
}
}
}
return value;
},
async conceal(action, value) {
if (action === 'read') return '**********';
return value;
}
};
processValues(operation: Operation, payloads: Partial<Item>[]): Promise<Partial<Item>[]>;
processValues(operation: Operation, payload: Partial<Item>): Promise<Partial<Item>>;
processValues(action: Action, payloads: Partial<Item>[]): Promise<Partial<Item>[]>;
processValues(action: Action, payload: Partial<Item>): Promise<Partial<Item>>;
async processValues(
operation: Operation,
action: Action,
payload: Partial<Item> | Partial<Item>[]
): Promise<Partial<Item> | Partial<Item>[]> {
const processedPayload = (Array.isArray(payload) ? payload : [payload]) as Partial<Item>[];
@@ -108,7 +114,7 @@ export default class PayloadService {
.where({ collection: this.collection })
.whereNotNull('special');
if (operation === 'read') {
if (action === 'read') {
specialFieldsQuery.whereIn('field', fieldsInPayload);
}
@@ -118,14 +124,14 @@ export default class PayloadService {
processedPayload.map(async (record: any) => {
await Promise.all(
specialFieldsInCollection.map(async (field) => {
const newValue = await this.processField(field, record, operation);
const newValue = await this.processField(field, record, action);
if (newValue !== undefined) record[field.field] = newValue;
})
);
})
);
if (['create', 'update'].includes(operation)) {
if (['create', 'update'].includes(action)) {
processedPayload.forEach((record) => {
for (const [key, value] of Object.entries(record)) {
if (Array.isArray(value) || (typeof value === 'object' && value !== null)) {
@@ -145,15 +151,22 @@ export default class PayloadService {
async processField(
field: Pick<FieldMeta, 'field' | 'special'>,
payload: Partial<Item>,
operation: Operation
action: Action
) {
if (!field.special) return payload[field.field];
if (this.transformers.hasOwnProperty(field.special)) {
return await this.transformers[field.special](operation, payload[field.field], payload);
const fieldSpecials = field.special.split(',').map(s => s.trim());
let value = clone(payload[field.field]);
for (const special of fieldSpecials) {
if (this.transformers.hasOwnProperty(special)) {
value = await this.transformers[special](action, value, payload);
}
}
return payload[field.field];
return value;
}
/**
@@ -267,3 +280,4 @@ export default class PayloadService {
}
}
}
0

View File

@@ -5,7 +5,7 @@ import { sendInviteMail } from '../mail';
import database from '../database';
import argon2 from 'argon2';
import { InvalidPayloadException } from '../exceptions';
import { Accountability, Query, Item, AbstractServiceOptions } from '../types';
import { Accountability, PrimaryKey, Item, AbstractServiceOptions } from '../types';
import Knex from 'knex';
import env from '../env';
@@ -22,6 +22,29 @@ export default class UsersService extends ItemsService {
this.service = new ItemsService('directus_users', options);
}
update(data: Partial<Item>, keys: PrimaryKey[]): Promise<PrimaryKey[]>;
update(data: Partial<Item>, key: PrimaryKey): Promise<PrimaryKey>;
update(data: Partial<Item>[]): Promise<PrimaryKey[]>;
async update(
data: Partial<Item> | Partial<Item>[],
key?: PrimaryKey | PrimaryKey[]
): Promise<PrimaryKey | PrimaryKey[]> {
/**
* @NOTE
* This is just an extra bit of hardcoded security. We don't want anybody to be able to disable 2fa through
* the regular /users endpoint. Period. You should only be able to manage the 2fa status through the /tfa endpoint.
*/
const payloads = Array.isArray(data) ? data : [data];
for (const payload of payloads) {
if (payload.hasOwnProperty('tfa_secret')) {
throw new InvalidPayloadException(`You can't change the tfa_secret manually.`);
}
}
return this.service.update(data, key as any);
}
async inviteUser(email: string, role: string) {
await this.service.create({ email, role, status: 'invited' });
@@ -64,7 +87,10 @@ export default class UsersService extends ItemsService {
await this.knex('directus_users').update({ tfa_secret: secret }).where({ id: pk });
return await authService.generateOTPAuthURL(pk, secret);
return {
secret,
url: await authService.generateOTPAuthURL(pk, secret),
};
}
async disableTFA(pk: string) {

106
api/src/services/utils.ts Normal file
View File

@@ -0,0 +1,106 @@
import { AbstractServiceOptions, Accountability, PrimaryKey } from "../types";
import database from '../database';
import Knex from 'knex';
import { InvalidPayloadException, ForbiddenException } from '../exceptions';
import SchemaInspector from 'knex-schema-inspector';
export default class UtilsService {
knex: Knex;
accountability: Accountability | null;
constructor(options?: AbstractServiceOptions) {
this.knex = options?.knex || database;
this.accountability = options?.accountability || null;
}
async sort(collection: string, { item, to }: { item: PrimaryKey, to: PrimaryKey }) {
const schemaInspector = SchemaInspector(this.knex);
const sortFieldResponse = await this.knex
.select('sort_field')
.from('directus_collections')
.where({ collection })
.first();
const sortField = sortFieldResponse?.sort_field;
if (!sortField) {
throw new InvalidPayloadException(`Collection "${collection}" doesn't have a sort field.`);
}
if (this.accountability?.admin !== true) {
const permissions = await this.knex
.select('fields')
.from('directus_permissions')
.where({
collection,
action: 'update',
role: this.accountability?.role || null,
})
.first();
if (!permissions) {
throw new ForbiddenException();
}
const allowedFields = permissions.fields.split(',');
if (allowedFields[0] !== '*' && allowedFields.includes(sortField) === false) {
throw new ForbiddenException();
}
}
const primaryKeyField = await schemaInspector.primary(collection);
// Make sure all rows have a sort value
const countResponse = await this.knex
.count('* as count')
.from(collection)
.whereNull(sortField)
.first();
if (countResponse?.count && +countResponse.count !== 0) {
const lastSortValueResponse = await this.knex
.max(sortField)
.from(collection)
.first();
const rowsWithoutSortValue = await this.knex
.select(primaryKeyField, sortField)
.from(collection)
.whereNull(sortField);
let lastSortValue = lastSortValueResponse ? Object.values(lastSortValueResponse)[0] : 0;
for (const row of rowsWithoutSortValue) {
lastSortValue++;
await this.knex(collection)
.update({ [sortField]: lastSortValue })
.where({ [primaryKeyField]: row[primaryKeyField] });
}
}
const targetSortValueResponse = await this.knex.select(sortField).from(collection).where({ [primaryKeyField]: to }).first();
const targetSortValue = targetSortValueResponse[sortField];
const sourceSortValueResponse = await this.knex.select(sortField).from(collection).where({ [primaryKeyField]: item }).first();
const sourceSortValue = sourceSortValueResponse[sortField];
// Set the target item to the new sort value
await this.knex(collection).update({ [sortField]: targetSortValue }).where({ [primaryKeyField]: item });
if (sourceSortValue < targetSortValue) {
await this.knex(collection)
.decrement(sortField, 1)
.where(sortField, '>', sourceSortValue)
.andWhere(sortField, '<=', targetSortValue)
.andWhereNot({ [primaryKeyField]: item });
} else {
await this.knex(collection)
.increment(sortField, 1)
.where(sortField, '>=', targetSortValue)
.andWhere(sortField, '<=', sourceSortValue)
.andWhereNot({ [primaryKeyField]: item });
}
}
}

View File

@@ -1,8 +1,7 @@
export type Operation =
export type PermissionsAction =
| 'create'
| 'read'
| 'update'
| 'validate'
| 'delete'
| 'comment'
| 'explain';
@@ -11,8 +10,9 @@ export type Permission = {
id: number;
role: string | null;
collection: string;
operation: Operation;
action: PermissionsAction;
permissions: Record<string, any>;
validation: Record<string, any>;
limit: number | null;
presets: Record<string, any> | null;
fields: string | null;

View File

@@ -2,7 +2,7 @@ import Knex from 'knex';
import { Accountability } from './accountability';
import { Item, PrimaryKey } from './items';
import { Query } from './query';
import { Operation } from './permissions';
import { PermissionsAction } from './permissions';
export type AbstractServiceOptions = {
knex?: Knex;
@@ -18,8 +18,8 @@ export interface AbstractService {
readByQuery(query: Query): Promise<Item[]>;
readByKey(keys: PrimaryKey[], query: Query, operation: Operation): Promise<Item[]>;
readByKey(key: PrimaryKey, query: Query, operation: Operation): Promise<Item>;
readByKey(keys: PrimaryKey[], query: Query, action: PermissionsAction): Promise<Item[]>;
readByKey(key: PrimaryKey, query: Query, action: PermissionsAction): Promise<Item>;
update(data: Partial<Item>, keys: PrimaryKey[]): Promise<PrimaryKey[]>;
update(data: Partial<Item>, key: PrimaryKey): Promise<PrimaryKey>;

View File

@@ -1,7 +1,14 @@
import { Filter } from '../types';
import Joi, { AnySchema } from 'joi';
export default function generateJoi(filter: Filter) {
/**
* @TODO
* This is copy pasted between app and api. Make this a reusable module.
*/
export default function generateJoi(filter: Filter | null) {
filter = filter || {};
const schema: Record<string, AnySchema> = {};
for (const [key, value] of Object.entries(filter)) {
@@ -10,10 +17,6 @@ export default function generateJoi(filter: Filter) {
if (isField) {
const operator = Object.keys(value)[0];
/** @TODO
* - Extend with all operators
*/
if (operator === '_eq') {
schema[key] = Joi.any().equal(Object.values(value)[0]);
}
@@ -22,6 +25,30 @@ export default function generateJoi(filter: Filter) {
schema[key] = Joi.any().not(Object.values(value)[0]);
}
if (operator === '_contains') {
schema[key] = Joi.string().custom((value, helpers) => {
const contains = value.includes(Object.values(value)[0]);
if (contains === false) {
return helpers.error(`"${key}" must include "${Object.values(value)[0]}"`);
}
return value;
});
}
if (operator === '_ncontains') {
schema[key] = Joi.string().custom((value, helpers) => {
const contains = value.includes(Object.values(value)[0]);
if (contains === true) {
return helpers.error(`"${key}" can't include "${Object.values(value)[0]}"`);
}
return value;
});
}
if (operator === '_in') {
schema[key] = Joi.any().equal(...(Object.values(value)[0] as (string | number)[]));
}
@@ -34,9 +61,43 @@ export default function generateJoi(filter: Filter) {
schema[key] = Joi.number().greater(Number(Object.values(value)[0]));
}
if (operator === '_gte') {
schema[key] = Joi.number().min(Number(Object.values(value)[0]));
}
if (operator === '_lt') {
schema[key] = Joi.number().less(Number(Object.values(value)[0]));
}
if (operator === '_lte') {
schema[key] = Joi.number().max(Number(Object.values(value)[0]));
}
if (operator === '_null') {
schema[key] = Joi.any().valid(null);
}
if (operator === '_nnull') {
schema[key] = Joi.any().invalid(null);
}
if (operator === '_empty') {
schema[key] = Joi.any().valid('');
}
if (operator === '_nempty') {
schema[key] = Joi.any().invalid('');
}
if (operator === '_between') {
const values = Object.values(value)[0] as number[];
schema[key] = Joi.number().greater(values[0]).less(values[1]);
}
if (operator === '_nbetween') {
const values = Object.values(value)[0] as number[];
schema[key] = Joi.number().less(values[0]).greater(values[1]);
}
}
}

View File

@@ -8,7 +8,7 @@ import {
FieldAST,
Query,
Relation,
Operation,
PermissionsAction,
Accountability,
} from '../types';
import database from '../database';
@@ -17,7 +17,7 @@ export default async function getASTFromQuery(
collection: string,
query: Query,
accountability?: Accountability | null,
operation?: Operation
action?: PermissionsAction
): Promise<AST> {
/**
* we might not need al this info at all times, but it's easier to fetch it all once, than trying to fetch it for every
@@ -30,7 +30,7 @@ export default async function getASTFromQuery(
? await database
.select<{ collection: string; fields: string }[]>('collection', 'fields')
.from('directus_permissions')
.where({ role: accountability.role, operation: operation || 'read' })
.where({ role: accountability.role, action: action || 'read' })
: null;
const ast: AST = {

190
app/package-lock.json generated
View File

@@ -1,6 +1,6 @@
{
"name": "@directus/app",
"version": "9.0.0-alpha.20",
"version": "9.0.0-alpha.25",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@@ -1274,6 +1274,11 @@
"integrity": "sha512-1dVNHT76Uu5N3eJNTYcvxee+jzX4Z9lfciqRRHCU27ihbUcYi+iSc2iml5Ke1LXe1SyJCLA0+14Jh4tXJgOppA==",
"dev": true
},
"@hapi/formula": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-2.0.0.tgz",
"integrity": "sha512-V87P8fv7PI0LH7LiVi8Lkf3x+KCO7pQozXRssAHNXXL9L1K+uyu4XypLXwxqVDKgyQai6qj3/KteNlrqDx4W5A=="
},
"@hapi/hoek": {
"version": "8.5.1",
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-8.5.1.tgz",
@@ -1292,6 +1297,11 @@
"@hapi/topo": "3.x.x"
}
},
"@hapi/pinpoint": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@hapi/pinpoint/-/pinpoint-2.0.0.tgz",
"integrity": "sha512-vzXR5MY7n4XeIvLpfl3HtE3coZYO4raKXW766R6DZw/6aLqR26iuZ109K7a0NtF2Db0jxqh7xz2AxkUwpUFybw=="
},
"@hapi/topo": {
"version": "3.1.6",
"resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-3.1.6.tgz",
@@ -2730,6 +2740,15 @@
"integrity": "sha512-1HcDas8SEj4z1Wc696tH56G8OlRaH/sqZOynNNB+HF0WOeXPaxTtbYzJY2oEfiUxjSKjhCKr+MvR7dCHcEelug==",
"dev": true
},
"@types/qrcode": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.3.5.tgz",
"integrity": "sha512-92QMnMb9m0ErBU20za5Eqtf4lzUcSkk5w/Cz30q5qod0lWHm2loztmFs2EnCY06yT51GY1+m/oFq2D8qVK2Bjg==",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"@types/reach__router": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/reach__router/-/reach__router-1.3.5.tgz",
@@ -5966,8 +5985,7 @@
"base64-js": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz",
"integrity": "sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g==",
"dev": true
"integrity": "sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g=="
},
"batch": {
"version": "0.6.1",
@@ -6371,11 +6389,29 @@
"isarray": "^1.0.0"
}
},
"buffer-alloc": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz",
"integrity": "sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==",
"requires": {
"buffer-alloc-unsafe": "^1.1.0",
"buffer-fill": "^1.0.0"
}
},
"buffer-alloc-unsafe": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz",
"integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg=="
},
"buffer-fill": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz",
"integrity": "sha1-+PeLdniYiO858gXNY39o5wISKyw="
},
"buffer-from": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz",
"integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==",
"dev": true
"integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A=="
},
"buffer-indexof": {
"version": "1.1.1",
@@ -6582,8 +6618,7 @@
"camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"dev": true
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="
},
"camelcase-keys": {
"version": "6.2.2",
@@ -7173,7 +7208,6 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz",
"integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==",
"dev": true,
"requires": {
"string-width": "^3.1.0",
"strip-ansi": "^5.2.0",
@@ -7183,20 +7217,17 @@
"ansi-regex": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz",
"integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==",
"dev": true
"integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg=="
},
"is-fullwidth-code-point": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz",
"integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=",
"dev": true
"integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8="
},
"string-width": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz",
"integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==",
"dev": true,
"requires": {
"emoji-regex": "^7.0.1",
"is-fullwidth-code-point": "^2.0.0",
@@ -7207,7 +7238,6 @@
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz",
"integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==",
"dev": true,
"requires": {
"ansi-regex": "^4.1.0"
}
@@ -8334,8 +8364,7 @@
"decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=",
"dev": true
"integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA="
},
"decamelize-keys": {
"version": "1.1.0",
@@ -8746,6 +8775,11 @@
}
}
},
"dijkstrajs": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.1.tgz",
"integrity": "sha1-082BIh4+pAdCz83lVtTpnpjdxxs="
},
"dir-glob": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-2.0.0.tgz",
@@ -9095,8 +9129,7 @@
"emoji-regex": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz",
"integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==",
"dev": true
"integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA=="
},
"emojis-list": {
"version": "3.0.0",
@@ -10711,8 +10744,7 @@
"get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="
},
"get-own-enumerable-property-symbols": {
"version": "3.0.2",
@@ -11700,8 +11732,7 @@
"ieee754": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz",
"integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==",
"dev": true
"integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg=="
},
"iferr": {
"version": "0.1.5",
@@ -14089,6 +14120,41 @@
"supports-color": "^7.0.0"
}
},
"joi": {
"version": "17.2.1",
"resolved": "https://registry.npmjs.org/joi/-/joi-17.2.1.tgz",
"integrity": "sha512-YT3/4Ln+5YRpacdmfEfrrKh50/kkgX3LgBltjqnlMPIYiZ4hxXZuVJcxmsvxsdeHg9soZfE3qXxHC2tMpCCBOA==",
"requires": {
"@hapi/address": "^4.1.0",
"@hapi/formula": "^2.0.0",
"@hapi/hoek": "^9.0.0",
"@hapi/pinpoint": "^2.0.0",
"@hapi/topo": "^5.0.0"
},
"dependencies": {
"@hapi/address": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@hapi/address/-/address-4.1.0.tgz",
"integrity": "sha512-SkszZf13HVgGmChdHo/PxchnSaCJ6cetVqLzyciudzZRT0jcOouIF/Q93mgjw8cce+D+4F4C1Z/WrfFN+O3VHQ==",
"requires": {
"@hapi/hoek": "^9.0.0"
}
},
"@hapi/hoek": {
"version": "9.0.4",
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.0.4.tgz",
"integrity": "sha512-EwaJS7RjoXUZ2cXXKZZxZqieGtc7RbvQhUy8FwDoMQtxWVi14tFjeFCYPZAM1mBCpOpiBpyaZbb9NeHc7eGKgw=="
},
"@hapi/topo": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.0.0.tgz",
"integrity": "sha512-tFJlT47db0kMqVm3H4nQYgn6Pwg10GTZHb1pwmSiv1K4ks6drQOtfEF5ZnPjkvC+y4/bUPHK+bc87QvLcL+WMw==",
"requires": {
"@hapi/hoek": "^9.0.0"
}
}
}
},
"js-beautify": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.11.0.tgz",
@@ -14796,7 +14862,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz",
"integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==",
"dev": true,
"requires": {
"p-locate": "^3.0.0",
"path-exists": "^3.0.0"
@@ -16488,7 +16553,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dev": true,
"requires": {
"p-try": "^2.0.0"
}
@@ -16497,7 +16561,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz",
"integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==",
"dev": true,
"requires": {
"p-limit": "^2.0.0"
}
@@ -16529,8 +16592,7 @@
"p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"dev": true
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="
},
"package-json": {
"version": "6.5.0",
@@ -16720,8 +16782,7 @@
"path-exists": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz",
"integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=",
"dev": true
"integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU="
},
"path-is-absolute": {
"version": "1.0.1",
@@ -16906,6 +16967,11 @@
"integrity": "sha512-2qHaIQr2VLRFoxe2nASzsV6ef4yOOH+Fi9FBOVH6cqeSgUnoyySPZkxzLuzd+RYOQTRpROA0ztTMqxROKSb/nA==",
"dev": true
},
"pngjs": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-3.4.0.tgz",
"integrity": "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w=="
},
"pnp-webpack-plugin": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/pnp-webpack-plugin/-/pnp-webpack-plugin-1.5.0.tgz",
@@ -18171,6 +18237,36 @@
"integrity": "sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=",
"dev": true
},
"qrcode": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.4.4.tgz",
"integrity": "sha512-oLzEC5+NKFou9P0bMj5+v6Z40evexeE29Z9cummZXZ9QXyMr3lphkURzxjXgPJC5azpxcshoDWV1xE46z+/c3Q==",
"requires": {
"buffer": "^5.4.3",
"buffer-alloc": "^1.2.0",
"buffer-from": "^1.1.1",
"dijkstrajs": "^1.0.1",
"isarray": "^2.0.1",
"pngjs": "^3.3.0",
"yargs": "^13.2.4"
},
"dependencies": {
"buffer": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.6.0.tgz",
"integrity": "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==",
"requires": {
"base64-js": "^1.0.2",
"ieee754": "^1.1.4"
}
},
"isarray": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
"integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="
}
}
},
"qs": {
"version": "6.9.4",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.9.4.tgz",
@@ -19250,14 +19346,12 @@
"require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=",
"dev": true
"integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I="
},
"require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"dev": true
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="
},
"requires-port": {
"version": "1.0.0",
@@ -19802,8 +19896,7 @@
"set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=",
"dev": true
"integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc="
},
"set-value": {
"version": "2.0.1",
@@ -23965,8 +24058,7 @@
"which-module": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz",
"integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=",
"dev": true
"integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho="
},
"which-pm-runs": {
"version": "1.0.0",
@@ -24066,7 +24158,6 @@
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz",
"integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==",
"dev": true,
"requires": {
"ansi-styles": "^3.2.0",
"string-width": "^3.0.0",
@@ -24076,14 +24167,12 @@
"ansi-regex": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz",
"integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==",
"dev": true
"integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg=="
},
"ansi-styles": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
"integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
"dev": true,
"requires": {
"color-convert": "^1.9.0"
}
@@ -24091,14 +24180,12 @@
"is-fullwidth-code-point": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz",
"integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=",
"dev": true
"integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8="
},
"string-width": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz",
"integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==",
"dev": true,
"requires": {
"emoji-regex": "^7.0.1",
"is-fullwidth-code-point": "^2.0.0",
@@ -24109,7 +24196,6 @@
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz",
"integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==",
"dev": true,
"requires": {
"ansi-regex": "^4.1.0"
}
@@ -24197,8 +24283,7 @@
"y18n": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz",
"integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==",
"dev": true
"integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w=="
},
"yallist": {
"version": "3.1.1",
@@ -24216,7 +24301,6 @@
"version": "13.3.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz",
"integrity": "sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==",
"dev": true,
"requires": {
"cliui": "^5.0.0",
"find-up": "^3.0.0",
@@ -24233,14 +24317,12 @@
"ansi-regex": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz",
"integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==",
"dev": true
"integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg=="
},
"find-up": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz",
"integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==",
"dev": true,
"requires": {
"locate-path": "^3.0.0"
}
@@ -24248,14 +24330,12 @@
"is-fullwidth-code-point": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz",
"integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=",
"dev": true
"integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8="
},
"string-width": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz",
"integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==",
"dev": true,
"requires": {
"emoji-regex": "^7.0.1",
"is-fullwidth-code-point": "^2.0.0",
@@ -24266,7 +24346,6 @@
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz",
"integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==",
"dev": true,
"requires": {
"ansi-regex": "^4.1.0"
}
@@ -24277,7 +24356,6 @@
"version": "13.1.2",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.2.tgz",
"integrity": "sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==",
"dev": true,
"requires": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"

View File

@@ -1,6 +1,6 @@
{
"name": "@directus/app",
"version": "9.0.0-alpha.20",
"version": "9.0.0-alpha.25",
"private": false,
"description": "Directus is an Open-Source Headless CMS & API for Managing Custom Databases",
"author": "Rijk van Zanten <rijk@rngr.org>",
@@ -50,6 +50,7 @@
"date-fns": "^2.14.0",
"diff": "^4.0.2",
"htmlhint": "^0.14.1",
"joi": "^17.2.1",
"js-yaml": "^3.14.0",
"jshint": "^2.11.1",
"jsonlint": "^1.6.3",
@@ -63,6 +64,7 @@
"nanoid": "^3.1.10",
"pinia": "^0.0.7",
"portal-vue": "^2.1.7",
"qrcode": "^1.4.4",
"resize-observer": "^1.0.0",
"semver": "^7.3.2",
"stylelint-config-prettier": "^8.0.2",
@@ -90,6 +92,7 @@
"@types/jest": "^26.0.5",
"@types/marked": "^1.1.0",
"@types/mime-types": "^2.1.0",
"@types/qrcode": "^1.3.5",
"@types/semver": "^7.3.1",
"@typescript-eslint/eslint-plugin": "^2.34.0",
"@typescript-eslint/parser": "^2.34.0",
@@ -155,5 +158,5 @@
"stylelint --fix"
]
},
"gitHead": "9092740d708c1e9ed1575b0932a01f531d224f77"
"gitHead": "c22537b6d0dd7dc6cc651a9200a68f6b060353d4"
}

View File

@@ -51,16 +51,17 @@ export const onError = async (error: RequestError) => {
/* istanbul ignore next */
const status = error.response?.status;
/* istanbul ignore next */
const code = error.response?.data?.error?.code;
const code = error.response?.data?.errors?.[0]?.extensions?.code;
if (status === 401 && code === 'INVALID_CREDENTIALS' && error.request.responseURL.includes('refresh') === false) {
try {
await refresh();
/** @todo retry failed request after successful refresh */
} catch {
logout({ reason: LogoutReason.ERROR_SESSION_EXPIRED });
return Promise.reject();
}
return api.request(error.config);
}
return Promise.reject(error);

View File

@@ -6,7 +6,15 @@
</div>
</transition>
<router-view v-if="!hydrating && appAccess" />
<v-info v-if="error" type="danger" :title="$t('unexpected_error')" icon="error" center>
{{ $t('unexpected_error_copy') }}
<template #append>
<v-error :error="error" />
</template>
</v-info>
<router-view v-else-if="!hydrating && appAccess" />
<v-info v-else-if="appAccess === false" center :title="$t('no_app_access')" type="danger" icon="block">
{{ $t('no_app_access_copy') }}
@@ -79,10 +87,12 @@ export default defineComponent({
const appAccess = computed(() => {
if (!userStore.state.currentUser) return true;
return userStore.state.currentUser?.role?.app_access;
return userStore.state.currentUser?.role?.app_access || false;
});
return { hydrating, brandStyle, appAccess };
const error = computed(() => appStore.state.error);
return { hydrating, brandStyle, appAccess, error };
},
});
</script>

View File

@@ -2,7 +2,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<g fill="none" fill-rule="evenodd">
<path fill="#ECEFF1" d="M0 0h64v64H0z"/>
<path d="M32 32c4.42 0 8-3.58 8-8s-3.58-8-8-8-8 3.58-8 8 3.58 8 8 8zm0 4c-5.34 0-16 2.68-16 8v4h32v-4c0-5.32-10.66-8-16-8z" fill="#B0BEC5" fill-rule="nonzero"/>
<path d="M8 8h48v48H8z"/>
<path d="M32 12a20 20 0 100 40 20 20 0 000-40zm-9.86 32.56C23 42.76 28.24 41 32 41s9.02 1.76 9.86 3.56a15.8 15.8 0 01-19.72 0zm22.58-2.9C41.86 38.18 34.92 37 32 37s-9.86 1.18-12.72 4.66A16.02 16.02 0 1148 32a15.9 15.9 0 01-3.28 9.66zM32 20c-3.88 0-7 3.12-7 7s3.12 7 7 7 7-3.12 7-7-3.12-7-7-7zm0 10a3 3 0 110-6 3 3 0 010 6z" fill="#B0BEC5"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 392 B

After

Width:  |  Height:  |  Size: 539 B

View File

@@ -46,9 +46,12 @@ body {
</style>
<style lang="scss" scoped>
@import '@/styles/mixins/breakpoint';
.v-breadcrumb {
display: flex;
align-items: center;
font-size: 12px;
.section {
display: contents;
@@ -89,5 +92,9 @@ body {
}
}
}
@include breakpoint(small) {
font-size: inherit;
}
}
</style>

View File

@@ -122,6 +122,7 @@ export default defineComponent({
const sizeClass = useSizeClass(props);
const component = computed<'a' | 'router-link' | 'button'>(() => {
if (props.disabled) return 'button';
if (notEmpty(props.href)) return 'a';
if (notEmpty(props.to)) return 'router-link';
return 'button';
@@ -208,6 +209,12 @@ body {
border-color: var(--v-button-background-color-hover);
}
&.activated {
color: var(--v-button-color);
background-color: var(--v-button-background-color);
border-color: var(--v-button-background-color);
}
&.align-left {
justify-content: flex-start;
}
@@ -240,8 +247,9 @@ body {
background-color: transparent;
&:hover {
&:not(.activated):hover {
color: var(--v-button-background-color-hover);
background-color: transparent;
border-color: var(--v-button-background-color-hover);
}

View File

@@ -107,7 +107,7 @@ export const htmlLabel = () => ({
},
template: `
<v-checkbox v-model="checked" @change="onChange">
<template #label>
<template>
Any <i>custom</i> markup in here
</template>
</v-checkbox>

View File

@@ -12,7 +12,7 @@
<div class="prepend" v-if="$scopedSlots.prepend"><slot name="prepend" /></div>
<v-icon class="checkbox" :name="icon" @click.stop="toggleInput" />
<span class="label type-text">
<slot name="label" v-if="customValue === false">{{ label }}</slot>
<slot v-if="customValue === false">{{ label }}</slot>
<input @click.stop class="custom-input" v-else v-model="_value" />
</span>
<div class="append" v-if="$scopedSlots.append"><slot name="append" /></div>
@@ -132,6 +132,7 @@ body {
appearance: none;
.label:not(:empty) {
flex-grow: 1;
margin-left: 8px;
transition: color var(--fast) var(--transition);

View File

@@ -1,6 +1,6 @@
<template>
<div class="v-error">
<output>{{ code }}</output>
<output>Code: {{ code }}</output>
<v-icon
v-tooltip="$t('copy_details')"
v-if="showCopy"
@@ -24,7 +24,7 @@ export default defineComponent({
},
setup(props) {
const code = computed(() => {
return props.error?.response?.data?.error?.code || 'UNKNOWN';
return props.error?.response?.data?.errors?.[0]?.extensions?.code || 'UNKNOWN';
});
const copied = ref(false);
@@ -34,7 +34,8 @@ export default defineComponent({
return { code, copyError, showCopy, copied };
async function copyError() {
await navigator.clipboard.writeText(JSON.stringify(props.error, null, 2));
const error = props.error?.response?.data || props.error;
await navigator.clipboard.writeText(JSON.stringify(error, null, 2));
copied.value = true;
}
},

View File

@@ -14,7 +14,7 @@
</template>
<template #append>
<v-icon name="add_box" @click="toggle" />
<v-icon name="add_box" outline @click="toggle" />
</template>
</v-input>
</template>

View File

@@ -31,7 +31,7 @@
<script lang="ts">
import { defineComponent, PropType, computed } from '@vue/composition-api';
import { Field } from '@/types';
import interfaces from '@/interfaces';
import { getInterfaces } from '@/interfaces';
export default defineComponent({
props: {
@@ -65,8 +65,10 @@ export default defineComponent({
},
},
setup(props) {
const interfaces = getInterfaces();
const interfaceExists = computed(() => {
return !!interfaces.find((inter) => inter.id === props.field.meta.interface);
return !!interfaces.value.find((inter) => inter.id === props.field.meta.interface);
});
return { interfaceExists };

View File

@@ -19,7 +19,7 @@
</template>
<script lang="ts">
import { defineComponent, PropType, computed, ref } from '@vue/composition-api';
import { defineComponent, PropType, computed, ref, provide } from '@vue/composition-api';
import { useFieldsStore } from '@/stores/';
import { Field } from '@/types';
import { useElementSize } from '@/composables/use-element-size';
@@ -83,6 +83,8 @@ export default defineComponent({
const { toggleBatchField, batchActiveFields } = useBatch();
provide('form-values', values);
return {
el,
formFields,
@@ -115,7 +117,7 @@ export default defineComponent({
const gridClass = computed<string | null>(() => {
if (el.value === null) return null;
if (width.value > 612 && width.value <= 792) {
if (width.value > 588 && width.value <= 792) {
return 'grid';
} else {
return 'grid with-fill';
@@ -180,6 +182,8 @@ body {
</style>
<style lang="scss" scoped>
@import '@/styles/mixins/breakpoint';
.v-form {
&.grid {
display: grid;
@@ -196,15 +200,27 @@ body {
& > .half,
& > .half-left,
& > .half-space {
grid-column: start / half;
grid-column: start / fill;
@include breakpoint(medium) {
grid-column: start / half;
}
}
& > .half-right {
grid-column: half / full;
grid-column: start / fill;
@include breakpoint(medium) {
grid-column: half / full;
}
}
& > .full {
grid-column: start / full;
grid-column: start / fill;
@include breakpoint(medium) {
grid-column: start / full;
}
}
& > .fill {

View File

@@ -0,0 +1,16 @@
<template functional>
<svg
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
fill-rule="evenodd"
clip-rule="evenodd"
stroke-linejoin="round"
stroke-miterlimit="2"
>
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.2 14.15a1.8 1.8 0 01-.35-.12c-.1-.05-.14-.18-.13-.3.02-.41-.01-.78.03-1.2.18-1.85 1.38-1.26 2.46-1.57.58-.16 1.17-.47 1.42-1.06.07-.16.02-.35-.1-.48a13.66 13.66 0 00-2.27-1.98 14.25 14.25 0 00-9.85-2.31.2.2 0 00-.15.32A5.17 5.17 0 0011.93 7c.1.06.06.2-.06.18-.3-.06-.68-.18-1.06-.4a.43.43 0 00-.38-.05l-.44.18a.2.2 0 00-.05.34 5.32 5.32 0 006.14.42c.1-.06.25.07.22.18-.07.21-.15.52-.23.95-.48 2.37-1.87 2.18-3.58 1.59-3.36-1.19-5.3-.22-7-2.1-.19-.21-.5-.29-.7-.09a1.55 1.55 0 00.1 2.29c.15.12.36.07.5-.06.07-.06.13-.1.21-.14.1-.04.16.11.07.18-.4.33-.51.7-.76 1.48-.38 1.16-.22 2.36-1.98 2.67-.93.04-.91.66-1.25 1.58A5.2 5.2 0 01.3 18.27c-.4.41-.44 1.18.14 1.23.18 0 .36-.03.55-.1.99-.4 1.75-1.65 2.47-2.46.8-.9 2.72-.51 4.17-1.4.82-.48 1.3-1.1 1.08-2.07-.02-.12.12-.2.18-.09.1.2.18.41.23.63.05.22.25.39.47.4 1.36.1 3.02 1.3 4.63 1.88.32.11.56-.26.43-.57-.1-.24-.18-.48-.23-.7-.02-.13.16-.16.22-.05a3.5 3.5 0 002.88 1.88c.46.03.97-.02 1.5-.18.63-.18 1.22-.42 1.91-.3.52.1 1 .36 1.3.79.36.5 1.04.7 1.54.38.28-.19.29-.58.13-.87-1.14-2.08-3.61-2.25-4.7-2.52z"/>
</svg>
</template>
<script lang="ts">
export default {};
</script>

View File

@@ -11,7 +11,7 @@
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M20 6h-8l-2-2H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm.295 7l-5-5v2.852H11v4.296h4.295V18l5-5z"
d="M11.17 8l-2-2H4v12h16V8h-8.83zM4 4h6l2 2h8a2 2 0 012 2v10a2 2 0 01-2 2H4a2 2 0 01-2-2l.01-12A2 2 0 014 4zm10.44 5l4 4-4 4v-3H11v-2h3.44V9z"
/>
</svg>
</template>

View File

@@ -0,0 +1,22 @@
<template functional>
<svg
viewBox="0 0 24 24"
width="24"
height="24"
fill="none"
fill-rule="evenodd"
clip-rule="evenodd"
stroke-linejoin="round"
stroke-miterlimit="2"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M0 0h24v24H0z" fill="none"/>
<path
d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5zM4 5h8V3H4a2 2 0 00-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z"/>
</svg>
</template>
<script lang="ts">
export default {};
</script>

View File

@@ -54,7 +54,6 @@ export const interactive = () => ({
template: `
<v-icon
:name="name"
:outline="outline"
:sup="sup"
:style="{'--v-icon-color': color}"
:x-small="size === 'xSmall'"

View File

@@ -7,7 +7,7 @@
:tabindex="hasClick ? 0 : null"
>
<component v-if="customIconName" :is="customIconName" />
<i v-else :class="{ outline }">{{ name }}</i>
<i v-else :class="{ filled }">{{ name }}</i>
</span>
</template>
@@ -15,6 +15,7 @@
import { defineComponent, computed } from '@vue/composition-api';
import useSizeClass, { sizeProps } from '@/composables/size-class';
import CustomIconDirectus from './custom-icons/directus.vue';
import CustomIconBox from './custom-icons/box.vue';
import CustomIconCommitNode from './custom-icons/commit_node.vue';
import CustomIconGrid1 from './custom-icons/grid_1.vue';
@@ -29,8 +30,10 @@ import CustomIconSignalWifi3Bar from './custom-icons/signal_wifi_3_bar.vue';
import CustomIconFlipHorizontal from './custom-icons/flip_horizontal.vue';
import CustomIconFlipVertical from './custom-icons/flip_vertical.vue';
import CustomIconFolderMove from './custom-icons/folder_move.vue';
import CustomIconLogout from './custom-icons/logout.vue';
const customIcons: string[] = [
'directus',
'box',
'commit_node',
'grid_1',
@@ -45,10 +48,12 @@ const customIcons: string[] = [
'flip_horizontal',
'flip_vertical',
'folder_move',
'logout',
];
export default defineComponent({
components: {
CustomIconDirectus,
CustomIconBox,
CustomIconCommitNode,
CustomIconGrid1,
@@ -63,13 +68,14 @@ export default defineComponent({
CustomIconFlipHorizontal,
CustomIconFlipVertical,
CustomIconFolderMove,
CustomIconLogout,
},
props: {
name: {
type: String,
required: true,
},
outline: {
filled: {
type: Boolean,
default: false,
},
@@ -143,7 +149,7 @@ body {
font-weight: normal;
font-size: var(--v-icon-size);
/* stylelint-disable-next-line font-family-no-missing-generic-family-keyword */
font-family: 'Material Icons';
font-family: 'Material Icons Outline';
font-style: normal;
line-height: 1;
letter-spacing: normal;
@@ -152,9 +158,9 @@ body {
word-wrap: normal;
font-feature-settings: 'liga';
&.outline {
&.filled {
/* stylelint-disable-next-line font-family-no-missing-generic-family-keyword */
font-family: 'Material Icons Outline';
font-family: 'Material Icons';
}
}

View File

@@ -1,7 +1,7 @@
<template>
<div class="v-info" :class="[type, { center }]">
<div class="icon">
<v-icon large :name="icon" />
<v-icon large :name="icon" outline />
</div>
<h2 class="title type-title">{{ title }}</h2>
<p class="content"><slot /></p>

View File

@@ -138,6 +138,7 @@ export default defineComponent({
return { _listeners, hasClick, stepUp, stepDown, input };
function processValue(event: KeyboardEvent) {
if (!event.key) return;
const key = event.key.toLowerCase();
const systemKeys = ['meta', 'shift', 'alt', 'backspace', 'tab'];
const value = (event.target as HTMLInputElement).value;

View File

@@ -164,6 +164,7 @@ body {
background-color: var(--background-normal);
transform: translateX(-100%);
transition: transform var(--slow) var(--transition-out);
z-index: 2;
&.active {
transform: translateX(0);
@@ -177,16 +178,17 @@ body {
}
.v-overlay {
--v-overlay-z-index: none;
--v-overlay-z-index: 1;
@include breakpoint(medium) {
--v-overlay-z-index: none;
display: none;
}
}
.main {
flex-grow: 1;
padding: 8px 16px;
padding: 16px 16px 32px;
overflow: auto;
@include breakpoint(medium) {

View File

@@ -33,7 +33,7 @@ export default defineComponent({
<style>
body {
--v-overlay-color: rgba(38, 50, 56, 0.9);
--v-overlay-color: var(--overlay-color);
--v-overlay-z-index: 500;
}
</style>

View File

@@ -1,11 +1,12 @@
<template>
<div class="v-pagination">
<v-button v-if="value !== 1" :disabled="disabled" secondary icon small @click="toPrev">
<v-button v-if="value !== 1" class="previous" :disabled="disabled" secondary icon small @click="toPrev">
<v-icon name="chevron_left" />
</v-button>
<v-button
v-if="showFirstLast && value > Math.ceil(totalVisible / 2)"
class="page"
@click="toPage(1)"
secondary
small
@@ -20,6 +21,7 @@
v-for="page in visiblePages"
:key="page"
:class="{ active: value === page }"
class="page"
@click="toPage(page)"
secondary
small
@@ -33,8 +35,9 @@
</span>
<v-button
v-if="showFirstLast && value < length - Math.ceil(totalVisible / 2)"
v-if="showFirstLast && value <= length - Math.ceil(totalVisible / 2)"
:class="{ active: value === length }"
class="page"
@click="toPage(length)"
secondary
small
@@ -43,7 +46,7 @@
{{ length }}
</v-button>
<v-button v-if="value !== length" :disabled="disabled" secondary icon small @click="toNext">
<v-button v-if="value !== length" class="next" :disabled="disabled" secondary icon small @click="toNext">
<v-icon name="chevron_right" />
</v-button>
</div>
@@ -130,10 +133,20 @@ body {
</style>
<style lang="scss" scoped>
@import '@/styles/mixins/breakpoint';
.v-pagination {
display: flex;
.gap {
margin: 0 4px;
color: var(--foreground-subdued);
display: none;
line-height: 2em;
@include breakpoint(small) {
display: inline;
}
}
.v-button {
@@ -144,6 +157,14 @@ body {
margin: 0 2px;
vertical-align: middle;
&.page:not(.active) {
display: none;
@include breakpoint(small) {
display: inline;
}
}
& ::v-deep {
.small {
--v-button-min-width: 32px;

View File

@@ -120,10 +120,14 @@ body {
@keyframes indeterminate {
0% {
transform: scaleX(0);
}
10% {
transform: scaleX(0);
animation-timing-function: cubic-bezier(0.1, 0.6, 0.9, 0.5);
}
50% {
60% {
transform: scaleX(1) translateX(25%);
animation-timing-function: cubic-bezier(0.4, 0.1, 0.2, 0.9);
}

View File

@@ -90,7 +90,7 @@ export const htmlLabel = () => ({
},
template: `
<v-switch v-model="checked" @change="onChange">
<template #label>
<template>
Any <i>custom</i> markup in here
</template>
</v-switch>

View File

@@ -45,8 +45,8 @@
tag="tbody"
handle=".drag-handle"
:disabled="disabled || _sort.by !== manualSortKey"
@change="onSortChange"
:set-data="hideDragImage"
@end="onSortChange"
>
<table-row
v-for="item in _items"
@@ -363,25 +363,18 @@ export default defineComponent({
}
}
interface VueDraggableChangeEvent extends CustomEvent {
moved?: {
oldIndex: number;
newIndex: number;
element: Record<string, any>;
};
interface EndEvent extends CustomEvent {
oldIndex: number;
newIndex: number;
}
function onSortChange(event: VueDraggableChangeEvent) {
function onSortChange(event: EndEvent) {
if (props.disabled) return;
if (event.moved) {
emit('manual-sort', {
item: event.moved.element,
oldIndex: event.moved.oldIndex,
newIndex: event.moved.newIndex,
});
}
const item = _items.value[event.oldIndex][props.itemKey];
const to = _items.value[event.newIndex][props.itemKey];
emit('manual-sort', { item, to });
}
},
});

View File

@@ -41,7 +41,7 @@ export default defineComponent({
<style>
body {
--v-tab-color: var(--foreground-normal);
--v-tab-color: var(--foreground-subdued);
--v-tab-background-color: var(--background-page);
--v-tab-color-active: var(--foreground-normal);
--v-tab-background-color-active: var(--background-page);
@@ -50,12 +50,13 @@ body {
<style lang="scss" scoped>
.v-tab.horizontal {
transition: color var(--fast) var(--transition);
color: var(--v-tab-color);
font-weight: 500;
font-size: 12px;
text-transform: uppercase;
font-size: 14px;
background-color: var(--v-tab-background-color);
&:hover,
&.active {
color: var(--v-tab-color-active);
background-color: var(--v-tab-background-color-active);

View File

@@ -4,7 +4,6 @@
</v-list>
<div v-else class="v-tabs horizontal">
<slot />
<div class="slider" :style="slideStyle" />
</div>
</template>
@@ -40,20 +39,11 @@ export default defineComponent({
'v-tabs'
);
const slideStyle = computed(() => {
const activeIndex = items.value.findIndex((item) => item.active.value);
return {
'--_v-tabs-items': items.value.length,
'--_v-tabs-selected': activeIndex,
};
});
function update(newSelection: readonly (string | number)[]) {
emit('input', newSelection);
}
return { update, slideStyle, items };
return { update, items };
},
});
</script>
@@ -67,7 +57,7 @@ body {
<style lang="scss" scoped>
.v-tabs.horizontal {
position: relative;
display: flex;
display: inline-flex;
::v-deep .v-tab {
display: flex;
@@ -76,20 +66,9 @@ body {
flex-shrink: 0;
align-items: center;
justify-content: center;
height: 44px;
padding: 12px 20px;
height: 38px;
padding: 8px 20px;
cursor: pointer;
}
.slider {
position: absolute;
bottom: 0;
left: calc(100% / var(--_v-tabs-items) * var(--_v-tabs-selected));
width: calc(100% / var(--_v-tabs-items));
height: 2px;
background-color: var(--v-tabs-underline-color);
transition: var(--medium) cubic-bezier(0.66, 0, 0.33, 1);
transition-property: left, top;
}
}
</style>

View File

@@ -28,34 +28,9 @@ export function useCollection(collectionKey: string | Ref<string>) {
return fields.value?.find((field) => field.meta?.special === 'user_created') || null;
});
const statusField = computed(() => {
return fields.value?.find((field) => field.meta?.special === 'status') || null;
});
const sortField = computed(() => {
return fields.value?.find((field) => field.meta?.special === 'sort') || null;
return info.value?.meta?.sort_field || null;
});
type Status = {
background_color: string;
browse_badge: string;
browse_subdued: string;
name: string;
published: boolean;
required_fields: boolean;
soft_delete: boolean;
text_color: string;
value: string;
};
const softDeleteStatus = computed<string | null>(() => {
if (statusField.value === null) return null;
const statuses = Object.values(statusField.value?.meta?.options?.status_mapping || {});
return (
(statuses.find((status) => (status as Status).soft_delete === true) as Status | undefined)?.value || null
);
});
return { info, fields, primaryKeyField, userCreatedField, statusField, softDeleteStatus, sortField };
return { info, fields, primaryKeyField, userCreatedField, sortField };
}

View File

@@ -1,13 +1,14 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { computed, Ref } from '@vue/composition-api';
import { isEmpty } from '@/utils/is-empty';
import getDefaultInterfaceForType from '@/utils/get-default-interface-for-type';
import interfaces from '@/interfaces';
import { getInterfaces } from '@/interfaces';
import { FormField } from '@/components/v-form/types';
import { Field } from '@/types';
export default function useFormFields(fields: Ref<Field[]>) {
const interfaces = getInterfaces();
const formFields = computed(() => {
let formFields = [...fields.value];
@@ -49,14 +50,14 @@ export default function useFormFields(fields: Ref<Field[]>) {
field.meta.width = 'full';
}
let interfaceUsed = interfaces.find((int) => int.id === field.meta.interface);
let interfaceUsed = interfaces.value.find((int) => int.id === field.meta.interface);
const interfaceExists = interfaceUsed !== undefined;
if (interfaceExists === false) {
field.meta.interface = getDefaultInterfaceForType(field.type);
}
interfaceUsed = interfaces.find((int) => int.id === field.meta.interface);
interfaceUsed = interfaces.value.find((int) => int.id === field.meta.interface);
if (interfaceUsed?.hideLabel === true) {
(field as FormField).hideLabel = true;
@@ -80,7 +81,7 @@ export default function useFormFields(fields: Ref<Field[]>) {
// Filter out the fields that are marked hidden on detail
formFields = formFields.filter((field) => {
const hidden = field.meta?.hidden;
const systemFake = field.field.startsWith('$');
const systemFake = field.field?.startsWith('$') || false;
return hidden !== true && systemFake === false;
});

View File

@@ -6,7 +6,7 @@ import useCollection from '@/composables/use-collection';
import { AxiosResponse } from 'axios';
export function useItem(collection: Ref<string>, primaryKey: Ref<string | number | null>) {
const { info: collectionInfo, primaryKeyField, softDeleteStatus, statusField } = useCollection(collection);
const { info: collectionInfo, primaryKeyField } = useCollection(collection);
const item = ref<any>(null);
const error = ref(null);
@@ -45,6 +45,7 @@ export function useItem(collection: Ref<string>, primaryKey: Ref<string | number
save,
isNew,
remove,
softDelete,
deleting,
softDeleting,
saveAsCopy,
@@ -176,25 +177,18 @@ export function useItem(collection: Ref<string>, primaryKey: Ref<string | number
}
}
async function remove(soft = false) {
if (soft) {
softDeleting.value = true;
} else {
deleting.value = true;
}
async function softDelete() {
if (!collectionInfo.value?.meta?.soft_delete_field) return;
softDeleting.value = true;
const field = collectionInfo.value.meta.soft_delete_field;
const value = collectionInfo.value.meta.soft_delete_value;
try {
if (soft) {
if (!statusField.value || softDeleteStatus.value === null) {
throw new Error('[useItem] You cant soft-delete without a status field');
}
await api.patch(itemEndpoint.value, {
[statusField.value.field]: softDeleteStatus.value,
});
} else {
await api.delete(itemEndpoint.value);
}
await api.patch(itemEndpoint.value, {
[field]: value,
});
item.value = null;
@@ -218,11 +212,39 @@ export function useItem(collection: Ref<string>, primaryKey: Ref<string | number
throw err;
} finally {
if (soft) {
softDeleting.value = false;
} else {
deleting.value = false;
}
softDeleting.value = false;
}
}
async function remove() {
deleting.value = true;
try {
await api.delete(itemEndpoint.value);
item.value = null;
notify({
title: i18n.tc('item_delete_success', isBatch.value ? 2 : 1),
text: i18n.tc('item_in', isBatch.value ? 2 : 1, {
collection: collection.value,
primaryKey: isBatch.value ? (primaryKey.value as string).split(',').join(', ') : primaryKey.value,
}),
type: 'success',
});
} catch (err) {
notify({
title: i18n.tc('item_delete_failed', isBatch.value ? 2 : 1),
text: i18n.tc('item_in', isBatch.value ? 2 : 1, {
collection: collection.value,
primaryKey: isBatch.value ? (primaryKey.value as string).split(',').join(', ') : primaryKey.value,
}),
type: 'error',
});
throw err;
} finally {
deleting.value = false;
}
}

View File

@@ -216,50 +216,20 @@ export function useItems(collection: Ref<string>, query: Query) {
}
type ManualSortData = {
item: Record<string, any>;
oldIndex: number;
newIndex: number;
item: string | number;
to: string | number;
};
async function changeManualSort({ item, oldIndex, newIndex }: ManualSortData) {
async function changeManualSort({ item, to }: ManualSortData) {
const pk = primaryKeyField.value?.field;
if (!pk) return;
const move = newIndex > oldIndex ? 'down' : 'up';
const selectionRange = move === 'down' ? [oldIndex + 1, newIndex + 1] : [newIndex, oldIndex];
const fromIndex = items.value.findIndex((existing: Record<string, any>) => existing[pk] === item);
const toIndex = items.value.findIndex((existing: Record<string, any>) => existing[pk] === to);
const updates = items.value.slice(...selectionRange).map((toBeUpdatedItem: any) => {
const sortValue = getPositionForItem(toBeUpdatedItem);
items.value = moveInArray(items.value, fromIndex, toIndex);
return {
[pk]: toBeUpdatedItem[pk],
sort: move === 'down' ? sortValue - 1 : sortValue + 1,
};
});
const sortOfItemOnNewIndex = newIndex + 1 + limit.value * (page.value - 1);
updates.push({
[pk]: item[pk],
sort: sortOfItemOnNewIndex,
});
// Reflect changes in local items array
items.value = moveInArray(items.value, oldIndex, newIndex);
// Save updates to items
await api.patch(endpoint.value, updates);
}
// Used as default value for the sort position. This is the index of the given item in the array
// of items, offset by the page count and current page
function getPositionForItem(item: any) {
const pk = primaryKeyField.value?.field;
if (!pk) return;
const index = items.value.findIndex((existingItem: any) => existingItem[pk] === item[pk]);
return index + 1 + limit.value * (page.value - 1);
const endpoint = computed(() => `/utils/sort/${collection.value}`);
await api.post(endpoint.value, { item, to });
}
}

View File

@@ -12,6 +12,9 @@
<v-list-item-content>
<render-template :template="template" :item="item" :collection="relatedCollection" />
</v-list-item-content>
<v-list-item-icon>
<v-icon name="launch" small />
</v-list-item-icon>
</v-list-item>
</v-list>
</v-menu>

View File

@@ -8,7 +8,9 @@ import {
useSettingsStore,
useLatencyStore,
useRelationsStore,
usePermissionsStore,
} from '@/stores';
import { register as registerModules, unregister as unregisterModules } from '@/modules/register';
import { setLanguage, Language } from '@/lang';
@@ -30,6 +32,7 @@ export function useStores(
useSettingsStore,
useLatencyStore,
useRelationsStore,
usePermissionsStore,
]
) {
return stores.map((useStore) => useStore()) as GenericStore[];
@@ -57,6 +60,8 @@ export async function hydrate(stores = useStores()) {
setLanguage((userStore.state.currentUser?.locale as Language) || 'en-US');
await Promise.all(stores.filter(({ id }) => id !== 'userStore').map((store) => store.hydrate?.()));
registerModules();
} catch (error) {
appStore.state.error = error;
} finally {
@@ -76,5 +81,7 @@ export async function dehydrate(stores = useStores()) {
await store.dehydrate?.();
}
unregisterModules();
appStore.state.hydrated = false;
}

View File

@@ -73,7 +73,15 @@ export default defineComponent({
const content = cm.getValue();
if (props.type === 'json') {
emit('input', JSON.parse(content));
if (content.length === 0) {
return emit('input', null);
}
try {
emit('input', JSON.parse(content));
} catch {
// We won't stage invalid JSON
}
} else {
emit('input', content);
}
@@ -130,9 +138,7 @@ export default defineComponent({
if (text.length > 0) {
try {
jsonlint.parse(text);
} catch (e) {
console.error(e);
}
} catch (e) {}
}
return found;
});
@@ -274,11 +280,7 @@ export default defineComponent({
.interface-code {
position: relative;
width: 100%;
font-size: 12px;
&:focus {
border-color: var(--primary-125);
}
font-size: 14px;
}
.small {

View File

@@ -1,65 +1,12 @@
import InterfaceCheckboxes from './checkboxes';
import InterfaceCode from './code';
import InterfaceCollections from './collections';
import InterfaceColor from './color';
import InterfaceDateTime from './datetime';
import InterfaceDivider from './divider/';
import InterfaceDropdown from './dropdown/';
import InterfaceDropdownMultiselect from './dropdown-multiselect/';
import InterfaceFile from './file';
import InterfaceFiles from './files';
import InterfaceHash from './hash';
import InterfaceIcon from './icon';
import InterfaceImage from './image';
import InterfaceManyToMany from './many-to-many';
import InterfaceManyToOne from './many-to-one';
import InterfaceNotice from './notice';
import InterfaceNumeric from './numeric/';
import InterfaceOneToMany from './one-to-many';
import InterfaceRadioButtons from './radio-buttons';
import InterfaceRepeater from './repeater';
import InterfaceSlider from './slider/';
import InterfaceSlug from './slug';
import InterfaceStatus from './status';
import InterfaceTags from './tags';
import InterfaceTextarea from './textarea/';
import InterfaceTextInput from './text-input/';
import InterfaceToggle from './toggle/';
import InterfaceTranslations from './translations';
import InterfaceUser from './user';
import InterfaceWYSIWYG from './wysiwyg/';
import { ref, Ref } from '@vue/composition-api';
import { InterfaceConfig } from './types';
export const interfaces = [
InterfaceCheckboxes,
InterfaceCode,
InterfaceCollections,
InterfaceColor,
InterfaceDateTime,
InterfaceDivider,
InterfaceDropdown,
InterfaceDropdownMultiselect,
InterfaceFile,
InterfaceFiles,
InterfaceHash,
InterfaceIcon,
InterfaceImage,
InterfaceManyToMany,
InterfaceManyToOne,
InterfaceNotice,
InterfaceNumeric,
InterfaceOneToMany,
InterfaceRadioButtons,
InterfaceRepeater,
InterfaceSlider,
InterfaceSlug,
InterfaceStatus,
InterfaceTags,
InterfaceTextarea,
InterfaceTextInput,
InterfaceToggle,
InterfaceTranslations,
InterfaceUser,
InterfaceWYSIWYG,
];
let interfaces: Ref<InterfaceConfig[]>;
export default interfaces;
export function getInterfaces() {
if (!interfaces) {
interfaces = ref([]);
}
return interfaces;
}

View File

@@ -0,0 +1,12 @@
import { defineInterface } from '../define';
import InterfaceOptions from './interface-options.vue';
export default defineInterface(({ i18n }) => ({
id: 'interface-options',
name: 'Interface Options',
icon: 'box',
component: InterfaceOptions,
types: ['string'],
options: [],
system: true,
}));

View File

@@ -0,0 +1,66 @@
<template>
<v-notice v-if="!selectedInterface">
{{ $t('select_interface') }}
</v-notice>
<v-notice v-else-if="!selectedInterface.options">
{{ $t('no_options_available') }}
</v-notice>
<div class="inset" v-else>
<v-form
v-if="Array.isArray(selectedInterface.options)"
:fields="selectedInterface.options"
primary-key="+"
:edits="value"
@input="$listeners.input"
/>
<component
:value="value"
@input="$listeners.input"
:field-data="fieldData"
:is="`interface-options-${selectedInterface.id}`"
v-else
/>
</div>
</template>
<script lang="ts">
import { defineComponent, computed, inject, ref } from '@vue/composition-api';
import { getInterfaces } from '@/interfaces';
export default defineComponent({
props: {
value: {
type: Object,
default: null,
},
interfaceField: {
type: String,
required: true,
},
},
setup(props, { parent }) {
const interfaces = getInterfaces();
const values = inject('form-values', ref<Record<string, any>>({}));
const selectedInterface = computed(() => {
if (!values.value[props.interfaceField]) return;
return interfaces.value.find((inter) => inter.id === values.value[props.interfaceField]);
});
return { selectedInterface, values };
},
});
</script>
<style lang="scss" scoped>
.inset {
padding: 8px;
border: var(--border-width) solid var(--border-normal);
border-radius: var(--border-radius);
}
</style>

View File

@@ -0,0 +1,12 @@
import { defineInterface } from '../define';
import InterfaceInterface from './interface.vue';
export default defineInterface(({ i18n }) => ({
id: 'interface',
name: 'Interface',
icon: 'box',
component: InterfaceInterface,
types: ['string'],
system: true,
options: [],
}));

View File

@@ -0,0 +1,34 @@
<template>
<v-select :items="items" @input="$listeners.input" :value="value" />
</template>
<script lang="ts">
import { defineComponent, computed } from '@vue/composition-api';
import i18n from '@/lang';
import { getInterfaces } from '@/interfaces';
export default defineComponent({
props: {
value: {
type: String,
default: null,
},
},
setup() {
const interfaces = getInterfaces();
const items = computed(() => {
return interfaces.value
.filter((inter) => inter.relationship === undefined && inter.system !== true)
.map((inter) => {
return {
text: inter.name,
value: inter.id,
};
});
});
return { items };
},
});
</script>

View File

@@ -0,0 +1,132 @@
# h1 Heading
## h2 Heading
### h3 Heading
#### h4 Heading
##### h5 Heading
###### h6 Heading
## Horizontal Rules
___
---
***
## Emphasis
**This is bold text**
__This is bold text__
*This is italic text*
_This is italic text_
~~Strikethrough~~
## Blockquotes
> Blockquotes can also be nested...
>> ...by using additional greater-than signs right next to each other...
> > > ...or with spaces between arrows.
## Lists
Unordered
+ Create a list by starting a line with `+`, `-`, or `*`
+ Sub-lists are made by indenting 2 spaces:
- Marker character change forces new list start:
* Ac tristique libero volutpat at
+ Facilisis in pretium nisl aliquet
- Nulla volutpat aliquam velit
+ Very easy!
Ordered
1. Lorem ipsum dolor sit amet
2. Consectetur adipiscing elit
3. Integer molestie lorem at massa
1. You can use sequential numbers...
1. ...or keep all the numbers as `1.`
Start numbering with offset:
57. foo
1. bar
## Code
Inline `code`
Indented code
// Some comments
line 1 of code
line 2 of code
line 3 of code
Block code "fences"
```
Sample text here...
```
Syntax highlighting
``` js
var foo = function (bar) {
return bar++;
};
console.log(foo(5));
```
## Tables
| Option | Description |
| ------ | ----------- |
| data | path to data files to supply the data that will be passed into templates. |
| engine | engine to be used for processing templates. Handlebars is the default. |
| ext | extension to be used for dest files. |
Right aligned columns
| Option | Description |
| ------:| -----------:|
| data | path to data files to supply the data that will be passed into templates. |
| engine | engine to be used for processing templates. Handlebars is the default. |
| ext | extension to be used for dest files. |
## Links
[link text](http://dev.nodeca.com)
[link with title](http://nodeca.github.io/pica/demo/ "title text!")
Autoconverted link https://github.com/nodeca/pica (enable linkify to see)
## Images
![Minion](https://octodex.github.com/images/minion.png)
![Stormtroopocat](https://octodex.github.com/images/stormtroopocat.jpg "The Stormtroopocat")
Like links, Images also have a footnote style syntax
![Alt text][id]
With a reference later in the document defining the URL location:
[id]: https://octodex.github.com/images/dojocat.jpg "The Dojocat"

View File

@@ -0,0 +1,30 @@
import InterfaceMarkdown from './markdown.vue';
import { defineInterface } from '@/interfaces/define';
export default defineInterface(({ i18n }) => ({
id: 'markdown',
name: i18n.t('markdown'),
icon: 'text_fields',
component: InterfaceMarkdown,
types: ['text'],
options: [
{
field: 'placeholder',
name: i18n.t('placeholder'),
type: 'string',
meta: {
width: 'half',
interface: 'text-input',
},
},
{
field: 'tabbed',
name: i18n.t('tabbed'),
type: 'boolean',
meta: {
width: 'half',
interface: 'toggle',
},
},
],
}));

View File

@@ -0,0 +1,51 @@
import { withKnobs, boolean, text, optionsKnob } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';
import Vue from 'vue';
import InterfaceMarkdown from './markdown.vue';
import markdown from './readme.md';
import withPadding from '../../../.storybook/decorators/with-padding';
import { defineComponent, ref } from '@vue/composition-api';
import RawValue from '../../../.storybook/raw-value.vue';
import i18n from '@/lang';
Vue.component('interface-markdown', InterfaceMarkdown);
export default {
title: 'Interfaces / Markdown',
decorators: [withKnobs, withPadding],
parameters: {
notes: markdown,
},
};
export const basic = () =>
defineComponent({
components: { RawValue },
i18n,
props: {
disabled: {
default: boolean('Disabled', false, 'Options'),
},
placeholder: {
default: text('Placeholder', 'Enter a value...', 'Options'),
},
tabbed: {
default: boolean('Tabbed', false, 'Options'),
},
},
setup() {
const value = ref('');
const onInput = action('input');
return { onInput, value };
},
template: `
<div>
<interface-markdown
v-model="value"
v-bind="{ placeholder, tabbed, disabled }"
@input="onInput"
/>
<raw-value>{{ value }}</raw-value>
</div>
`,
});

View File

@@ -0,0 +1,24 @@
import VueCompositionAPI from '@vue/composition-api';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import InterfaceMarkdown from './markdown.vue';
import VTextarea from '@/components/v-textarea';
const localVue = createLocalVue();
localVue.use(VueCompositionAPI);
localVue.component('v-textarea', VTextarea);
describe('Interfaces / Markdown', () => {
it('Renders a v-markdown', () => {
const component = shallowMount(InterfaceMarkdown, {
localVue,
propsData: {
placeholder: 'Enter value...',
},
listeners: {
input: () => {},
},
});
expect(component.find(VTextarea).exists()).toBe(true);
});
});

View File

@@ -0,0 +1,345 @@
<template>
<div class="interface-markdown" :class="{ tabbed }">
<div v-if="tabbed" class="toolbar">
<v-tabs v-model="currentTab">
<v-tab>
<v-icon name="code" left />
{{ $t('edit') }}
</v-tab>
<v-tab>
<v-icon name="visibility" outline left />
{{ $t('preview') }}
</v-tab>
</v-tabs>
</div>
<v-textarea
v-show="showEdit"
:placeholder="placeholder"
:value="value"
:disabled="disabled"
@input="$listeners.input"
/>
<div v-show="showPreview" class="preview-container">
<div class="preview" v-html="html"></div>
</div>
</div>
</template>
<script lang="ts">
import marked from 'marked';
import { defineComponent, computed, ref } from '@vue/composition-api';
export default defineComponent({
props: {
value: {
type: String,
default: null,
},
disabled: {
type: Boolean,
default: false,
},
placeholder: {
type: String,
default: null,
},
tabbed: {
type: Boolean,
default: true,
},
},
setup(props) {
const currentTab = ref([0]);
const html = computed(() => (props.value ? marked(props.value) : ''));
const showEdit = computed(() => !props.tabbed || currentTab.value[0] === 0);
const showPreview = computed(() => !props.tabbed || currentTab.value[0] !== 0);
return { html, currentTab, showEdit, showPreview };
},
});
</script>
<style lang="scss" scoped>
.interface-markdown {
--v-textarea-min-height: var(--input-height-tall);
--v-textarea-max-height: 400px;
--v-tab-background-color: var(--background-subdued);
--v-tab-background-color-active: var(--background-subdued);
display: flex;
flex-wrap: wrap;
.toolbar {
width: 100%;
height: 42px;
border: var(--border-width) solid var(--border-normal);
border-radius: var(--border-radius) var(--border-radius) 0 0;
background-color: var(--background-subdued);
}
.v-textarea {
height: unset;
min-height: var(--input-height-tall);
border-radius: var(--border-radius) 0 0 var(--border-radius);
}
.preview-container {
min-height: var(--v-textarea-min-height);
max-height: var(--v-textarea-max-height);
padding: var(--input-padding);
overflow-y: auto;
border: var(--border-width) solid var(--border-normal);
border-radius: 0 var(--border-radius) var(--border-radius) 0;
}
.v-textarea,
.preview-container {
flex-basis: 100px;
flex-grow: 1;
}
&:not(.tabbed) .preview-container {
border-left: none;
}
&.tabbed .v-textarea {
border-top: none;
border-radius: 0 0 var(--border-radius) var(--border-radius);
}
&.tabbed .preview-container {
border-top: none;
border-radius: 0 0 var(--border-radius) var(--border-radius);
}
::v-deep {
.preview {
font-weight: 500;
font-size: 14px;
& > *:first-child {
margin-top: 0;
}
& > *:last-child {
margin-bottom: 0;
}
a {
text-decoration: underline;
}
h1,
h2,
h3,
h4,
h5,
h6 {
position: relative;
margin: 20px 0 10px;
padding: 0;
font-weight: 600;
cursor: text;
}
pre {
padding: 6px 10px;
overflow: auto;
font-size: 13px;
line-height: 19px;
background-color: var(--background-page);
border: 1px solid var(--background-normal);
border-radius: var(--border-radius);
}
code,
tt {
margin: 0 2px;
padding: 0 5px;
white-space: nowrap;
background-color: var(--background-page);
border: 1px solid var(--background-normal);
border-radius: var(--border-radius);
}
pre code {
margin: 0;
padding: 0;
white-space: pre;
background: transparent;
border: none;
}
pre code,
pre tt {
background-color: transparent;
border: none;
}
h1 tt,
h1 code {
font-size: inherit;
}
h2 tt,
h2 code {
font-size: inherit;
}
h3 tt,
h3 code {
font-size: inherit;
}
h4 tt,
h4 code {
font-size: inherit;
}
h5 tt,
h5 code {
font-size: inherit;
}
h6 tt,
h6 code {
font-size: inherit;
}
h1 {
font-size: 28px;
}
h2 {
font-size: 24px;
}
h3 {
font-size: 18px;
}
h4 {
font-size: 16px;
}
h5 {
font-size: 14px;
}
h6 {
color: var(--foreground-normal);
font-size: 14px;
}
p,
blockquote,
ul,
ol,
dl,
li,
table,
pre {
margin: 15px 0;
}
& > h2:first-child {
margin-top: 0;
padding-top: 0;
}
& > h1:first-child {
margin-top: 0;
padding-top: 0;
}
& > h3:first-child,
& > h4:first-child,
& > h5:first-child,
& > h6:first-child {
margin-top: 0;
padding-top: 0;
}
a:first-child h1,
a:first-child h2,
a:first-child h3,
a:first-child h4,
a:first-child h5,
a:first-child h6 {
margin-top: 0;
padding-top: 0;
}
h1 p,
h2 p,
h3 p,
h4 p,
h5 p,
h6 p {
margin-top: 0;
}
li p.first {
display: inline-block;
}
ul,
ol {
padding-left: 30px;
li {
margin: 0;
}
}
ul :first-child,
ol :first-child {
margin-top: 0;
}
ul :last-child,
ol :last-child {
margin-bottom: 0;
}
blockquote {
padding: 0 15px;
color: var(--foreground-normal);
border-left: 4px solid var(--background-normal);
}
blockquote > :first-child {
margin-top: 0;
}
blockquote > :last-child {
margin-bottom: 0;
}
table {
padding: 0;
border-collapse: collapse;
border-spacing: 0;
}
table tr {
margin: 0;
padding: 0;
background-color: white;
border-top: 1px solid var(--background-normal);
}
table tr:nth-child(2n) {
background-color: var(--background-page);
}
table tr th {
margin: 0;
padding: 6px 13px;
font-weight: bold;
text-align: left;
border: 1px solid var(--background-normal);
}
table tr td {
margin: 0;
padding: 6px 13px;
text-align: left;
border: 1px solid var(--background-normal);
}
table tr th :first-child,
table tr td :first-child {
margin-top: 0;
}
table tr th :last-child,
table tr td :last-child {
margin-bottom: 0;
}
img {
max-width: 100%;
}
.highlight pre {
padding: 6px 10px;
overflow: auto;
font-size: 13px;
line-height: 19px;
background-color: var(--background-page);
border: 1px solid var(--background-normal);
border-radius: var(--border-radius);
}
hr {
margin: 20px auto;
border: none;
border-top: 1px solid var(--background-normal);
}
b,
strong {
font-weight: 600;
}
}
}
}
</style>

View File

@@ -0,0 +1,9 @@
# Markdown
## Options
| Option | Description | Default |
| ------------- | ------------------------------------------------------------------- | ------------ |
| `placeholder` | Text to show when no input is entered | `null` |
| `tabbed` | If the view should be tabbed | `false` |
| `disabled` | Disables the input | `false` |

View File

@@ -7,5 +7,19 @@ export default defineInterface(({ i18n }) => ({
icon: 'arrow_right_alt',
component: InterfaceOneToMany,
types: ['alias'],
options: [],
relationship: 'o2m',
options: [
{
field: 'fields',
type: 'json',
name: i18n.tc('field', 0),
meta: {
interface: 'tags',
width: 'full',
options: {
placeholder: i18n.t('readable_fields_copy'),
},
},
},
],
}));

View File

@@ -67,7 +67,7 @@ import useCollection from '@/composables/use-collection';
import { useCollectionsStore, useRelationsStore, useFieldsStore } from '@/stores/';
import ModalDetail from '@/views/private/components/modal-detail';
import ModalBrowse from '@/views/private/components/modal-browse';
import { Filter } from '@/types';
import { Filter, Field } from '@/types';
import { Header } from '@/components/v-table/types';
export default defineComponent({
@@ -91,7 +91,7 @@ export default defineComponent({
},
fields: {
type: Array as PropType<string[]>,
required: true,
default: () => [],
},
disabled: {
type: Boolean,
@@ -194,7 +194,7 @@ export default defineComponent({
async function fetchCurrent() {
loading.value = true;
let fields = [...props.fields];
let fields = [...(props.fields.length > 0 ? props.fields : getDefaultFields())];
if (fields.includes(relatedPrimaryKeyField.value.field) === false) {
fields.push(relatedPrimaryKeyField.value.field);
@@ -306,7 +306,7 @@ export default defineComponent({
watch(
() => props.fields,
() => {
tableHeaders.value = props.fields
tableHeaders.value = (props.fields.length > 0 ? props.fields : getDefaultFields())
.map((fieldKey) => {
const field = fieldsStore.getField(relatedCollection.value.collection, fieldKey);
@@ -517,6 +517,11 @@ export default defineComponent({
},
]);
}
function getDefaultFields(): string[] {
const fields = fieldsStore.getFieldsForCollection(relatedCollection.value.collection);
return fields.slice(0, 3).map((field: Field) => field.field);
}
},
});
</script>

View File

@@ -1,10 +1,22 @@
import registerComponent from '@/utils/register-component/';
import interfaces from './index';
import { getInterfaces } from './index';
import { Component } from 'vue';
interfaces.forEach((inter) => {
const interfaces = getInterfaces();
const context = require.context('.', true, /^.*index\.ts$/);
const modules = context
.keys()
.map((key) => context(key))
.map((mod) => mod.default)
.filter((m) => m);
interfaces.value = modules;
interfaces.value.forEach((inter) => {
registerComponent('interface-' + inter.id, inter.component);
if (typeof inter.options === 'function') {
registerComponent(`interface-options-${inter.id}`, inter.options);
if (typeof inter.options !== 'function' && Array.isArray(inter.options) === false) {
registerComponent(`interface-options-${inter.id}`, inter.options as Component);
}
});

View File

@@ -1,5 +1,6 @@
import { defineInterface } from '../define';
import InterfaceRepeater from './repeater.vue';
import RepeaterOptions from './options.vue';
export default defineInterface(({ i18n }) => ({
id: 'repeater',
@@ -7,5 +8,5 @@ export default defineInterface(({ i18n }) => ({
icon: 'replay',
component: InterfaceRepeater,
types: ['json'],
options: [],
options: RepeaterOptions,
}));

View File

@@ -0,0 +1,143 @@
<template>
<div>
<p class="type-label">Template</p>
<v-input class="input" v-model="template" :placeholder="`{{ field }}`" />
<p class="type-label">Fields</p>
<repeater v-model="repeaterValue" :template="`{{ field }} — {{ interface }}`" :fields="repeaterFields" />
</div>
</template>
<script lang="ts">
import { defineComponent, PropType, computed } from '@vue/composition-api';
import Repeater from './repeater.vue';
import { Field, FieldMeta } from '@/types';
import i18n from '@/lang';
export default defineComponent({
components: { Repeater },
props: {
value: {
type: Object as PropType<any>,
default: null,
},
},
setup(props, { emit }) {
const repeaterValue = computed({
get() {
return props.value?.fields?.map((field: Field) => field.meta);
},
set(newVal: FieldMeta[]) {
const fields = newVal.map((meta) => ({
field: meta.field,
meta,
}));
emit('input', {
...(props.value || {}),
fields: fields,
});
},
});
const repeaterFields: DeepPartial<Field>[] = [
{
name: i18n.tc('field', 1),
field: 'field',
type: 'string',
meta: {
interface: 'text-input',
width: 'half',
sort: 1,
options: {
font: 'monospace',
},
},
schema: null,
},
{
name: i18n.t('field_width'),
field: 'width',
type: 'string',
meta: {
interface: 'dropdown',
width: 'half',
sort: 2,
options: {
choices: [
{
value: 'half',
text: i18n.t('half_width'),
},
{
value: 'full',
text: i18n.t('full_width'),
},
],
},
},
schema: null,
},
{
name: i18n.t('interface'),
field: 'interface',
type: 'string',
meta: {
interface: 'interface',
width: 'half',
sort: 3,
},
schema: null,
},
{
name: i18n.t('note'),
field: 'note',
type: 'string',
meta: {
interface: 'text-input',
width: 'half',
sort: 4,
},
schema: null,
},
{
name: i18n.t('options'),
field: 'options',
type: 'string',
meta: {
interface: 'interface-options',
width: 'full',
sort: 5,
options: {
interfaceField: 'interface',
},
},
},
];
const template = computed({
get() {
return props.value?.template;
},
set(newTemplate: string) {
emit('input', {
...(props.value || {}),
template: newTemplate,
});
},
});
return { repeaterValue, repeaterFields, template };
},
});
</script>
<style lang="scss" scoped>
.type-label {
margin-bottom: 4px;
}
.input {
margin-bottom: 24px;
}
</style>

View File

@@ -0,0 +1,12 @@
import InterfaceTFASetup from './tfa-setup.vue';
import { defineInterface } from '@/interfaces/define';
export default defineInterface({
id: 'tfa-setup',
name: 'tfa-setup',
icon: 'box',
component: InterfaceTFASetup,
types: ['text'],
options: [],
system: true,
});

View File

@@ -0,0 +1,148 @@
<template>
<div>
<v-checkbox block :input-value="!!tfaEnabled" @click.native="toggle">
{{ $t('enabled') }}
<div class="spacer" />
<template #append>
<v-icon name="launch" class="checkbox-icon" :class="{ enabled: tfaEnabled }" />
</template>
</v-checkbox>
<v-dialog persistent v-model="enableActive">
<v-card>
<v-progress-circular class="loader" indeterminate v-if="loading === true" />
<template v-show="loading === false">
<v-card-title>
{{ $t('tfa_scan_code') }}
</v-card-title>
<v-card-text>
<canvas class="qr" :id="canvasID" />
<output class="secret">{{ secret }}</output>
</v-card-text>
<v-card-actions>
<v-button @click="enableActive = false">{{ $t('done') }}</v-button>
</v-card-actions>
</template>
</v-card>
</v-dialog>
<v-dialog persistent v-model="disableActive">
<v-card>
<v-card-title>
{{ $t('enter_otp_to_disable_tfa') }}
</v-card-title>
<v-card-text>
<v-input type="text" :placeholder="$t('otp')" v-model="otp" />
</v-card-text>
<v-card-actions>
<v-button class="disable" :loading="loading" @click="disableTFA" :disabled="otp.length !== 6">
{{ $t('disable_tfa') }}
</v-button>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, watch } from '@vue/composition-api';
import api from '@/api';
import qrcode from 'qrcode';
import { nanoid } from 'nanoid';
export default defineComponent({
props: {
value: {
type: String,
default: null,
},
},
setup(props) {
const tfaEnabled = ref(!!props.value);
const enableActive = ref(false);
const disableActive = ref(false);
const loading = ref(false);
const canvasID = nanoid();
const secret = ref<string>();
const otp = ref('');
const error = ref<any>();
watch(
() => props.value,
() => {
tfaEnabled.value = !!props.value;
},
{ immediate: true }
);
return { tfaEnabled, toggle, enableActive, disableActive, loading, canvasID, secret, disableTFA, otp, error };
function toggle() {
if (tfaEnabled.value === false) {
enableActive.value = true;
enableTFA();
} else {
disableActive.value = true;
}
}
async function enableTFA() {
loading.value = true;
const response = await api.post('/users/me/tfa/enable');
const url = response.data.data.otpauth_url;
secret.value = response.data.data.secret;
await qrcode.toCanvas(document.getElementById(canvasID), url);
loading.value = false;
tfaEnabled.value = true;
}
async function disableTFA() {
loading.value = true;
try {
await api.post('/users/me/tfa/disable', { otp: otp.value });
tfaEnabled.value = false;
disableActive.value = false;
} catch (err) {
error.value = err;
} finally {
loading.value = false;
}
}
},
});
</script>
<style lang="scss" scoped>
.checkbox-icon {
--v-icon-color: var(--foreground-subdued);
&.enabled {
--v-icon-color: var(--primary);
}
}
.loader {
margin: 100px auto;
}
.qr {
display: block;
margin: 0 auto;
}
.secret {
display: block;
margin: 0 auto;
color: var(--foreground-subdued);
font-family: var(--family-monospace);
letter-spacing: 2.6px;
text-align: center;
}
.disable {
--v-button-background-color: var(--warning);
--v-button-background-color-hover: var(--warning-125);
}
</style>

View File

@@ -12,10 +12,9 @@ export type InterfaceConfig = {
relationship?: null | 'm2o' | 'o2m' | 'm2m';
hideLabel?: boolean;
hideLoader?: boolean;
system?: boolean;
};
export type InterfaceContext = { i18n: VueI18n };
export type InterfaceDefineParam = InterfaceDefineParamGeneric<InterfaceConfig>;
export type InterfaceDefineParamGeneric<T> = T | ((context: InterfaceContext) => T);

View File

@@ -1,5 +1,9 @@
/* stylelint-disable font-family-no-missing-generic-family-keyword */
.tox .tox-tbtn {
margin: 2px 0 4px 0;
}
.tox .tox-tbtn svg {
fill: var(--foreground-normal);
}
@@ -84,14 +88,14 @@
.tox .tox-toolbar,
.tox .tox-toolbar__primary,
.tox .tox-toolbar__overflow {
background: url("data:image/svg+xml;charset=utf8,%3Csvg height='39px' viewBox='0 0 40 39px' width='40' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='0' y='37px' width='100' height='2' fill='%23cfd8dc'/%3E%3C/svg%3E")
background: url("data:image/svg+xml;charset=utf8,%3Csvg height='40px' viewBox='0 0 40 40px' width='40' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='0' y='38px' width='100' height='2' fill='%23cfd8dc'/%3E%3C/svg%3E")
left 0 top 0 var(--background-subdued);
}
body.dark .tox .tox-toolbar,
body.dark .tox .tox-toolbar__primary,
body.dark .tox .tox-toolbar__overflow {
background: url("data:image/svg+xml;charset=utf8,%3Csvg height='39px' viewBox='0 0 40 39px' width='40' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='0' y='37px' width='100' height='2' fill='%23455a64'/%3E%3C/svg%3E")
background: url("data:image/svg+xml;charset=utf8,%3Csvg height='40px' viewBox='0 0 40 40px' width='40' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='0' y='38px' width='100' height='2' fill='%23455a64'/%3E%3C/svg%3E")
left 0 top 0 var(--background-subdued);
}
@@ -99,7 +103,7 @@ body.dark .tox .tox-toolbar__overflow {
body.auto .tox .tox-toolbar,
body.auto .tox .tox-toolbar__primary,
body.auto .tox .tox-toolbar__overflow {
background: url("data:image/svg+xml;charset=utf8,%3Csvg height='39px' viewBox='0 0 40 39px' width='40' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='0' y='37px' width='100' height='2' fill='%23455a64'/%3E%3C/svg%3E")
background: url("data:image/svg+xml;charset=utf8,%3Csvg height='40px' viewBox='0 0 40 40px' width='40' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='0' y='38px' width='100' height='2' fill='%23455a64'/%3E%3C/svg%3E")
left 0 top 0 var(--background-subdued);
}
}

View File

@@ -16,8 +16,37 @@
"rename_folder": "Rename Folder",
"public": "Public",
"public_description": "Controls what API data is available without authenticating.",
"not_allowed": "Not Allowed",
"move_to_trash": "Move to Trash",
"move_to_trash_confirm": "Are you sure you want to move this item to the trash?",
"move_to_trash_confirm_count": "No Items Selected | Are you sure you want to move this item to the trash? | Are you sure you want to move these {count} items to the trash?",
"nested_files_folders_will_be_moved": "Nested files and folders will be moved one level up.",
"markdown": "Markdown",
"tabbed": "Tabbed",
"all_access": "All Access",
"no_access": "No Access",
"use_custom": "Use Custom",
"edit_custom": "Edit Custom",
"item_permissions": "Item Permissions",
"field_permissions": "Field Permissions",
"field_validation": "Field Validation",
"field_presets": "Field Presets",
"permissions_for_role": "Items the {role} Role can {action}.",
"fields_for_role": "Fields the {role} Role can {action}.",
"validation_for_role": "Field {action} rules the {role} Role must obey.",
"presets_for_role": "Field value defaults for the {role} Role.",
"presentation_and_aliases": "Presentation & Aliases",
"revision_post_update": "Here is what this item looked like after the update...",
"changes_made": "These are the specific changes that were made...",
"no_relational_data": "Keep in mind that this does not include relational data.",
@@ -413,9 +442,15 @@
"select_an_item": "Select an item...",
"edit": "Edit",
"preview": "Preview",
"enabled": "Enabled",
"enable_tfa": "Enable 2FA",
"disable_tfa": "Disable 2FA",
"tfa_scan_code": "Scan the code in your authenticator app to finish setting up 2FA",
"enter_otp_to_disable_tfa": "Enter the OTP to disable 2FA",
"use_bold_style": "Use bold style",
"formatted_value": "Formatted Value",
@@ -423,22 +458,16 @@
"auto_format_casing": "Auto-format casing",
"errors": {
"3": "Only super admins have access to this",
"4": "Super Admin Token not provided",
"11": "Can't Reach Database",
"12": "Field has invalid regular expression",
"100": "Incorrect Email/Password",
"101": "Logged-out from Inactivity",
"102": "Logged-out from Inactivity",
"103": "User Suspended",
"105": "Reset link expired",
"106": "Incorrect Email/Password",
"107": "User Not Found",
"111": "Enter One-Time Password",
"112": "Wrong One-Time Password",
"114": "Incorrect Email/Password",
"115": "SSO is not allowed when 2FA is enabled",
"503": "Email couldn't be sent. Please verify the API's configuration",
"COLLECTION_NOT_FOUND": "Collection doesn't exist.",
"FIELD_NOT_FOUND": "Field not found.",
"NO_PERMISSION": "Forbidden.",
"INVALID_CREDENTIALS": "Wrong username or password.",
"INVALID_OTP": "Wrong one-time password.",
"INVALID_PAYLOAD": "Invalid payload.",
"INVALID_QUERY": "Invalid query.",
"ITEM_LIMIT_REACHED": "Item limit reached.",
"ITEM_NOT_FOUND": "Item not found.",
"ROUTE_NOT_FOUND": "Not found.",
"-1": "Couldn't Reach API"
},
@@ -455,8 +484,8 @@
"presets": "Presets",
"unexpected_error": "An unexpected error occured",
"unexpected_error_copy": "Something went wrong.. Please try again later.",
"unexpected_error": "Unexpected Error",
"unexpected_error_copy": "An unexpected error has occured. Please try again later.",
"copy_details": "Copy Details",
"no_app_access": "No App Access",
@@ -760,19 +789,19 @@
"keep_editing": "Keep Editing",
"page_help_collections_overview": "**Collections Overview** — Lists of all collections you have access to.",
"page_help_collections_browse": "**Browse Items** — Lists all {collection} items you have access to. Customize layout, filters, and sorting to tailor your view, and even save bookmarks of these different configurations for quick access.<br><br><a href='https://docs.directus.io/guides/user-guide.html#items' target='_blank'>Learn More</a>",
"page_help_collections_browse": "**Browse Items** — Lists all {collection} items you have access to. Customize layout, filters, and sorting to tailor your view, and even save bookmarks of these different configurations for quick access.",
"page_help_collections_detail": "**Item Detail** — A form for viewing and managing this item. This sidebar also contains a full history of revisions, and embedded comments.",
"page_help_activity_browse": "**Browse Activity** — A comprehensive listing of all your user's system and content activity.",
"page_help_activity_detail": "**Activity Detail** — Shows accountability info, revision data, and the update message for this activity record.",
"page_help_files_browse": "**File Library** — Lists all file assets uploaded to this project. Customize layout, filters, and sorting to tailor your view, and even save bookmarks of these different configurations for quick access.",
"page_help_files_detail": "**File Detail** — A form for managing file metadata, editing the original asset, and updating access settings.",
"page_help_settings_project": "**Project Settings** — Your project's global configuration options.<br><br><a href='https://docs.directus.io/guides/admin-guide.html#global-settings' target='_blank'>Learn More</a>",
"page_help_settings_project": "**Project Settings** — Your project's global configuration options.",
"page_help_settings_datamodel_collections": "**Data Model: Collections** — Lists all collections available. This includes visible, hidden, and system collections, as well as unmanaged database tables that can be added.",
"page_help_settings_datamodel_fields": "**Data Model: Collection** — A form for managing this collection and its fields.",
"page_help_settings_roles_browse": "**Browse Roles** — Lists the Admin, Public and custom User Roles.",
"page_help_settings_roles_detail": "**Role Detail** — Manage a role's permissions and other settings.",
"page_help_settings_presets_browse": "**Browse Presets** — Lists all presets in the project, including: user, role, and global bookmarks, as well as default views.",
"page_help_settings_presets_detail": "**Preset Detail** — A form for managing bookmarks and default collection presets.<br><br>To create a default preset, choose a role... TK TK",
"page_help_settings_presets_detail": "**Preset Detail** — A form for managing bookmarks and default collection presets.",
"page_help_settings_webhooks_browse": "**Browse Webhooks** — Lists all webhooks within the project.",
"page_help_settings_webhooks_detail": "**Webhook Detail** — A form for creating and managing project webhooks.",
"page_help_users_browse": "**User Directory** — Lists all system users within this project.",
@@ -912,7 +941,7 @@
"connection": "Connection",
"contains": "Contains",
"continue": "Continue",
"continue_as": "<b>{name}</b> is already authenticated for this project. If you recognize this account, please press continue.",
"continue_as": "<b>{name}</b> is already authenticated. If you recognize this account, press continue.",
"creating_item": "Creating Item",
"creating_item_page_title": "Creating Item: {collection}",
"creating_role": "Creating Role",
@@ -964,6 +993,12 @@
"dialog_beginning": "Beginning of dialog window.",
"display_name": "Display Name",
"directus_version": "Directus Version",
"server_stack": "Server Stack",
"operating_system": "Operating System",
"installed_on": "Installed On",
"database_client": "Database Client",
"database_host": "Database Host",
"database_port": "Database Port",
"done": "Done",
"dont_manage": "Don't Manage",
"dont_manage_copy": "Privileges, preferences, and settings for this collection will be permanently removed from the system! Are you sure?",
@@ -1102,6 +1137,7 @@
"no_items_selected": "No items selected",
"no_related_entries": "Has no related entries",
"not_authenticated": "Not Authenticated",
"authenticated": "Authenticated",
"not_between": "Not between",
"not_contains": "Doesn't contain",
"not_empty": "Is not empty",

View File

@@ -100,13 +100,13 @@ export async function setLanguage(lang: Language): Promise<boolean> {
export default i18n;
export function translateAPIError(error: RequestError | number) {
export function translateAPIError(error: RequestError | string) {
const defaultMsg = i18n.t('unexpected_error');
let code = error;
if (typeof error === 'object') {
code = error?.response?.data?.error?.code;
code = error?.response?.data?.errors?.[0]?.extensions?.code;
}
if (!error) return defaultMsg;

View File

@@ -334,8 +334,8 @@ export default defineComponent({
const limit = createViewQueryOption<number>('limit', 25);
const fields = computed<string[]>(() => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const fields = [primaryKeyField.value!.field];
if (!primaryKeyField.value) return [];
const fields = [primaryKeyField.value.field];
if (imageSource.value) {
fields.push(`${imageSource.value}.type`);
@@ -386,6 +386,7 @@ export default defineComponent({
}
function getLinkForItem(item: Record<string, any>) {
if (!primaryKeyField.value) return;
return `/collections/${props.collection}/${item[primaryKeyField.value!.field]}`;
}
@@ -401,6 +402,8 @@ export default defineComponent({
</script>
<style lang="scss" scoped>
@import '@/styles/mixins/breakpoint';
.layout-cards {
padding: var(--content-padding);
padding-top: 0;
@@ -446,9 +449,14 @@ export default defineComponent({
.item-count {
position: relative;
display: none;
margin: 0 8px;
color: var(--foreground-subdued);
white-space: nowrap;
@include breakpoint(small) {
display: inline;
}
}
.fade-enter-active,

View File

@@ -2,11 +2,11 @@
<div class="cards-header">
<div class="start">
<div class="selected" v-if="_selection.length > 0" @click="_selection = []">
<v-icon name="cancel" />
<v-icon name="cancel" outline />
<span class="label">{{ $tc('n_items_selected', _selection.length) }}</span>
</div>
<button class="select-all" v-else @click="$emit('select-all')">
<v-icon name="check_circle" />
<v-icon name="check_circle" outline />
<span class="label">{{ $t('select_all') }}</span>
</button>
</div>

View File

@@ -78,7 +78,7 @@
:server-sort="itemCount === limit || totalPages > 1"
:item-key="primaryKeyField.field"
:show-manual-sort="_filters && _filters.length === 0 && sortField !== null"
:manual-sort-key="sortField && sortField.field"
:manual-sort-key="sortField"
selection-use-keys
@click:row="onRowClick"
@update:sort="onSortChange"
@@ -227,7 +227,7 @@ export default defineComponent({
page,
fields: fieldsWithRelational,
filters: _filters,
searchQuery,
searchQuery: _searchQuery,
});
const {
@@ -304,8 +304,7 @@ export default defineComponent({
const sort = computed({
get() {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return _viewQuery.value?.sort || primaryKeyField.value!.field;
return _viewQuery.value?.sort || primaryKeyField.value?.field;
},
set(newSort: string) {
page.value = 1;
@@ -337,13 +336,15 @@ export default defineComponent({
if (typeof _viewQuery.value.fields === 'string') {
return (_viewQuery.value.fields as string).split(',');
}
if (Array.isArray(_viewQuery.value.fields)) return _viewQuery.value.fields;
}
const fields =
_viewQuery.value?.fields ||
availableFields.value
.filter((field: Field) => {
return field.schema?.is_primary_key === false && field.meta.special !== 'sort';
return field.schema?.is_primary_key === false;
})
.slice(0, 4)
.map(({ field }) => field);
@@ -365,7 +366,7 @@ export default defineComponent({
function useTable() {
const tableSort = computed(() => {
if (sort.value.startsWith('-')) {
if (sort.value?.startsWith('-')) {
return { by: sort.value.substring(1), desc: true };
} else {
return { by: sort.value, desc: false };
@@ -499,6 +500,8 @@ export default defineComponent({
</script>
<style lang="scss" scoped>
@import '@/styles/mixins/breakpoint';
.layout-tabular {
display: contents;
margin: var(--content-padding);
@@ -574,6 +577,11 @@ export default defineComponent({
margin: 0 8px;
color: var(--foreground-subdued);
white-space: nowrap;
display: none;
@include breakpoint(small) {
display: inline;
}
}
.fade-enter-active,

View File

@@ -64,7 +64,6 @@
{{ $t('login') }}
</v-list-item-content>
</v-list-item>
</v-list>
</template>

View File

@@ -1,4 +0,0 @@
import ActivityNavigation from './navigation.vue';
export { ActivityNavigation };
export default ActivityNavigation;

View File

@@ -1,6 +1,6 @@
import { defineModule } from '@/modules/define';
import ActivityBrowse from './routes/browse/';
import ActivityDetail from './routes/detail/';
import ActivityBrowse from './routes/browse.vue';
import ActivityDetail from './routes/detail.vue';
export default defineModule(({ i18n }) => ({
id: 'activity',

View File

@@ -34,20 +34,17 @@
<template #drawer>
<drawer-detail icon="info_outline" :title="$t('information')" close>
<div class="format-markdown" v-html="marked($t('page_help_activity_browse'))" />
<div class="page-description" v-html="marked($t('page_help_activity_browse'))" />
</drawer-detail>
<layout-drawer-detail @input="viewType = $event" :value="viewType" />
<portal-target name="drawer" />
<drawer-detail icon="help_outline" :title="$t('help_and_docs')">
<div class="format-markdown" v-html="marked($t('page_help_activity_browse'))" />
</drawer-detail>
</template>
</private-view>
</template>
<script lang="ts">
import { defineComponent, computed, ref } from '@vue/composition-api';
import ActivityNavigation from '../../components/navigation/';
import ActivityNavigation from '../components/navigation.vue';
import { i18n } from '@/lang';
import { LayoutComponent } from '@/layouts/types';
import usePreset from '@/composables/use-collection-preset';

View File

@@ -1,4 +0,0 @@
import ActivityBrowse from './browse.vue';
export { ActivityBrowse };
export default ActivityBrowse;

View File

@@ -1,4 +0,0 @@
import ActivityDetail from './detail.vue';
export { ActivityDetail };
export default ActivityDetail;

View File

@@ -24,13 +24,24 @@
<v-list-item-content>{{ bookmark.title }}</v-list-item-content>
</v-list-item>
</template>
<div v-if="!customNavItems && !navItems.length && !bookmarks.length" class="empty">
<template v-if="isAdmin">
<v-button fullWidth outlined dashed to="/settings/data-model/+">{{ $t('create_collection') }}</v-button>
</template>
<template v-else>
{{ $t('no_collections_copy') }}
</template>
</div>
</v-list>
</template>
<script lang="ts">
import { defineComponent, computed } from '@vue/composition-api';
import useNavigation from '../../composables/use-navigation';
import useNavigation from '../composables/use-navigation';
import { usePresetsStore } from '@/stores/';
import { useUserStore } from '@/stores';
export default defineComponent({
props: {
@@ -42,6 +53,8 @@ export default defineComponent({
setup() {
const presetsStore = usePresetsStore();
const { customNavItems, navItems } = useNavigation();
const userStore = useUserStore();
const isAdmin = computed(() => userStore.state.currentUser?.role.admin === true);
const bookmarks = computed(() => {
return presetsStore.state.collectionPresets
@@ -56,7 +69,7 @@ export default defineComponent({
});
});
return { navItems, bookmarks, customNavItems };
return { navItems, bookmarks, customNavItems, isAdmin };
},
});
</script>
@@ -66,4 +79,12 @@ export default defineComponent({
padding-left: 8px;
font-weight: 600;
}
.empty {
color: var(--foreground-subdued);
.v-button {
--v-button-background-color: var(--foreground-subdued);
--v-button-background-color-hover: var(--primary);
}
}
</style>

View File

@@ -1,4 +0,0 @@
import CollectionsNavigation from './navigation.vue';
export { CollectionsNavigation };
export default CollectionsNavigation;

View File

@@ -37,7 +37,7 @@ export default function useNavigation() {
const navItem: NavItem = {
collection: collection,
name: collectionInfo.name,
icon: collectionInfo.meta?.icon || 'box',
icon: collectionInfo.meta?.icon || 'label',
to: `/collections/${collection}`,
};
@@ -56,7 +56,7 @@ export default function useNavigation() {
const navItem: NavItem = {
collection: collection.collection,
name: collection.name,
icon: collection.meta?.icon || 'box',
icon: collection.meta?.icon || 'label',
to: `/collections/${collection.collection}`,
};

View File

@@ -1,8 +1,8 @@
import { defineModule } from '@/modules/define';
import CollectionsOverview from './routes/overview/';
import CollectionsBrowseOrDetail from './routes/browse-or-detail/';
import CollectionsDetail from './routes/detail/';
import CollectionsItemNotFound from './routes/not-found';
import CollectionsOverview from './routes/overview.vue';
import CollectionsBrowseOrDetail from './routes/browse-or-detail.vue';
import CollectionsDetail from './routes/detail.vue';
import CollectionsItemNotFound from './routes/not-found.vue';
import { NavigationGuard } from 'vue-router';
const checkForSystem: NavigationGuard = (to, from, next) => {
@@ -69,4 +69,5 @@ export default defineModule(({ i18n }) => ({
beforeEnter: checkForSystem,
},
],
order: 5,
}));

View File

@@ -5,8 +5,8 @@
<script lang="ts">
import { defineComponent, ref, computed } from '@vue/composition-api';
import Vue from 'vue';
import CollectionsBrowse from '../browse';
import CollectionsDetail from '../detail';
import CollectionsBrowse from './browse.vue';
import CollectionsDetail from './detail.vue';
import { useCollectionsStore } from '@/stores/';
export default defineComponent({

View File

@@ -1,4 +0,0 @@
import CollectionsBrowseOrDetail from './browse-or-detail.vue';
export { CollectionsBrowseOrDetail };
export default CollectionsBrowseOrDetail;

View File

@@ -1,3 +0,0 @@
# Browse or Detail
Renders either the browse page or the detail page depending on whether or not the collection is a singleton

View File

@@ -48,8 +48,15 @@
<v-dialog v-model="confirmDelete" v-if="selection.length > 0">
<template #activator="{ on }">
<v-button rounded icon class="action-delete" @click="on" v-tooltip.bottom="$t('delete')">
<v-icon name="delete" />
<v-button
:disabled="batchDeleteAllowed !== true"
rounded
icon
class="action-delete"
@click="on"
v-tooltip.bottom="batchDeleteAllowed ? $t('delete') : $t('not_allowed')"
>
<v-icon name="delete_forever" outline />
</v-button>
</template>
@@ -67,18 +74,56 @@
</v-card>
</v-dialog>
<v-dialog
v-model="confirmSoftDelete"
v-if="selection.length > 0 && currentCollection.meta && currentCollection.meta.soft_delete_field"
>
<template #activator="{ on }">
<v-button
:disabled="batchSoftDeleteAllowed !== true"
rounded
icon
class="action-soft-delete"
@click="on"
v-tooltip.bottom="batchEditAllowed ? $t('move_to_trash') : $t('not_allowed')"
>
<v-icon name="delete" outline />
</v-button>
</template>
<v-card>
<v-card-title>{{ $tc('move_to_trash_confirm_count', selection.length) }}</v-card-title>
<v-card-actions>
<v-button @click="confirmSoftDelete = false" secondary>
{{ $t('cancel') }}
</v-button>
<v-button @click="batchDelete" class="action-soft-delete" :loading="softDeleting">
{{ $t('move_to_trash') }}
</v-button>
</v-card-actions>
</v-card>
</v-dialog>
<v-button
rounded
icon
class="action-batch"
v-if="selection.length > 1"
:to="batchLink"
v-tooltip.bottom="$t('edit')"
v-tooltip.bottom="batchEditAllowed ? $t('edit') : $t('not_allowed')"
:disabled="batchEditAllowed === false"
>
<v-icon name="edit" />
<v-icon name="edit" outline />
</v-button>
<v-button rounded icon :to="addNewLink" v-tooltip.bottom="$t('create_item')">
<v-button
rounded
icon
:to="addNewLink"
v-tooltip.bottom="createAllowed ? $t('create_item') : $t('not_allowed')"
:disabled="createAllowed === false"
>
<v-icon name="add" />
</v-button>
</template>
@@ -138,13 +183,8 @@
<template #drawer>
<drawer-detail icon="info_outline" :title="$t('information')" close>
Page Info Here...
</drawer-detail>
<layout-drawer-detail @input="viewType = $event" :value="viewType" />
<portal-target name="drawer" />
<drawer-detail icon="help_outline" :title="$t('help_and_docs')">
<div
class="format-markdown"
class="page-description"
v-html="
marked(
$t('page_help_collections_browse', {
@@ -154,16 +194,30 @@
"
/>
</drawer-detail>
<layout-drawer-detail @input="viewType = $event" :value="viewType" />
<portal-target name="drawer" />
</template>
<v-dialog v-if="deleteError" active>
<v-card>
<v-card-title>{{ $t('something_went_wrong') }}</v-card-title>
<v-card-text>
<v-error :error="deleteError" />
</v-card-text>
<v-card-actions>
<v-button @click="deleteError = null">{{ $t('done') }}</v-button>
</v-card-actions>
</v-card>
</v-dialog>
</private-view>
</template>
<script lang="ts">
import { defineComponent, computed, ref, watch, toRefs } from '@vue/composition-api';
import CollectionsNavigation from '../../components/navigation/';
import CollectionsNavigation from '../components/navigation.vue';
import api from '@/api';
import { LayoutComponent } from '@/layouts/types';
import CollectionsNotFound from '../not-found/';
import CollectionsNotFound from './not-found.vue';
import useCollection from '@/composables/use-collection';
import usePreset from '@/composables/use-collection-preset';
import LayoutDrawerDetail from '@/views/private/components/layout-drawer-detail';
@@ -172,6 +226,7 @@ import BookmarkAdd from '@/views/private/components/bookmark-add';
import BookmarkEdit from '@/views/private/components/bookmark-edit';
import router from '@/router';
import marked from 'marked';
import { usePermissionsStore, useUserStore } from '@/stores';
type Item = {
[field: string]: any;
@@ -198,6 +253,8 @@ export default defineComponent({
},
},
setup(props) {
const userStore = useUserStore();
const permissionsStore = usePermissionsStore();
const layout = ref<LayoutComponent | null>(null);
const { collection } = toRefs(props);
@@ -218,7 +275,15 @@ export default defineComponent({
saveCurrentAsBookmark,
title: bookmarkName,
} = usePreset(collection, bookmarkID);
const { confirmDelete, deleting, batchDelete } = useBatchDelete();
const {
confirmDelete,
deleting,
batchDelete,
confirmSoftDelete,
softDelete,
softDeleting,
error: deleteError,
} = useBatchDelete();
const {
bookmarkDialogActive,
@@ -238,6 +303,8 @@ export default defineComponent({
{ immediate: true }
);
const { batchEditAllowed, batchSoftDeleteAllowed, batchDeleteAllowed, createAllowed } = usePermissions();
return {
addNewLink,
batchDelete,
@@ -265,6 +332,14 @@ export default defineComponent({
breadcrumb,
marked,
clearFilters,
confirmSoftDelete,
softDelete,
softDeleting,
batchEditAllowed,
batchSoftDeleteAllowed,
batchDeleteAllowed,
deleteError,
createAllowed,
};
function useBreadcrumb() {
@@ -294,28 +369,50 @@ export default defineComponent({
const confirmDelete = ref(false);
const deleting = ref(false);
return { confirmDelete, deleting, batchDelete };
const confirmSoftDelete = ref(false);
const softDeleting = ref(false);
const error = ref<any>();
return { confirmDelete, deleting, batchDelete, confirmSoftDelete, softDeleting, softDelete, error };
async function batchDelete() {
deleting.value = true;
confirmDelete.value = false;
const batchPrimaryKeys = selection.value;
try {
await api.delete(`/items/${props.collection}/${batchPrimaryKeys}`);
await layout.value?.refresh?.();
selection.value = [];
confirmDelete.value = false;
} catch (err) {
console.error(err);
error.value = err;
} finally {
deleting.value = false;
}
}
async function softDelete() {
if (!currentCollection.value?.meta?.soft_delete_field) return;
softDeleting.value = true;
const batchPrimaryKeys = selection.value;
try {
await api.patch(`/items/${props.collection}/${batchPrimaryKeys}`);
await layout.value?.refresh?.();
selection.value = [];
confirmSoftDelete.value = false;
} catch (err) {
error.value = err;
} finally {
softDeleting.value = false;
}
}
}
function useLinks() {
@@ -377,6 +474,54 @@ export default defineComponent({
filters.value = [];
searchQuery.value = null;
}
function usePermissions() {
const batchEditAllowed = computed(() => {
const admin = userStore.state?.currentUser?.role.admin === true;
if (admin) return true;
const updatePermissions = permissionsStore.state.permissions.find(
(permission) => permission.action === 'update' && permission.collection === collection.value
);
return !!updatePermissions;
});
const batchSoftDeleteAllowed = computed(() => {
if (!currentCollection.value?.meta?.soft_delete_field) return false;
const admin = userStore.state?.currentUser?.role.admin === true;
if (admin) return true;
const updatePermissions = permissionsStore.state.permissions.find(
(permission) => permission.action === 'update' && permission.collection === collection.value
);
if (!updatePermissions) return false;
if (!updatePermissions.fields) return false;
if (updatePermissions.fields === '*') return true;
return updatePermissions.fields.split(',').includes(currentCollection.value.meta.soft_delete_field);
});
const batchDeleteAllowed = computed(() => {
const admin = userStore.state?.currentUser?.role.admin === true;
if (admin) return true;
const deletePermissions = permissionsStore.state.permissions.find(
(permission) => permission.action === 'delete' && permission.collection === collection.value
);
return !!deletePermissions;
});
const createAllowed = computed(() => {
const admin = userStore.state?.currentUser?.role.admin === true;
if (admin) return true;
const createPermissions = permissionsStore.state.permissions.find(
(permission) => permission.action === 'create' && permission.collection === collection.value
);
return !!createPermissions;
});
return { batchEditAllowed, batchSoftDeleteAllowed, batchDeleteAllowed, createAllowed };
}
},
});
</script>
@@ -389,6 +534,13 @@ export default defineComponent({
--v-button-color-hover: var(--danger);
}
.action-soft-delete {
--v-button-background-color: var(--warning-25);
--v-button-color: var(--warning);
--v-button-background-color-hover: var(--warning-50);
--v-button-color-hover: var(--warning);
}
.action-batch {
--v-button-background-color: var(--warning-25);
--v-button-color: var(--warning);

View File

@@ -1,4 +0,0 @@
import CollectionsBrowse from './browse.vue';
export { CollectionsBrowse };
export default CollectionsBrowse;

Some files were not shown because too many files have changed in this diff Show More