Merge branch 'main' into feature-redis-cache

This commit is contained in:
kukulaka
2020-09-02 23:09:17 +01:00
261 changed files with 4794 additions and 34985 deletions

8
.gitignore vendored
View File

@@ -4,3 +4,11 @@ node_modules
.env
npm-debug.log
lerna-debug.log
# This is always a point of debate, but:
# * package-lock is auto-generate by lerna, which generates it differently from npm
# * We actually _want_ people to be on the latests semver versions for local development
# * package-locks are ignored when publishing to NPM _anyway_, so it doesn't matter for releases
# * the app is bundled on release, so its package versions are locked by definition
package-lock.json

8496
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.27",
"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.",
@@ -68,7 +68,7 @@
"example.env"
],
"dependencies": {
"@directus/app": "^9.0.0-alpha.20",
"@directus/app": "^9.0.0-alpha.27",
"@directus/format-title": "^3.2.0",
"@slynova/flydrive": "^1.0.2",
"@slynova/flydrive-gcs": "^1.0.2",
@@ -101,6 +101,7 @@
"knex-schema-inspector": "0.0.9",
"liquidjs": "^9.14.1",
"lodash": "^4.17.19",
"macos-release": "^2.4.1",
"ms": "^2.1.2",
"nanoid": "^3.1.12",
"node-cache": "^5.1.2",
@@ -167,5 +168,5 @@
"prettier --write"
]
},
"gitHead": "9092740d708c1e9ed1575b0932a01f531d224f77"
"gitHead": "c22537b6d0dd7dc6cc651a9200a68f6b060353d4"
}

View File

@@ -10,6 +10,7 @@ import { InvalidPayloadException } from '../exceptions/invalid-payload';
import ms from 'ms';
import cookieParser from 'cookie-parser';
import env from '../env';
import UsersService from '../services/users';
const router = Router();
@@ -17,7 +18,7 @@ const loginSchema = Joi.object({
email: Joi.string().email().required(),
password: Joi.string().required(),
mode: Joi.string().valid('cookie', 'json'),
otp: Joi.string()
otp: Joi.string(),
});
router.post(
@@ -153,6 +154,55 @@ router.post(
})
);
router.post(
'/password/request',
asyncHandler(async (req, res) => {
if (!req.body.email) {
throw new InvalidPayloadException(`"email" field is required.`);
}
const accountability = {
ip: req.ip,
userAgent: req.get('user-agent'),
role: null,
};
const service = new UsersService({ accountability });
try {
await service.requestPasswordReset(req.body.email);
} catch {
// We don't want to give away what email addresses exist, so we'll always return a 200
// from this endpoint
} finally {
return res.status(200).end();
}
})
);
router.post(
'/password/reset',
asyncHandler(async (req, res) => {
if (!req.body.token) {
throw new InvalidPayloadException(`"token" field is required.`);
}
if (!req.body.password) {
throw new InvalidPayloadException(`"password" field is required.`);
}
const accountability = {
ip: req.ip,
userAgent: req.get('user-agent'),
role: null,
};
const service = new UsersService({ accountability });
await service.resetPassword(req.body.token, req.body.password);
return res.status(200).end();
})
);
router.use(
'/sso',
session({ secret: env.SECRET as string, saveUninitialized: false, resave: false })

View File

@@ -1,13 +1,12 @@
import { Router } from 'express';
import asyncHandler from 'express-async-handler';
import redis from 'redis';
import FieldsService from '../services/fields';
import validateCollection from '../middleware/collection-exists';
import checkCacheMiddleware from '../middleware/check-cache';
import setCacheMiddleware from '../middleware/set-cache';
import delCacheMiddleware from '../middleware/delete-cache';
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';
@@ -59,7 +58,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);
@@ -73,11 +72,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(),
});
@@ -120,7 +119,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);
@@ -139,12 +138,11 @@ router.patch(
// @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);
@@ -159,8 +157,7 @@ router.delete(
delCacheMiddleware,
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

@@ -1,8 +1,14 @@
import { Router } from 'express';
import ServerService from '../services/server';
const router = Router();
router.get('/ping', (req, res) => res.send('pong'));
router.get('/info', (req, res) => res.json({ data: process.versions }));
router.get('/info', (req, res) => {
const service = new ServerService({ accountability: req.accountability });
const data = service.serverInfo();
res.json({ data });
});
export default router;

View File

@@ -161,9 +161,9 @@ router.post(
}
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 } });
})
);

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

@@ -44,6 +44,11 @@ export default async function runAST(ast: AST, query = ast.query) {
// Query defaults
query.limit = query.limit || 100;
if (query.limit === -1) {
delete query.limit;
}
query.sort = query.sort || [{ column: primaryKeyField, order: 'asc' }];
await applyQuery(ast.name, dbQuery, query);
@@ -116,7 +121,7 @@ export default async function runAST(ast: AST, query = ast.query) {
*/
if (batchQuery.limit) {
tempLimit = batchQuery.limit;
delete batchQuery.limit;
batchQuery.limit = -1;
}
}

View File

@@ -4,6 +4,14 @@ tables:
collection:
type: string
primary: true
icon:
type: string
length: 30
note:
type: text
display_template:
type: string
length: 255
hidden:
type: boolean
nullable: false
@@ -12,14 +20,19 @@ tables:
type: boolean
nullable: false
default: false
icon:
type: string
length: 30
note:
type: text
translation:
type: json
display_template:
archive_field:
type: string
length: 64
archive_app_filter:
type: boolean
nullable: false
default: true
archive_value:
type: string
length: 255
unarchive_value:
type: string
length: 255
sort_field:
@@ -310,12 +323,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:
@@ -551,58 +566,74 @@ rows:
note: null
data:
- collection: directus_collections
field: collection
interface: text-input
field: collection_divider
special: alias
interface: divider
options:
icon: box
title: Collection Setup
color: '#2F80ED'
locked: true
sort: 1
width: full
- collection: directus_collections
field: collection
special:
interface: text-input
options:
font: monospace
readonly: true
sort: 1
width: half
- collection: directus_collections
field: icon
interface: icon
locked: true
readonly: true
sort: 2
width: half
- collection: directus_collections
field: note
interface: text-input
field: icon
special:
interface: icon
options:
locked: true
sort: 3
width: half
- collection: directus_collections
field: note
special:
interface: text-input
options:
placeholder: A description of this collection...
sort: 3
width: full
- collection: directus_collections
field: display_template
interface: text-input
locked: true
options:
placeholder: 'Reference title for items, eg: {{ first_name }} {{ last_name }}'
sort: 4
width: full
- collection: directus_collections
field: hidden
interface: toggle
field: display_template
special:
interface: display-template
options:
collectionField: collection
locked: true
sort: 5
width: full
- collection: directus_collections
field: hidden
special: boolean
interface: toggle
options:
label: Hide within the App
sort: 5
special: boolean
locked: true
sort: 6
width: half
- collection: directus_collections
field: singleton
special: boolean
interface: toggle
locked: true
options:
label: Treat as single object
sort: 6
special: boolean
locked: true
sort: 7
width: half
- collection: directus_collections
field: translation
special: json
interface: repeater
locked: true
options:
template: '{{ locale }}'
fields:
@@ -618,9 +649,88 @@ rows:
system:
interface: text-input
width: half
special: json
sort: 7
locked: true
sort: 8
width: full
- collection: directus_collections
field: archive_divider
special: alias
interface: divider
options:
icon: archive
title: Archive
color: '#2F80ED'
locked: true
sort: 9
width: full
- collection: directus_collections
field: archive_field
special:
interface: field
options:
collectionField: collection
allowNone: true
placeholder: Choose a field...
locked: true
sort: 10
width: half
- collection: directus_collections
field: archive_app_filter
special:
interface: toggle
options:
label: Enable App Archive Filter
locked: true
sort: 11
width: half
- collection: directus_collections
field: archive_value
special:
interface: text-input
options:
font: monospace
iconRight: archive
placeholder: Value set when archiving...
locked: true
sort: 12
width: half
- collection: directus_collections
field: unarchive_value
special:
interface: text-input
options:
font: monospace
iconRight: unarchive
placeholder: Value set when unarchiving...
locked: true
sort: 13
width: half
- collection: directus_collections
field: sort_divider
special: alias
interface: divider
options:
icon: sort
title: Sort
color: '#2F80ED'
locked: true
sort: 14
width: full
- collection: directus_collections
field: sort_field
special: ''
interface: field
options:
collectionField: collection
placeholder: Choose a field...
typeAllowList:
- float
- decimal
- integer
allowNone: true
locked: true
sort: 15
width: half
- collection: directus_roles
field: id
@@ -804,7 +914,7 @@ rows:
width: half
- collection: directus_users
field: password
special: hash
special: hash, conceal
interface: hash
locked: true
options:
@@ -1239,7 +1349,9 @@ rows:
width: half
- collection: directus_users
field: tfa_secret
interface: tfa-setup
locked: true
special: conceal
sort: 14
width: half
- collection: directus_users
@@ -1408,7 +1520,7 @@ rows:
options:
icon: insert_drive_file
title: File Naming
color: '#B0BEC5'
color: '#2F80ED'
special: alias
sort: 6
width: full
@@ -1537,7 +1649,7 @@ rows:
options:
icon: public
title: Public Pages
color: '#B0BEC5'
color: '#2F80ED'
special: alias
sort: 5
width: full
@@ -1574,7 +1686,7 @@ rows:
options:
icon: security
title: Security
color: '#B0BEC5'
color: '#2F80ED'
special: alias
sort: 9
width: full
@@ -1615,7 +1727,7 @@ rows:
options:
icon: storage
title: Files & Thumbnails
color: '#B0BEC5'
color: '#2F80ED'
special: alias
sort: 13
width: full
@@ -1699,7 +1811,7 @@ rows:
options:
icon: pending
title: Miscellaneous
color: '#B0BEC5'
color: '#2F80ED'
special: alias
sort: 16
width: full
@@ -1785,15 +1897,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"
@@ -1827,7 +1940,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

@@ -5,6 +5,7 @@ export * from './forbidden';
export * from './hit-rate-limit';
export * from './invalid-cache-key';
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

@@ -65,3 +65,13 @@ export async function sendInviteMail(email: string, url: string) {
const html = await liquidEngine.renderFile('user-invitation', { email, url, projectName });
await transporter.sendMail({ from: env.EMAIL_FROM, to: email, html: html });
}
export async function sendPasswordResetMail(email: string, url: string) {
/**
* @TODO pull this from directus_settings
*/
const projectName = 'directus';
const html = await liquidEngine.renderFile('password-reset', { email, url, projectName });
await transporter.sendMail({ from: env.EMAIL_FROM, to: email, html: html });
}

View File

@@ -0,0 +1,15 @@
{% layout "base" %}
{% block content %}
<p>You requested to reset your password. Please click the link below to reset your password:</p>
<p><a href="{{ url }}">{{ url }}</a></p>
{% comment %}
@TODO
Make this white-labeled
{% endcomment %}
<p>Love,<br>Directus</p>
{% endblock %}

View File

@@ -3,7 +3,7 @@
<p>You have been invited to {{ projectName }}. Please click the link below to join:</p>
<p><a href="url">{{ url }}</a></p>
<p><a href="{{ url }}">{{ url }}</a></p>
{% comment %}
@TODO

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,11 @@ 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 +55,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 +115,37 @@ 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) {
@@ -146,13 +158,21 @@ export default class AuthenticationService {
}
async generateOTPAuthURL(pk: string, secret: string) {
const user = await this.knex.select('first_name', 'last_name').from('directus_users').where({ id: pk }).first();
const user = await this.knex
.select('first_name', 'last_name')
.from('directus_users')
.where({ id: pk })
.first();
const name = `${user.first_name} ${user.last_name}`;
return authenticator.keyuri(name, 'Directus', secret);
}
async verifyOTP(pk: string, otp: string): Promise<boolean> {
const user = await this.knex.select('tfa_secret').from('directus_users').where({ id: pk }).first();
const user = await this.knex
.select('tfa_secret')
.from('directus_users')
.where({ id: pk })
.first();
if (!user.tfa_secret) {
throw new InvalidPayloadException(`User "${pk}" doesn't have TFA enabled.`);

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,11 @@ 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 +240,18 @@ 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,11 @@ 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 +247,29 @@ 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,17 @@ 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 } },
limit: -1,
})) as FieldMeta[];
} else {
fields = (await this.itemsService.readByQuery({})) as FieldMeta[];
fields = (await nonAuthorizedItemsService.readByQuery({ limit: -1 })) as FieldMeta[];
}
fields = (await this.payloadService.processValues('read', fields)) as FieldMeta[];
@@ -106,11 +98,57 @@ 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 +179,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 +209,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 +254,54 @@ 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
);
@@ -246,7 +246,10 @@ export default class ItemsService implements AbstractService {
columns.map(({ column }) => column)
);
payloadWithoutAliases = await payloadService.processValues('update', payloadWithoutAliases);
payloadWithoutAliases = await payloadService.processValues(
'update',
payloadWithoutAliases
);
if (Object.keys(payloadWithoutAliases).length > 0) {
await trx(this.collection)
@@ -331,7 +334,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,15 @@ 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>,
accountability: Accountability | null
) => Promise<any>;
};
export default class PayloadService {
@@ -41,24 +46,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 +75,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 +92,43 @@ export default class PayloadService {
}
}
}
return value;
},
async conceal(action, value) {
if (action === 'read') return value ? '**********' : null;
return value;
},
async 'user-created'(action, value, payload, accountability) {
if (action === 'create') return accountability?.user || null;
return value;
},
async 'user-updated'(action, value, payload, accountability) {
if (action === 'update') return accountability?.user || null;
return value;
},
async 'role-created'(action, value, payload, accountability) {
if (action === 'create') return accountability?.role || null;
return value;
},
async 'role-updated'(action, value, payload, accountability) {
if (action === 'update') return accountability?.role || null;
return value;
},
async 'date-created'(action, value) {
if (action === 'create') return new Date();
return value;
},
async 'date-updated'(action, value) {
if (action === 'update') return new Date();
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 +143,7 @@ export default class PayloadService {
.where({ collection: this.collection })
.whereNotNull('special');
if (operation === 'read') {
if (action === 'read') {
specialFieldsQuery.whereIn('field', fieldsInPayload);
}
@@ -118,14 +153,19 @@ 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,
this.accountability
);
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 +185,22 @@ export default class PayloadService {
async processField(
field: Pick<FieldMeta, 'field' | 'special'>,
payload: Partial<Item>,
operation: Operation
action: Action,
accountability: Accountability | null
) {
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, accountability);
}
}
return payload[field.field];
return value;
}
/**
@@ -253,12 +300,19 @@ export default class PayloadService {
);
const toBeUpdated = relatedRecords.filter(
(record) => record.hasOwnProperty(relation.many_primary) === true && record.hasOwnProperty('$delete') === false
(record) =>
record.hasOwnProperty(relation.many_primary) === true &&
record.hasOwnProperty('$delete') === false
);
const toBeDeleted = relatedRecords
.filter(record => record.hasOwnProperty(relation.many_primary) === true && record.hasOwnProperty('$delete') && record.$delete === true)
.map(record => record[relation.many_primary]);
.filter(
(record) =>
record.hasOwnProperty(relation.many_primary) === true &&
record.hasOwnProperty('$delete') &&
record.$delete === true
)
.map((record) => record[relation.many_primary]);
await itemsService.create(toBeCreated);
await itemsService.update(toBeUpdated);
@@ -267,3 +321,4 @@ export default class PayloadService {
}
}
}
0;

View File

@@ -0,0 +1,46 @@
import { AbstractServiceOptions, Accountability } from '../types';
import Knex from 'knex';
import database from '../database';
import os from 'os';
import { ForbiddenException } from '../exceptions';
// @ts-ignore
import { version } from '../../package.json';
import macosRelease from 'macos-release';
export default class ServerService {
knex: Knex;
accountability: Accountability | null;
constructor(options?: AbstractServiceOptions) {
this.knex = options?.knex || database;
this.accountability = options?.accountability || null;
}
serverInfo() {
if (this.accountability?.admin !== true) {
throw new ForbiddenException();
}
const osType = os.type() === 'Darwin' ? 'macOS' : os.type();
const osVersion =
osType === 'macOS'
? `${macosRelease().name} (${macosRelease().version})`
: os.release();
return {
directus: {
version,
},
node: {
version: process.versions.node,
uptime: Math.round(process.uptime()),
},
os: {
type: osType,
version: osVersion,
uptime: Math.round(os.uptime()),
totalmem: os.totalmem(),
},
};
}
}

View File

@@ -1,11 +1,11 @@
import AuthService from './authentication';
import ItemsService from './items';
import jwt from 'jsonwebtoken';
import { sendInviteMail } from '../mail';
import { sendInviteMail, sendPasswordResetMail } from '../mail';
import database from '../database';
import argon2 from 'argon2';
import { InvalidPayloadException } from '../exceptions';
import { Accountability, Query, Item, AbstractServiceOptions } from '../types';
import { InvalidPayloadException, ForbiddenException } from '../exceptions';
import { Accountability, PrimaryKey, Item, AbstractServiceOptions } from '../types';
import Knex from 'knex';
import env from '../env';
@@ -22,10 +22,33 @@ 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' });
const payload = { email };
const payload = { email, scope: 'invite' };
const token = jwt.sign(payload, env.SECRET as string, { expiresIn: '7d' });
const acceptURL = env.PUBLIC_URL + '/admin/accept-invite?token=' + token;
@@ -33,9 +56,14 @@ export default class UsersService extends ItemsService {
}
async acceptInvite(token: string, password: string) {
const { email } = jwt.verify(token, env.SECRET as string) as { email: string };
const { email, scope } = jwt.verify(token, env.SECRET as string) as {
email: string;
scope: string;
};
const user = await database
if (scope !== 'invite') throw new ForbiddenException();
const user = await this.knex
.select('id', 'status')
.from('directus_users')
.where({ email })
@@ -47,13 +75,53 @@ export default class UsersService extends ItemsService {
const passwordHashed = await argon2.hash(password);
await database('directus_users')
await this.knex('directus_users')
.update({ password: passwordHashed, status: 'active' })
.where({ id: user.id });
}
async requestPasswordReset(email: string) {
const user = await this.knex.select('id').from('directus_users').where({ email }).first();
if (!user) throw new ForbiddenException();
const payload = { email, scope: 'password-reset' };
const token = jwt.sign(payload, env.SECRET as string, { expiresIn: '7d' });
const acceptURL = env.PUBLIC_URL + '/admin/reset-password?token=' + token;
await sendPasswordResetMail(email, acceptURL);
}
async resetPassword(token: string, password: string) {
const { email, scope } = jwt.verify(token, env.SECRET as string) as {
email: string;
scope: string;
};
if (scope !== 'password-reset') throw new ForbiddenException();
const user = await this.knex
.select('id', 'status')
.from('directus_users')
.where({ email })
.first();
if (!user || user.status !== 'active') {
throw new ForbiddenException();
}
const passwordHashed = await argon2.hash(password);
await this.knex('directus_users')
.update({ password: passwordHashed, status: 'active' })
.where({ id: user.id });
}
async enableTFA(pk: string) {
const user = await this.knex.select('tfa_secret').from('directus_users').where({ id: pk }).first();
const user = await this.knex
.select('tfa_secret')
.from('directus_users')
.where({ id: pk })
.first();
if (user?.tfa_secret !== null) {
throw new InvalidPayloadException('TFA Secret is already set for this user');
@@ -64,7 +132,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) {

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

@@ -0,0 +1,115 @@
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,18 +1,12 @@
export type Operation =
| 'create'
| 'read'
| 'update'
| 'validate'
| 'delete'
| 'comment'
| 'explain';
export type PermissionsAction = 'create' | 'read' | 'update' | 'delete' | 'comment' | 'explain';
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 = {

View File

@@ -56,7 +56,7 @@ const localTypeMap: Record<string, { type: typeof types[number]; useTimezone?: b
// Postgres
json: { type: 'json' },
uuid: { type: 'string' },
uuid: { type: 'uuid' },
int2: { type: 'integer' },
serial4: { type: 'integer' },
int4: { type: 'integer' },

View File

@@ -11,6 +11,6 @@
"lib": [
"es2019"
],
"skipLibCheck": true
"skipLibCheck": true,
}
}

View File

@@ -12,8 +12,7 @@
"custom-properties",
"declarations",
"at-variables",
"rules",
"at-rules"
"rules"
],
"at-rule-no-unknown": null,
"scss/at-rule-no-unknown": true,

24361
app/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@directus/app",
"version": "9.0.0-alpha.20",
"version": "9.0.0-alpha.27",
"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,8 @@
"nanoid": "^3.1.10",
"pinia": "^0.0.7",
"portal-vue": "^2.1.7",
"pretty-ms": "^7.0.0",
"qrcode": "^1.4.4",
"resize-observer": "^1.0.0",
"semver": "^7.3.2",
"stylelint-config-prettier": "^8.0.2",
@@ -90,6 +93,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 +159,5 @@
"stylelint --fix"
]
},
"gitHead": "9092740d708c1e9ed1575b0932a01f531d224f77"
"gitHead": "c22537b6d0dd7dc6cc651a9200a68f6b060353d4"
}

View File

@@ -51,15 +51,26 @@ 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) {
if (
status === 401 &&
code === 'INVALID_CREDENTIALS' &&
error.request.responseURL.includes('refresh') === false &&
error.request.responseURL.includes('login') === false
) {
try {
await refresh();
const newToken = await refresh();
/** @todo retry failed request after successful refresh */
return api.request({
...error.config,
headers: {
Authorization: `Bearer ${newToken}`,
},
});
} catch {
logout({ reason: LogoutReason.ERROR_SESSION_EXPIRED });
return Promise.reject();
}
}

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,6 +46,8 @@ export async function refresh({ navigate }: LogoutOptions = { navigate: true })
// logged in without any noticable hickups or delays
setTimeout(() => refresh(), response.data.data.expires * 1000 - 10 * 1000);
appStore.state.authenticated = true;
return accessToken;
} catch (error) {
await logout({ navigate, reason: LogoutReason.ERROR_SESSION_EXPIRED });
}

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

@@ -8,5 +8,7 @@
flex-wrap: wrap;
align-items: center;
padding: var(--v-card-padding);
margin-top: 12px;
line-height: 1.6em;
}
</style>

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

@@ -10,11 +10,13 @@
@keydown="onKeyDown"
@input="onInput"
@click="onClick"
/>
>
<span class="text" />
</span>
</template>
<template #append>
<v-icon name="add_box" @click="toggle" />
<v-icon name="add_box" outline @click="toggle" />
</template>
</v-input>
</template>
@@ -26,7 +28,7 @@
</template>
<script lang="ts">
import { defineComponent, toRefs, ref, watch, onMounted } from '@vue/composition-api';
import { defineComponent, toRefs, ref, watch, onMounted, onUnmounted } from '@vue/composition-api';
import FieldListItem from './field-list-item.vue';
import { useFieldsStore } from '@/stores';
import { Field } from '@/types/';
@@ -58,15 +60,26 @@ export default defineComponent({
const { tree } = useFieldTree(collection);
watch(() => props.value, setContent, { immediate: true });
onMounted(setContent);
return { tree, addField, onInput, contentEl, onClick, onKeyDown, menuActive };
onMounted(() => {
if (contentEl.value) {
contentEl.value.addEventListener('selectstart', onSelect);
setContent();
}
});
onUnmounted(() => {
if (contentEl.value) {
contentEl.value.removeEventListener('selectstart', onSelect);
}
});
return { tree, addField, onInput, contentEl, onClick, onKeyDown, menuActive, onSelect };
function onInput() {
if (!contentEl.value) return;
const valueString = getInputValue();
emit('input', valueString);
}
@@ -78,11 +91,14 @@ export default defineComponent({
const field = target.dataset.field;
emit('input', props.value.replace(`{{${field}}}`, ''));
// A button is wrapped in two empty `<span></span>` elements
target.previousElementSibling?.remove();
target.nextElementSibling?.remove();
target.remove();
const before = target.previousElementSibling;
const after = target.nextElementSibling;
if (!before || !after || !(before instanceof HTMLElement) || !(after instanceof HTMLElement)) return;
target.remove();
joinElements(before, after);
window.getSelection()?.removeAllRanges();
onInput();
}
@@ -91,6 +107,44 @@ export default defineComponent({
event.preventDefault();
menuActive.value = true;
}
if (contentEl.value?.innerHTML === '') {
contentEl.value.innerHTML = '<span class="text"></span>';
}
}
function onSelect() {
if (!contentEl.value) return;
const selection = window.getSelection();
if (!selection || selection.rangeCount <= 0) return;
const range = selection.getRangeAt(0);
if (!range) return;
const start = range.startContainer;
if (
!(start instanceof HTMLElement && start.classList.contains('text')) &&
!start.parentElement?.classList.contains('text')
) {
selection.removeAllRanges();
const range = new Range();
let textSpan = null;
for (let i = 0; i < contentEl.value.childNodes.length || !textSpan; i++) {
const child = contentEl.value.children[i];
if (child.classList.contains('text')) {
textSpan = child;
}
}
if (!textSpan) {
textSpan = document.createElement('span');
textSpan.classList.add('text');
contentEl.value.appendChild(textSpan);
}
range.setStart(textSpan, 0);
selection.addRange(range);
}
}
function addField(fieldKey: string) {
@@ -104,22 +158,64 @@ export default defineComponent({
button.innerText = String(field.name);
const range = window.getSelection()?.getRangeAt(0);
range?.deleteContents();
range?.insertNode(button);
window.getSelection()?.removeAllRanges();
if (!range) return;
range.deleteContents();
const end = splitElements();
if (end) {
contentEl.value.insertBefore(button, end);
window.getSelection()?.removeAllRanges();
} else {
contentEl.value.appendChild(button);
const span = document.createElement('span');
span.classList.add('text');
contentEl.value.appendChild(span);
}
onInput();
}
function joinElements(first: HTMLElement, second: HTMLElement) {
first.innerText += second.innerText;
second.remove();
}
function splitElements() {
const range = window.getSelection()?.getRangeAt(0);
if (!range) return;
const textNode = range.startContainer;
if (textNode.nodeType !== Node.TEXT_NODE) return;
const start = textNode.parentElement;
if (!start || !(start instanceof HTMLSpanElement) || !start.classList.contains('text')) return;
const startOffset = range.startOffset;
const left = start.textContent?.substr(0, startOffset) || '';
const right = start.textContent?.substr(startOffset) || '';
start.innerText = left;
const nextSpan = document.createElement('span');
nextSpan.classList.add('text');
nextSpan.innerText = right;
contentEl.value?.insertBefore(nextSpan, start.nextSibling);
return nextSpan;
}
function getInputValue() {
if (!contentEl.value) return null;
return Array.from(contentEl.value.childNodes).reduce((acc, node) => {
if (node.nodeType === Node.TEXT_NODE) return (acc += node.textContent);
const el = node as HTMLElement;
const tag = el.tagName;
if (tag.toLowerCase() === 'button') return (acc += `{{${el.dataset.field}}}`);
if (tag) {
if (tag.toLowerCase() === 'button') return (acc += `{{${el.dataset.field}}}`);
if (tag.toLowerCase() === 'span') return (acc += el.textContent);
}
return (acc += '');
}, '');
}
@@ -127,30 +223,31 @@ export default defineComponent({
function setContent() {
if (!contentEl.value) return;
if (props.value === null) {
contentEl.value.innerHTML = '';
if (props.value === null || props.value === '') {
contentEl.value.innerHTML = '<span class="text"></span>';
return;
}
if (props.value !== getInputValue()) {
const regex = /({{.*?}})/g;
const before = null;
const after = null;
const newInnerHTML = props.value
.split(regex)
.map((part) => {
if (part.startsWith('{{') === false) return part;
const fieldKey = part.replace(/{{/g, '').replace(/}}/g, '').trim();
if (part.startsWith('{{') === false) {
return `<span class="text">${part}</span>`;
}
const fieldKey = part.replaceAll(/({|})/g, '').trim();
const field: Field | null = fieldsStore.getField(props.collection, fieldKey);
// Instead of crashing when the field doesn't exist, we'll render a couple question
// marks to indicate it's absence
if (!field) return '???';
if (!field) return '';
return `<button contenteditable="false" data-field="${field.field}">${field.name}</button>`;
})
.join('');
contentEl.value.innerHTML = newInnerHTML;
}
}
@@ -169,7 +266,7 @@ export default defineComponent({
::v-deep {
> * {
display: inline;
display: inline-block;
white-space: nowrap;
}
@@ -177,8 +274,13 @@ export default defineComponent({
display: none;
}
span {
min-width: 1px;
min-height: 1em;
}
button {
margin: 0;
margin: 0 4px;
padding: 0 4px;
color: var(--primary);
background-color: var(--primary-alt);

View File

@@ -9,11 +9,13 @@
<component
v-if="interfaceExists"
:is="`interface-${field.meta.interface}`"
v-bind="field.meta.options"
:is="
field.meta ? `interface-${field.meta.interface}` : `interface-${getDefaultInterfaceForType(field.type)}`
"
v-bind="(field.meta && field.meta.options) || {}"
:disabled="disabled"
:value="value === undefined ? field.schema.default_value : value"
:width="field.meta.width"
:width="(field.meta && field.meta.width) || 'full'"
:type="field.type"
:collection="field.collection"
:field="field.field"
@@ -23,7 +25,7 @@
/>
<v-notice v-else type="warning">
{{ $t('interface_not_found', { interface: field.meta.interface }) }}
{{ $t('interface_not_found', { interface: field.meta && field.meta.interface }) }}
</v-notice>
</div>
</template>
@@ -31,7 +33,8 @@
<script lang="ts">
import { defineComponent, PropType, computed } from '@vue/composition-api';
import { Field } from '@/types';
import interfaces from '@/interfaces';
import { getInterfaces } from '@/interfaces';
import { getDefaultInterfaceForType } from '@/utils/get-default-interface-for-type';
export default defineComponent({
props: {
@@ -65,11 +68,13 @@ 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 || 'text-input');
});
return { interfaceExists };
return { interfaceExists, getDefaultInterfaceForType };
},
});
</script>

View File

@@ -1,5 +1,5 @@
<template>
<div class="field" :key="field.field" :class="field.meta.width">
<div class="field" :key="field.field" :class="(field.meta && field.meta.width) || 'full'">
<v-menu v-if="field.hideLabel !== true" placement="bottom-start" show-arrow :disabled="isDisabled">
<template #activator="{ toggle, active }">
<form-field-label
@@ -33,7 +33,7 @@
@input="$emit('input', $event)"
/>
<small class="note" v-if="field.meta.note" v-html="marked(field.meta.note)" />
<small class="note" v-if="field.meta && field.meta.note" v-html="marked(field.meta.note)" />
</div>
</template>
@@ -84,7 +84,7 @@ export default defineComponent({
setup(props) {
const isDisabled = computed(() => {
if (props.disabled) return true;
if (props.field.meta.readonly) return true;
if (props.field?.meta?.readonly === true) return true;
if (props.batchMode && props.batchActive === false) return true;
return false;
});

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('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';
@@ -130,7 +132,7 @@ export default defineComponent({
return (
props.loading ||
props.disabled === true ||
field.meta.readonly === true ||
field.meta?.readonly === true ||
(props.batchMode && batchActiveFields.value.includes(field.field) === false)
);
}
@@ -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

@@ -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>
@@ -30,6 +30,7 @@ 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',
@@ -47,6 +48,7 @@ const customIcons: string[] = [
'flip_horizontal',
'flip_vertical',
'folder_move',
'logout',
];
export default defineComponent({
@@ -66,13 +68,14 @@ export default defineComponent({
CustomIconFlipHorizontal,
CustomIconFlipVertical,
CustomIconFolderMove,
CustomIconLogout,
},
props: {
name: {
type: String,
required: true,
},
outline: {
filled: {
type: Boolean,
default: false,
},
@@ -126,6 +129,7 @@ export default defineComponent({
<style>
body {
--v-icon-color: currentColor;
--v-icon-color-hover: currentColor;
--v-icon-size: 24px;
}
</style>
@@ -146,7 +150,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;
@@ -155,9 +159,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';
}
}
@@ -169,6 +173,11 @@ body {
&.has-click {
cursor: pointer;
transition: color var(--fast) var(--transition);
&:hover {
color: var(--v-icon-color-hover);
}
}
&.sup {

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

@@ -17,6 +17,12 @@ export default defineComponent({
});
</script>
<style>
body {
--v-list-item-icon-color: var(--foreground-subdued);
}
</style>
<style lang="scss" scoped>
.v-list-item-icon {
$this: &;
@@ -62,8 +68,8 @@ export default defineComponent({
}
}
&.dense:not(.nav) #{$this} {
color: var(--foreground-subdued);
&.dense:not(.nav) #{$this} .v-icon {
--v-icon-color: var(--v-list-item-icon-color);
}
}
}

View File

@@ -351,7 +351,7 @@ body {
}
.v-menu-content {
max-height: 30vh;
// max-height: 30vh;
padding: 0 4px;
overflow-x: hidden;
overflow-y: auto;

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

@@ -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

@@ -1,6 +1,7 @@
import { usePresetsStore, useUserStore } from '@/stores';
import { ref, Ref, computed, watch } from '@vue/composition-api';
import { debounce } from 'lodash';
import { useCollection } from '@/composables/use-collection';
import { Filter, Preset } from '@/types/';
@@ -8,6 +9,8 @@ export function usePreset(collection: Ref<string>, bookmark: Ref<number | null>
const presetsStore = usePresetsStore();
const userStore = useUserStore();
const { info: collectionInfo } = useCollection(collection);
const bookmarkExists = computed(() => {
if (!bookmark.value) return false;
@@ -156,11 +159,28 @@ export function usePreset(collection: Ref<string>, bookmark: Ref<number | null>
};
}
if (!localPreset.value.view_type)
if (!localPreset.value.view_type) {
localPreset.value = {
...localPreset.value,
view_type: 'tabular',
};
}
if (collectionInfo.value?.meta?.archive_field && collectionInfo.value?.meta?.archive_app_filter === true) {
localPreset.value = {
...localPreset.value,
filters: [
...(localPreset.value.filters || []),
{
key: 'hide-archived',
field: collectionInfo.value.meta.archive_field,
operator: 'neq',
value: collectionInfo.value.meta.archive_value!,
locked: true,
},
],
};
}
}
/**

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 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,15 +1,17 @@
/* 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';
import { clone } from 'lodash';
export default function useFormFields(fields: Ref<Field[]>) {
const interfaces = getInterfaces();
const formFields = computed(() => {
let formFields = [...fields.value];
let formFields = clone(fields.value);
// Sort the fields on the sort column value
formFields = formFields.sort((a, b) => {
@@ -23,40 +25,16 @@ export default function useFormFields(fields: Ref<Field[]>) {
});
formFields = formFields.map((field, index) => {
if (!field.meta) {
field.meta = {
id: -1,
collection: field.collection,
field: field.field,
group: null,
hidden: false,
locked: false,
interface: null,
options: null,
display: null,
display_options: null,
readonly: false,
required: false,
sort: null,
special: null,
translation: null,
width: 'full',
note: null,
};
}
if (!field.meta) return field;
if (!field.meta.width) {
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;
@@ -69,7 +47,7 @@ export default function useFormFields(fields: Ref<Field[]>) {
if (index !== 0 && field.meta!.width === 'half') {
const prevField = formFields[index - 1];
if (prevField.meta.width === 'half') {
if (prevField.meta?.width === 'half') {
field.meta.width = 'half-right';
}
}
@@ -80,7 +58,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,19 +6,29 @@ 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);
const loading = ref(false);
const saving = ref(false);
const deleting = ref(false);
const softDeleting = ref(false);
const archiving = ref(false);
const edits = ref({});
const isNew = computed(() => primaryKey.value === '+');
const isBatch = computed(() => typeof primaryKey.value === 'string' && primaryKey.value.includes(','));
const isSingle = computed(() => !!collectionInfo.value?.meta?.singleton);
const isArchived = computed(() => {
if (!collectionInfo.value?.meta?.archive_field) return null;
if (collectionInfo.value.meta.archive_value === 'true') {
return item.value?.[collectionInfo.value.meta.archive_field] === true;
}
return item.value?.[collectionInfo.value.meta.archive_field] === collectionInfo.value.meta.archive_value;
});
const endpoint = computed(() => {
return collection.value.startsWith('directus_')
? `/${collection.value.substring(9)}`
@@ -46,7 +56,9 @@ export function useItem(collection: Ref<string>, primaryKey: Ref<string | number
isNew,
remove,
deleting,
softDeleting,
archive,
isArchived,
archiving,
saveAsCopy,
isBatch,
getItem,
@@ -176,25 +188,65 @@ 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 archive() {
if (!collectionInfo.value?.meta?.archive_field) return;
archiving.value = true;
const field = collectionInfo.value.meta.archive_field;
let archiveValue: any = collectionInfo.value.meta.archive_value;
if (archiveValue === 'true') archiveValue = true;
if (archiveValue === 'false') archiveValue = false;
let unarchiveValue: any = collectionInfo.value.meta.unarchive_value;
if (unarchiveValue === 'true') unarchiveValue = true;
if (unarchiveValue === 'false') unarchiveValue = false;
try {
if (soft) {
if (!statusField.value || softDeleteStatus.value === null) {
throw new Error('[useItem] You cant soft-delete without a status field');
}
let value: any = item.value[field] === archiveValue ? unarchiveValue : archiveValue;
await api.patch(itemEndpoint.value, {
[statusField.value.field]: softDeleteStatus.value,
});
} else {
await api.delete(itemEndpoint.value);
}
if (value === 'true') value = true;
if (value === 'false') value = false;
item.value = {
...item.value,
[field]: value,
};
await api.patch(itemEndpoint.value, {
[field]: value,
});
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 {
archiving.value = false;
}
}
async function remove() {
deleting.value = true;
try {
await api.delete(itemEndpoint.value);
item.value = null;
@@ -218,11 +270,7 @@ export function useItem(collection: Ref<string>, primaryKey: Ref<string | number
throw err;
} finally {
if (soft) {
softDeleting.value = false;
} else {
deleting.value = false;
}
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

@@ -32,6 +32,9 @@ const Tooltip: DirectiveOptions = {
bind(element, binding);
} else if (!binding.value && binding.oldValue) {
unbind(element);
} else {
unbind(element);
bind(element, binding);
}
},
};

View File

@@ -5,7 +5,7 @@ export default defineDisplay(({ i18n }) => ({
id: 'collection',
name: i18n.t('collection'),
types: ['string'],
icon: 'box',
icon: 'label',
handler: DisplayCollection,
options: [
{

View File

@@ -9,7 +9,6 @@ import DisplayImage from './image';
import DisplayMimeType from './mime-type';
import DisplayRating from './rating';
import DisplayRaw from './raw';
import DisplayStatusBadge from './status-badge/';
import DisplayStatusDot from './status-dot/';
import DisplayTags from './tags/';
import DisplayTemplate from './template';
@@ -27,7 +26,6 @@ export const displays = [
DisplayImage,
DisplayMimeType,
DisplayRating,
DisplayStatusBadge,
DisplayStatusDot,
DisplayTags,
DisplayTemplate,

View File

@@ -1,11 +0,0 @@
import { defineDisplay } from '@/displays/define';
import DisplayStatusBadge from './status-badge.vue';
export default defineDisplay(({ i18n }) => ({
id: 'status-badge',
name: i18n.t('status_badge'),
types: ['string'],
icon: 'flag',
handler: DisplayStatusBadge,
options: null,
}));

View File

@@ -1,4 +0,0 @@
# Status Badge
Renders the set status formatted according to the status mapping set in the interface options.

View File

@@ -1,53 +0,0 @@
import withPadding from '../../../.storybook/decorators/with-padding';
import { withKnobs, text, object } from '@storybook/addon-knobs';
import readme from './readme.md';
import { defineComponent } from '@vue/composition-api';
export default {
title: 'Displays / Status (Badge)',
decorators: [withPadding, withKnobs],
parameters: {
notes: readme,
},
};
const defaultStatusMapping = {
published: {
name: 'Published',
value: 'published',
text_color: '#fff',
background_color: 'var(--primary)',
},
draft: {
name: 'Draft',
value: 'draft',
text_color: 'var(--primary-subdued)',
background_color: 'var(--background-subdued)',
},
deleted: {
name: 'Deleted',
value: 'deleted',
text_color: 'var(--danger)',
background_color: 'var(--danger-alt)',
},
};
export const basic = () =>
defineComponent({
props: {
value: {
default: text('Value', 'published'),
},
statusMapping: {
default: object('Status Mapping', defaultStatusMapping),
},
},
template: `
<display-status-badge
:value="value"
:interface-options="{
status_mapping: statusMapping,
}"
/>
`,
});

View File

@@ -1,73 +0,0 @@
import DisplayStatusBadge from './status-badge.vue';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import VIcon from '@/components/v-icon';
import VueCompositionAPI from '@vue/composition-api';
import Tooltip from '@/directives/tooltip';
const localVue = createLocalVue();
localVue.component('v-icon', VIcon);
localVue.use(VueCompositionAPI);
localVue.directive('tooltip', Tooltip);
describe('Displays / Status Badge', () => {
it('Renders an empty span if no value is passed', () => {
const component = shallowMount(DisplayStatusBadge, {
localVue,
propsData: {
value: null,
},
});
expect(component.find('span').exists()).toBe(true);
expect(component.find('span').text()).toBe('');
});
it('Renders a question mark icon is status is unknown in interface options', () => {
const component = shallowMount(DisplayStatusBadge, {
localVue,
propsData: {
value: 'draft',
interfaceOptions: {
status_mapping: {
published: {},
},
},
},
});
expect(component.find(VIcon).exists()).toBe(true);
expect(component.attributes('name')).toBe('help_outline');
});
it('Renders the badge with the correct colors', () => {
const component = shallowMount(DisplayStatusBadge, {
localVue,
propsData: {
value: 'draft',
interfaceOptions: {
status_mapping: {
draft: {
background_color: 'rgb(171, 202, 188)',
text_color: 'rgb(150, 100, 125)',
},
},
},
},
});
expect(component.exists()).toBe(true);
expect(component.attributes('style')).toBe('background-color: rgb(171, 202, 188); color: rgb(150, 100, 125);');
});
it('Sets status to null if interface options are missing', () => {
const component = shallowMount(DisplayStatusBadge, {
localVue,
propsData: {
value: 'draft',
interfaceOptions: null,
},
});
expect((component.vm as any).status).toBe(null);
});
});

View File

@@ -1,51 +0,0 @@
<template>
<span v-if="!value" />
<v-icon name="help_outline" v-else-if="!status" />
<div
v-else
class="badge type-text"
:style="{
backgroundColor: status.background_color,
color: status.text_color,
}"
>
{{ status.name }}
</div>
</template>
<script lang="ts">
import { defineComponent, computed } from '@vue/composition-api';
export default defineComponent({
props: {
value: {
type: String,
default: null,
},
interfaceOptions: {
type: Object,
default: null,
},
},
setup(props) {
const status = computed(() => {
if (props.interfaceOptions === null) return null;
return props.interfaceOptions.status_mapping?.[props.value];
});
return { status };
},
});
</script>
<style lang="scss" scoped>
.badge {
display: inline-block;
padding: 8px;
color: var(--foreground-inverted);
line-height: 1;
vertical-align: middle;
border-radius: var(--border-radius);
}
</style>

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

@@ -10,6 +10,7 @@ import {
useRelationsStore,
usePermissionsStore,
} from '@/stores';
import { register as registerModules, unregister as unregisterModules } from '@/modules/register';
import { setLanguage, Language } from '@/lang';
@@ -59,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 {
@@ -78,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

@@ -0,0 +1,48 @@
<template>
<v-notice v-if="!collectionField" type="warning">
{{ $t('collection_field_not_setup') }}
</v-notice>
<v-notice v-else-if="collection === null" type="warning">
{{ $t('select_a_collection') }}
</v-notice>
<v-field-template v-else :collection="collection" @input="$listeners.input" :value="value" :disabled="disabled" />
</template>
<script lang="ts">
import { defineComponent, inject, ref, computed } from '@vue/composition-api';
import { useCollectionsStore } from '@/stores/collections';
export default defineComponent({
props: {
disabled: {
type: Boolean,
default: false,
},
value: {
type: String,
default: null,
},
collectionField: {
type: String,
default: null,
},
},
setup(props) {
const collectionsStore = useCollectionsStore();
const values = inject('values', ref<Record<string, any>>({}));
const collection = computed(() => {
if (!props.collectionField) return null;
const collectionName = values.value[props.collectionField];
const collectionExists = !!collectionsStore.state.collections.find(
(collection) => collection.collection === collectionName
);
if (collectionExists === false) return null;
return collectionName;
});
return { collection };
},
});
</script>

View File

@@ -0,0 +1,12 @@
import { defineInterface } from '@/interfaces/define';
import InterfaceDisplayTemplate from './display-template.vue';
export default defineInterface(({ i18n }) => ({
id: 'display-template',
name: i18n.t('display-template'),
icon: 'arrow_drop_down_circle',
component: InterfaceDisplayTemplate,
types: ['string'],
system: true,
options: [],
}));

View File

@@ -0,0 +1,9 @@
# Display template
This is the interface version of the v-field-template component.
## Options
| Option | Description | Default |
|---------------|----------------------------------------|---------|
| `*collection` | Fields of collection to use | `null` |

View File

@@ -36,6 +36,6 @@ export default defineComponent({
<style lang="scss" scoped>
.margin {
margin-top: 48px;
margin-top: 12px;
}
</style>

View File

@@ -0,0 +1,80 @@
<template>
<v-notice v-if="!collectionField" type="warning">
{{ $t('collection_field_not_setup') }}
</v-notice>
<v-notice v-else-if="selectItems.length === 0" type="warning">
{{ $t('select_a_collection') }}
</v-notice>
<v-select
v-else
:show-deselect="allowNone"
@input="$listeners.input"
:value="value"
:disabled="disabled"
:items="selectItems"
:placeholder="placeholder"
/>
</template>
<script lang="ts">
import { defineComponent, computed, inject, ref, PropType } from '@vue/composition-api';
import { useFieldsStore } from '@/stores';
import { Field } from '@/types';
export default defineComponent({
props: {
collectionField: {
type: String,
default: null,
},
typeAllowList: {
type: Array as PropType<string[]>,
default: () => [],
},
value: {
type: String,
default: null,
},
disabled: {
type: Boolean,
default: false,
},
placeholder: {
type: String,
default: null,
},
allowNone: {
type: Boolean,
default: false,
},
},
setup(props) {
const fieldsStore = useFieldsStore();
const values = inject('values', ref<Record<string, any>>({}));
const fields = computed(() => {
if (!props.collectionField) return [];
return fieldsStore.getFieldsForCollection(values.value[props.collectionField]);
});
const selectItems = computed(() =>
fields.value.map((field: Field) => {
let disabled = false;
if (field?.schema?.is_primary_key === true) disabled = true;
if (props.typeAllowList.length > 0 && props.typeAllowList.includes(field.type) === false)
disabled = true;
return {
text: field.name,
value: field.field,
disabled,
};
})
);
return { selectItems };
},
});
</script>

View File

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

View File

@@ -1,67 +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 InterfaceMarkdown from './markdown';
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,
InterfaceMarkdown,
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('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

@@ -7,7 +7,7 @@
{{ $t('edit') }}
</v-tab>
<v-tab>
<v-icon name="visibility" left />
<v-icon name="visibility" outline left />
{{ $t('preview') }}
</v-tab>
</v-tabs>
@@ -65,16 +65,23 @@ export default defineComponent({
--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);
}
@@ -109,15 +116,17 @@ export default defineComponent({
::v-deep {
.preview {
font-weight: 400;
font-weight: 500;
font-size: 14px;
line-height: 1.6;
& > *:first-child {
margin-top: 0;
}
& > *:last-child {
margin-bottom: 0;
}
a {
text-decoration: underline;
}
h1,
h2,
h3,
@@ -221,10 +230,6 @@ export default defineComponent({
margin-top: 0;
padding-top: 0;
}
& > h1:first-child + h2 {
margin-top: 0;
padding-top: 0;
}
& > h3:first-child,
& > h4:first-child,
& > h5:first-child,

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

@@ -1,25 +0,0 @@
import InterfaceStatus from './status.vue';
import { defineInterface } from '@/interfaces/define';
export default defineInterface(({ i18n }) => ({
id: 'status',
name: i18n.t('status'),
icon: 'bubble_chart',
component: InterfaceStatus,
types: ['string'],
options: [
/** @TODO change this to a custom options element */
{
field: 'status_mapping',
name: i18n.t('status_mapping'),
type: 'json',
meta: {
width: 'full',
interface: 'code',
options: {
language: 'json'
}
}
},
],
}));

View File

@@ -1,26 +0,0 @@
# Status Interface
Renders a dropdown with the available status options.
## Options
| Option | Description | Default |
|------------------|-----------------------------|---------|
| `status_mapping` | What statuses are available | `null` |
### Status Mapping format
```ts
type Status = {
[key: string]: {
name: string;
text_color: string;
background_color: string;
soft_delete: boolean;
published: boolean;
}
}
```
`status_mapping` is the only option for an interface that isn't camelCased. This is due to the fact
that the API relies on the same setting for it's permissions management.

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