mirror of
https://github.com/directus/directus.git
synced 2026-04-25 03:00:53 -04:00
Merge branch 'main' into feature-rate-limiting
This commit is contained in:
16193
api/package-lock.json
generated
16193
api/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "directus",
|
||||
"version": "9.0.0-alpha.20",
|
||||
"version": "9.0.0-alpha.25",
|
||||
"license": "GPL-3.0-only",
|
||||
"homepage": "https://github.com/directus/next#readme",
|
||||
"description": "Directus is a real-time API and App dashboard for managing SQL database content.",
|
||||
@@ -64,7 +64,7 @@
|
||||
"example.env"
|
||||
],
|
||||
"dependencies": {
|
||||
"@directus/app": "^9.0.0-alpha.20",
|
||||
"@directus/app": "^9.0.0-alpha.25",
|
||||
"@directus/format-title": "^3.2.0",
|
||||
"@slynova/flydrive": "^1.0.2",
|
||||
"@slynova/flydrive-gcs": "^1.0.2",
|
||||
@@ -115,8 +115,8 @@
|
||||
"mssql": "^6.2.0",
|
||||
"mysql": "^2.18.1",
|
||||
"oracledb": "^5.0.0",
|
||||
"pg": "^8.3.0",
|
||||
"sqlite3": "^5.0.0",
|
||||
"pg": "^8.3.2",
|
||||
"redis": "^3.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -164,5 +164,5 @@
|
||||
"prettier --write"
|
||||
]
|
||||
},
|
||||
"gitHead": "9092740d708c1e9ed1575b0932a01f531d224f77"
|
||||
"gitHead": "c22537b6d0dd7dc6cc651a9200a68f6b060353d4"
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import asyncHandler from 'express-async-handler';
|
||||
import FieldsService from '../services/fields';
|
||||
import validateCollection from '../middleware/collection-exists';
|
||||
import { schemaInspector } from '../database';
|
||||
import { FieldNotFoundException, InvalidPayloadException } from '../exceptions';
|
||||
import { FieldNotFoundException, InvalidPayloadException, ForbiddenException } from '../exceptions';
|
||||
import Joi from 'joi';
|
||||
import { Field } from '../types/field';
|
||||
import useCollection from '../middleware/use-collection';
|
||||
@@ -48,7 +48,7 @@ router.get(
|
||||
const service = new FieldsService({ accountability: req.accountability });
|
||||
|
||||
const exists = await schemaInspector.hasColumn(req.collection, req.params.field);
|
||||
if (exists === false) throw new FieldNotFoundException(req.collection, req.params.field);
|
||||
if (exists === false) throw new ForbiddenException();
|
||||
|
||||
const field = await service.readOne(req.params.collection, req.params.field);
|
||||
return res.json({ data: field || null });
|
||||
@@ -60,11 +60,11 @@ const newFieldSchema = Joi.object({
|
||||
field: Joi.string().required(),
|
||||
type: Joi.string().valid(...types),
|
||||
schema: Joi.object({
|
||||
comment: Joi.string(),
|
||||
comment: Joi.string().allow(null),
|
||||
default_value: Joi.any(),
|
||||
max_length: [Joi.number(), Joi.string()],
|
||||
is_nullable: Joi.bool(),
|
||||
}),
|
||||
}).unknown(),
|
||||
/** @todo base this on default validation */
|
||||
meta: Joi.any(),
|
||||
});
|
||||
@@ -105,7 +105,7 @@ router.patch(
|
||||
let results: any = [];
|
||||
|
||||
for (const field of req.body) {
|
||||
await service.updateField(req.params.collection, field, req.accountability);
|
||||
await service.updateField(req.params.collection, field);
|
||||
|
||||
const updatedField = await service.readOne(req.params.collection, field.field);
|
||||
|
||||
@@ -120,15 +120,13 @@ router.patch(
|
||||
'/:collection/:field',
|
||||
validateCollection,
|
||||
useCollection('directus_fields'),
|
||||
// @todo: validate field
|
||||
asyncHandler(async (req, res) => {
|
||||
const service = new FieldsService({ accountability: req.accountability });
|
||||
|
||||
const fieldData: Partial<Field> & { field: string; type: typeof types[number] } = req.body;
|
||||
|
||||
if (!fieldData.field) fieldData.field = req.params.field;
|
||||
|
||||
await service.updateField(req.params.collection, fieldData, req.accountability);
|
||||
await service.updateField(req.params.collection, fieldData);
|
||||
|
||||
const updatedField = await service.readOne(req.params.collection, req.params.field);
|
||||
|
||||
@@ -142,8 +140,7 @@ router.delete(
|
||||
useCollection('directus_fields'),
|
||||
asyncHandler(async (req, res) => {
|
||||
const service = new FieldsService({ accountability: req.accountability });
|
||||
|
||||
await service.deleteField(req.params.collection, req.params.field, req.accountability);
|
||||
await service.deleteField(req.params.collection, req.params.field);
|
||||
|
||||
res.status(200).end();
|
||||
})
|
||||
|
||||
@@ -79,7 +79,9 @@ router.patch(
|
||||
return res.json({ data: item || null });
|
||||
}
|
||||
|
||||
throw new RouteNotFoundException(req.path);
|
||||
const primaryKeys = await service.update(req.body);
|
||||
const result = await service.readByKey(primaryKeys, req.sanitizedQuery);
|
||||
return res.json({ data: result || null });
|
||||
})
|
||||
);
|
||||
|
||||
@@ -96,7 +98,6 @@ router.patch(
|
||||
const primaryKey = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk;
|
||||
|
||||
const updatedPrimaryKey = await service.update(req.body, primaryKey as any);
|
||||
|
||||
const result = await service.readByKey(updatedPrimaryKey, req.sanitizedQuery);
|
||||
|
||||
res.json({ data: result || null });
|
||||
|
||||
@@ -159,9 +159,9 @@ router.post('/me/tfa/enable/', asyncHandler(async (req, res) => {
|
||||
}
|
||||
|
||||
const service = new UsersService({ accountability: req.accountability });
|
||||
const url = await service.enableTFA(req.accountability.user);
|
||||
const { url, secret } = await service.enableTFA(req.accountability.user);
|
||||
|
||||
return res.json({ data: { otpauth_url: url }});
|
||||
return res.json({ data: { secret, otpauth_url: url }});
|
||||
}));
|
||||
|
||||
router.post('/me/tfa/disable', asyncHandler(async (req, res) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -22,6 +22,15 @@ tables:
|
||||
display_template:
|
||||
type: string
|
||||
length: 255
|
||||
sort_field:
|
||||
type: string
|
||||
length: 64
|
||||
soft_delete_field:
|
||||
type: string
|
||||
length: 64
|
||||
soft_delete_value:
|
||||
type: string
|
||||
length: 255
|
||||
|
||||
directus_roles:
|
||||
id:
|
||||
@@ -84,6 +93,8 @@ tables:
|
||||
type: text
|
||||
tags:
|
||||
type: json
|
||||
avatar:
|
||||
type: uuid
|
||||
timezone:
|
||||
type: string
|
||||
length: 255
|
||||
@@ -115,8 +126,6 @@ tables:
|
||||
length: 255
|
||||
last_login:
|
||||
type: timestamp
|
||||
avatar:
|
||||
type: uuid
|
||||
last_page:
|
||||
type: string
|
||||
length: 255
|
||||
@@ -307,12 +316,14 @@ tables:
|
||||
references:
|
||||
table: directus_collections
|
||||
column: collection
|
||||
operation:
|
||||
action:
|
||||
type: string
|
||||
length: 10
|
||||
nullable: false
|
||||
permissions:
|
||||
type: json
|
||||
validation:
|
||||
type: json
|
||||
presets:
|
||||
type: json
|
||||
fields:
|
||||
@@ -628,6 +639,8 @@ rows:
|
||||
- collection: directus_roles
|
||||
field: name
|
||||
interface: text-input
|
||||
options:
|
||||
placeholder: The unique name for this role...
|
||||
locked: true
|
||||
sort: 1
|
||||
width: half
|
||||
@@ -799,7 +812,7 @@ rows:
|
||||
width: half
|
||||
- collection: directus_users
|
||||
field: password
|
||||
special: hash
|
||||
special: hash, conceal
|
||||
interface: hash
|
||||
locked: true
|
||||
options:
|
||||
@@ -1234,7 +1247,9 @@ rows:
|
||||
width: half
|
||||
- collection: directus_users
|
||||
field: tfa_secret
|
||||
interface: tfa-setup
|
||||
locked: true
|
||||
special: conceal
|
||||
sort: 14
|
||||
width: half
|
||||
- collection: directus_users
|
||||
@@ -1780,15 +1795,16 @@ rows:
|
||||
defaults:
|
||||
role: null
|
||||
collection: null
|
||||
operation: null
|
||||
action: null
|
||||
permissions: null
|
||||
validation: null
|
||||
presets: null
|
||||
fields: null
|
||||
limit: null
|
||||
|
||||
data:
|
||||
- collection: directus_settings
|
||||
operation: read
|
||||
action: read
|
||||
permissions: {}
|
||||
fields: "project_name,project_logo,project_color,public_foreground,public_background,public_note"
|
||||
|
||||
@@ -1822,7 +1838,7 @@ rows:
|
||||
view_type: cards
|
||||
view_options:
|
||||
cards:
|
||||
icon: person
|
||||
icon: account_circle
|
||||
title: '{{ first_name }} {{ last_name }}'
|
||||
subtitle: '{{ title }}'
|
||||
size: 4
|
||||
|
||||
@@ -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 || {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ export * from './field-not-found';
|
||||
export * from './forbidden';
|
||||
export * from './hit-rate-limit';
|
||||
export * from './invalid-credentials';
|
||||
export * from './invalid-otp';
|
||||
export * from './invalid-payload';
|
||||
export * from './invalid-query';
|
||||
export * from './item-limit';
|
||||
|
||||
7
api/src/exceptions/invalid-otp.ts
Normal file
7
api/src/exceptions/invalid-otp.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { BaseException } from './base';
|
||||
|
||||
export class InvalidOTPException extends BaseException {
|
||||
constructor(message = 'Invalid user OTP.') {
|
||||
super(message, 401, 'INVALID_OTP');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -3,7 +3,7 @@ import jwt from 'jsonwebtoken';
|
||||
import argon2 from 'argon2';
|
||||
import { nanoid } from 'nanoid';
|
||||
import ms from 'ms';
|
||||
import { InvalidCredentialsException, InvalidPayloadException } from '../exceptions';
|
||||
import { InvalidCredentialsException, InvalidPayloadException, InvalidOTPException } from '../exceptions';
|
||||
import { Session, Accountability, AbstractServiceOptions, Action } from '../types';
|
||||
import Knex from 'knex';
|
||||
import ActivityService from '../services/activity';
|
||||
@@ -51,14 +51,14 @@ export default class AuthenticationService {
|
||||
}
|
||||
|
||||
if (user.tfa_secret && !otp) {
|
||||
throw new InvalidPayloadException(`"otp" is required`);
|
||||
throw new InvalidOTPException(`"otp" is required`);
|
||||
}
|
||||
|
||||
if (user.tfa_secret && otp) {
|
||||
const otpValid = await this.verifyOTP(user.id, otp);
|
||||
|
||||
if (otpValid === false) {
|
||||
throw new InvalidPayloadException(`"otp" is invalid`);
|
||||
throw new InvalidOTPException(`"otp" is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,29 +111,31 @@ export default class AuthenticationService {
|
||||
}
|
||||
|
||||
const record = await database
|
||||
.select<Session & { email: string }>('directus_sessions.*', 'directus_users.email')
|
||||
.select<Session & { email: string, id: string }>('directus_sessions.*', 'directus_users.email', 'directus_users.id')
|
||||
.from('directus_sessions')
|
||||
.where({ 'directus_sessions.token': refreshToken })
|
||||
.leftJoin('directus_users', 'directus_sessions.user', 'directus_users.id')
|
||||
.first();
|
||||
|
||||
/** @todo
|
||||
* Check if it's worth checking for ip address and/or user agent. We could make this a little
|
||||
* more secure by requiring the refresh token to be used from the same device / location as the
|
||||
* auth session was created in the first place
|
||||
*/
|
||||
|
||||
if (!record || !record.email || record.expires < new Date()) {
|
||||
throw new InvalidCredentialsException();
|
||||
}
|
||||
|
||||
await this.knex.delete().from('directus_sessions').where({ token: refreshToken });
|
||||
|
||||
return await this.authenticate({
|
||||
email: record.email,
|
||||
ip: record.ip,
|
||||
userAgent: record.user_agent,
|
||||
const accessToken = jwt.sign({ id: record.id }, env.SECRET as string, {
|
||||
expiresIn: env.ACCESS_TOKEN_TTL,
|
||||
});
|
||||
|
||||
const newRefreshToken = nanoid(64);
|
||||
const refreshTokenExpiration = new Date(Date.now() + ms(env.REFRESH_TOKEN_TTL as string));
|
||||
|
||||
await this.knex('directus_sessions').update({ token: newRefreshToken, expires: refreshTokenExpiration }).where({ token: refreshToken });
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken: newRefreshToken,
|
||||
expires: ms(env.ACCESS_TOKEN_TTL as string) / 1000,
|
||||
id: record.id,
|
||||
};
|
||||
}
|
||||
|
||||
async logout(refreshToken: string) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
FieldAST,
|
||||
Query,
|
||||
Permission,
|
||||
Operation,
|
||||
PermissionsAction,
|
||||
Item,
|
||||
PrimaryKey,
|
||||
} from '../types';
|
||||
@@ -26,13 +26,13 @@ export default class AuthorizationService {
|
||||
this.accountability = options?.accountability || null;
|
||||
}
|
||||
|
||||
async processAST(ast: AST, operation: Operation = 'read'): Promise<AST> {
|
||||
async processAST(ast: AST, action: PermissionsAction = 'read'): Promise<AST> {
|
||||
const collectionsRequested = getCollectionsFromAST(ast);
|
||||
|
||||
const permissionsForCollections = await this.knex
|
||||
.select<Permission[]>('*')
|
||||
.from('directus_permissions')
|
||||
.where({ operation, role: this.accountability?.role })
|
||||
.where({ action, role: this.accountability?.role })
|
||||
.whereIn(
|
||||
'collection',
|
||||
collectionsRequested.map(({ collection }) => collection)
|
||||
@@ -165,18 +165,18 @@ export default class AuthorizationService {
|
||||
/**
|
||||
* Checks if the provided payload matches the configured permissions, and adds the presets to the payload.
|
||||
*/
|
||||
processValues(
|
||||
operation: Operation,
|
||||
validatePayload(
|
||||
action: PermissionsAction,
|
||||
collection: string,
|
||||
payloads: Partial<Item>[]
|
||||
): Promise<Partial<Item>[]>;
|
||||
processValues(
|
||||
operation: Operation,
|
||||
validatePayload(
|
||||
action: PermissionsAction,
|
||||
collection: string,
|
||||
payload: Partial<Item>
|
||||
): Promise<Partial<Item>>;
|
||||
async processValues(
|
||||
operation: Operation,
|
||||
async validatePayload(
|
||||
action: PermissionsAction,
|
||||
collection: string,
|
||||
payload: Partial<Item>[] | Partial<Item>
|
||||
): Promise<Partial<Item>[] | Partial<Item>> {
|
||||
@@ -185,7 +185,7 @@ export default class AuthorizationService {
|
||||
const permission = await this.knex
|
||||
.select<Permission>('*')
|
||||
.from('directus_permissions')
|
||||
.where({ operation, collection, role: this.accountability?.role || null })
|
||||
.where({ action, collection, role: this.accountability?.role || null })
|
||||
.first();
|
||||
|
||||
if (!permission) throw new ForbiddenException();
|
||||
@@ -200,7 +200,9 @@ export default class AuthorizationService {
|
||||
);
|
||||
|
||||
if (invalidKeys.length > 0) {
|
||||
throw new InvalidPayloadException(`Field "${invalidKeys[0]}" doesn't exist.`);
|
||||
throw new ForbiddenException(
|
||||
`You're not allowed to ${action} field "${invalidKeys[0]}" in collection "${collection}".`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -209,7 +211,7 @@ export default class AuthorizationService {
|
||||
|
||||
payloads = payloads.map((payload) => merge({}, preset, payload));
|
||||
|
||||
const schema = generateJoi(permission.permissions);
|
||||
const schema = generateJoi(permission.validation);
|
||||
|
||||
for (const payload of payloads) {
|
||||
const { error } = schema.validate(payload);
|
||||
@@ -226,7 +228,7 @@ export default class AuthorizationService {
|
||||
}
|
||||
}
|
||||
|
||||
async checkAccess(operation: Operation, collection: string, pk: PrimaryKey | PrimaryKey[]) {
|
||||
async checkAccess(action: PermissionsAction, collection: string, pk: PrimaryKey | PrimaryKey[]) {
|
||||
const itemsService = new ItemsService(collection, { accountability: this.accountability });
|
||||
|
||||
try {
|
||||
@@ -234,12 +236,15 @@ export default class AuthorizationService {
|
||||
fields: ['*'],
|
||||
};
|
||||
|
||||
const result = await itemsService.readByKey(pk as any, query, operation);
|
||||
const result = await itemsService.readByKey(pk as any, query, action);
|
||||
|
||||
if (!result) throw '';
|
||||
if (Array.isArray(pk) && result.length !== pk.length) throw '';
|
||||
} catch {
|
||||
throw new ForbiddenException(
|
||||
`You're not allowed to ${operation} item "${pk}" in collection "${collection}".`
|
||||
`You're not allowed to ${action} item "${pk}" in collection "${collection}".`, {
|
||||
collection, item: pk, action
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import database, { schemaInspector } from '../database';
|
||||
import { AbstractServiceOptions, Accountability, Collection } from '../types';
|
||||
import { AbstractServiceOptions, Accountability, Collection, Relation } from '../types';
|
||||
import Knex from 'knex';
|
||||
import { ForbiddenException, InvalidPayloadException } from '../exceptions';
|
||||
import SchemaInspector from 'knex-schema-inspector';
|
||||
@@ -101,7 +101,7 @@ export default class CollectionsService {
|
||||
const permissions = await this.knex
|
||||
.select('collection')
|
||||
.from('directus_permissions')
|
||||
.where({ operation: 'read' })
|
||||
.where({ action: 'read' })
|
||||
.where({ role: this.accountability.role })
|
||||
.whereIn('collection', collectionKeys);
|
||||
|
||||
@@ -150,7 +150,7 @@ export default class CollectionsService {
|
||||
const collectionsYouHavePermissionToRead: string[] = (
|
||||
await this.knex.select('collection').from('directus_permissions').where({
|
||||
role: this.accountability.role,
|
||||
operation: 'read',
|
||||
action: 'read',
|
||||
})
|
||||
).map(({ collection }) => collection);
|
||||
|
||||
@@ -228,6 +228,8 @@ export default class CollectionsService {
|
||||
throw new ForbiddenException('Only admins can perform this action.');
|
||||
}
|
||||
|
||||
const fieldsService = new FieldsService({ knex: this.knex, accountability: this.accountability });
|
||||
|
||||
const tablesInDatabase = await schemaInspector.tables();
|
||||
|
||||
const collectionKeys = Array.isArray(collection) ? collection : [collection];
|
||||
@@ -242,11 +244,25 @@ export default class CollectionsService {
|
||||
await this.knex('directus_presets').delete().whereIn('collection', collectionKeys);
|
||||
await this.knex('directus_revisions').delete().whereIn('collection', collectionKeys);
|
||||
await this.knex('directus_activity').delete().whereIn('collection', collectionKeys);
|
||||
await this.knex('directus_permissions').delete().whereIn('collection', collectionKeys);
|
||||
|
||||
await this.knex('directus_relations')
|
||||
.delete()
|
||||
.whereIn('many_collection', collectionKeys)
|
||||
.orWhereIn('one_collection', collectionKeys);
|
||||
const relations = await this.knex
|
||||
.select<Relation[]>('*')
|
||||
.from('directus_relations')
|
||||
.where({ many_collection: collection })
|
||||
.orWhere({ one_collection: collection });
|
||||
|
||||
for (const relation of relations) {
|
||||
const isM2O = relation.many_collection === collection;
|
||||
|
||||
if (isM2O) {
|
||||
await this.knex('directus_relations').delete().where({ many_collection: collection, many_field: relation.many_field });
|
||||
await fieldsService.deleteField(relation.one_collection, relation.one_field);
|
||||
} else {
|
||||
await this.knex('directus_relations').update({ one_field: null }).where({ one_collection: collection, field: relation.one_field });
|
||||
await fieldsService.deleteField(relation.many_collection, relation.many_field);
|
||||
}
|
||||
}
|
||||
|
||||
const collectionItemsService = new ItemsService('directus_collections', {
|
||||
knex: this.knex,
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import database, { schemaInspector } from '../database';
|
||||
import { Field } from '../types/field';
|
||||
import { uniq } from 'lodash';
|
||||
import { Accountability, AbstractServiceOptions, FieldMeta } from '../types';
|
||||
import { Accountability, AbstractServiceOptions, FieldMeta, Relation } from '../types';
|
||||
import ItemsService from '../services/items';
|
||||
import { ColumnBuilder } from 'knex';
|
||||
import getLocalType from '../utils/get-local-type';
|
||||
import { types } from '../types';
|
||||
import { FieldNotFoundException } from '../exceptions';
|
||||
import { FieldNotFoundException, ForbiddenException } from '../exceptions';
|
||||
import Knex, { CreateTableBuilder } from 'knex';
|
||||
import PayloadService from '../services/payload';
|
||||
import getDefaultValue from '../utils/get-default-value';
|
||||
@@ -34,24 +33,16 @@ export default class FieldsService {
|
||||
this.payloadService = new PayloadService('directus_fields');
|
||||
}
|
||||
|
||||
async fieldsInCollection(collection: string) {
|
||||
const [fields, columns] = await Promise.all([
|
||||
this.itemsService.readByQuery({ filter: { collection: { _eq: collection } } }),
|
||||
schemaInspector.columns(collection),
|
||||
]);
|
||||
|
||||
return uniq([...fields.map(({ field }) => field), ...columns.map(({ column }) => column)]);
|
||||
}
|
||||
|
||||
async readAll(collection?: string) {
|
||||
let fields: FieldMeta[];
|
||||
const nonAuthorizedItemsService = new ItemsService('directus_fields', { knex: this.knex });
|
||||
|
||||
if (collection) {
|
||||
fields = (await this.itemsService.readByQuery({
|
||||
fields = (await nonAuthorizedItemsService.readByQuery({
|
||||
filter: { collection: { _eq: collection } },
|
||||
})) as FieldMeta[];
|
||||
} else {
|
||||
fields = (await this.itemsService.readByQuery({})) as FieldMeta[];
|
||||
fields = (await nonAuthorizedItemsService.readByQuery({})) as FieldMeta[];
|
||||
}
|
||||
|
||||
fields = (await this.payloadService.processValues('read', fields)) as FieldMeta[];
|
||||
@@ -106,11 +97,50 @@ export default class FieldsService {
|
||||
return data;
|
||||
});
|
||||
|
||||
return [...columnsWithSystem, ...aliasFieldsAsField];
|
||||
const result = [...columnsWithSystem, ...aliasFieldsAsField];
|
||||
|
||||
// Filter the result so we only return the fields you have read access to
|
||||
if (this.accountability && this.accountability.admin !== true) {
|
||||
const permissions = await this.knex.select('collection', 'fields').from('directus_permissions').where({ role: this.accountability.role, action: 'read' });
|
||||
const allowedFieldsInCollection: Record<string, string[]> = {};
|
||||
|
||||
permissions.forEach((permission) => {
|
||||
allowedFieldsInCollection[permission.collection] = (permission.fields || '').split(',');
|
||||
});
|
||||
|
||||
if (collection && allowedFieldsInCollection.hasOwnProperty(collection) === false) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return result.filter((field) => {
|
||||
if (allowedFieldsInCollection.hasOwnProperty(field.collection) === false) return false;
|
||||
const allowedFields = allowedFieldsInCollection[field.collection];
|
||||
if (allowedFields[0] === '*') return true;
|
||||
return allowedFields.includes(field.field);
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** @todo add accountability */
|
||||
async readOne(collection: string, field: string) {
|
||||
if (this.accountability && this.accountability.admin !== true) {
|
||||
const permissions = await this.knex
|
||||
.select('fields')
|
||||
.from('directus_permissions')
|
||||
.where({
|
||||
role: this.accountability.role,
|
||||
collection,
|
||||
action: 'read'
|
||||
}).first();
|
||||
|
||||
if (!permissions) throw new ForbiddenException();
|
||||
if (permissions.fields !== '*') {
|
||||
const allowedFields = (permissions.fields || '').split(',');
|
||||
if (allowedFields.includes(field) === false) throw new ForbiddenException();
|
||||
}
|
||||
}
|
||||
|
||||
let column;
|
||||
let fieldInfo = await this.knex
|
||||
.select('*')
|
||||
@@ -141,6 +171,10 @@ export default class FieldsService {
|
||||
field: Partial<Field> & { field: string; type: typeof types[number] },
|
||||
table?: CreateTableBuilder // allows collection creation to
|
||||
) {
|
||||
if (this.accountability && this.accountability.admin !== true) {
|
||||
throw new ForbiddenException('Only admins can perform this action.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @todo
|
||||
* Check if table / directus_fields row already exists
|
||||
@@ -167,7 +201,11 @@ export default class FieldsService {
|
||||
|
||||
/** @todo research how to make this happen in SQLite / Redshift */
|
||||
|
||||
async updateField(collection: string, field: RawField, accountability?: Accountability) {
|
||||
async updateField(collection: string, field: RawField) {
|
||||
if (this.accountability && this.accountability.admin !== true) {
|
||||
throw new ForbiddenException('Only admins can perform this action.');
|
||||
}
|
||||
|
||||
if (field.schema) {
|
||||
await this.knex.schema.alterTable(collection, (table) => {
|
||||
let column: ColumnBuilder;
|
||||
@@ -208,21 +246,47 @@ export default class FieldsService {
|
||||
.where({ collection, field: field.field })
|
||||
.first();
|
||||
if (!record) throw new FieldNotFoundException(collection, field.field);
|
||||
await database('directus_fields')
|
||||
.update(field.meta)
|
||||
.where({ collection, field: field.field });
|
||||
|
||||
await this.itemsService.update({
|
||||
...field.meta,
|
||||
collection: collection,
|
||||
field: field.field,
|
||||
}, record.id);
|
||||
}
|
||||
|
||||
return field.field;
|
||||
}
|
||||
|
||||
/** @todo save accountability */
|
||||
async deleteField(collection: string, field: string, accountability?: Accountability) {
|
||||
await database('directus_fields').delete().where({ collection, field });
|
||||
async deleteField(collection: string, field: string) {
|
||||
if (this.accountability && this.accountability.admin !== true) {
|
||||
throw new ForbiddenException('Only admins can perform this action.');
|
||||
}
|
||||
|
||||
await database.schema.table(collection, (table) => {
|
||||
table.dropColumn(field);
|
||||
});
|
||||
await this.knex('directus_fields').delete().where({ collection, field });
|
||||
|
||||
if (await schemaInspector.hasColumn(collection, field)) {
|
||||
await this.knex.schema.table(collection, (table) => {
|
||||
table.dropColumn(field);
|
||||
});
|
||||
}
|
||||
|
||||
const relations = await this.knex
|
||||
.select<Relation[]>('*')
|
||||
.from('directus_relations')
|
||||
.where({ many_collection: collection, many_field: field })
|
||||
.orWhere({ one_collection: collection, one_field: field });
|
||||
|
||||
for (const relation of relations) {
|
||||
const isM2O = relation.many_collection === collection && relation.many_field === field;
|
||||
|
||||
if (isM2O) {
|
||||
await this.knex('directus_relations').delete().where({ many_collection: collection, many_field: field });
|
||||
await this.deleteField(relation.one_collection, relation.one_field);
|
||||
} else {
|
||||
await this.knex('directus_relations').update({ one_field: null }).where({ one_collection: collection, one_field: field });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public addColumnToTable(table: CreateTableBuilder, field: Field) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import getASTFromQuery from '../utils/get-ast-from-query';
|
||||
import {
|
||||
Action,
|
||||
Accountability,
|
||||
Operation,
|
||||
PermissionsAction,
|
||||
Item,
|
||||
Query,
|
||||
PrimaryKey,
|
||||
@@ -69,7 +69,7 @@ export default class ItemsService implements AbstractService {
|
||||
);
|
||||
|
||||
if (this.accountability && this.accountability.admin !== true) {
|
||||
payloads = await authorizationService.processValues(
|
||||
payloads = await authorizationService.validatePayload(
|
||||
'create',
|
||||
this.collection,
|
||||
payloads
|
||||
@@ -165,12 +165,12 @@ export default class ItemsService implements AbstractService {
|
||||
return records;
|
||||
}
|
||||
|
||||
readByKey(keys: PrimaryKey[], query?: Query, operation?: Operation): Promise<Item[]>;
|
||||
readByKey(key: PrimaryKey, query?: Query, operation?: Operation): Promise<Item>;
|
||||
readByKey(keys: PrimaryKey[], query?: Query, action?: PermissionsAction): Promise<Item[]>;
|
||||
readByKey(key: PrimaryKey, query?: Query, action?: PermissionsAction): Promise<Item>;
|
||||
async readByKey(
|
||||
key: PrimaryKey | PrimaryKey[],
|
||||
query: Query = {},
|
||||
operation: Operation = 'read'
|
||||
action: PermissionsAction = 'read'
|
||||
): Promise<Item | Item[]> {
|
||||
const schemaInspector = SchemaInspector(this.knex);
|
||||
const primaryKeyField = await schemaInspector.primary(this.collection);
|
||||
@@ -190,14 +190,14 @@ export default class ItemsService implements AbstractService {
|
||||
this.collection,
|
||||
queryWithFilter,
|
||||
this.accountability,
|
||||
operation
|
||||
action
|
||||
);
|
||||
|
||||
if (this.accountability && this.accountability.admin !== true) {
|
||||
const authorizationService = new AuthorizationService({
|
||||
accountability: this.accountability,
|
||||
});
|
||||
ast = await authorizationService.processAST(ast, operation);
|
||||
ast = await authorizationService.processAST(ast, action);
|
||||
}
|
||||
|
||||
const records = await runAST(ast);
|
||||
@@ -226,8 +226,8 @@ export default class ItemsService implements AbstractService {
|
||||
accountability: this.accountability,
|
||||
});
|
||||
await authorizationService.checkAccess('update', this.collection, keys);
|
||||
payload = await authorizationService.processValues(
|
||||
'validate',
|
||||
payload = await authorizationService.validatePayload(
|
||||
'update',
|
||||
this.collection,
|
||||
payload
|
||||
);
|
||||
@@ -331,7 +331,7 @@ export default class ItemsService implements AbstractService {
|
||||
const schemaInspector = SchemaInspector(this.knex);
|
||||
const primaryKeyField = await schemaInspector.primary(this.collection);
|
||||
|
||||
if (this.accountability && this.accountability.admin !== false) {
|
||||
if (this.accountability && this.accountability.admin !== true) {
|
||||
const authorizationService = new AuthorizationService({
|
||||
accountability: this.accountability,
|
||||
});
|
||||
|
||||
@@ -14,10 +14,10 @@ import { URL } from 'url';
|
||||
import Knex from 'knex';
|
||||
import env from '../env';
|
||||
|
||||
type Operation = 'create' | 'read' | 'update';
|
||||
type Action = 'create' | 'read' | 'update';
|
||||
|
||||
type Transformers = {
|
||||
[type: string]: (operation: Operation, value: any, payload: Partial<Item>) => Promise<any>;
|
||||
[type: string]: (action: Action, value: any, payload: Partial<Item>) => Promise<any>;
|
||||
};
|
||||
|
||||
export default class PayloadService {
|
||||
@@ -41,24 +41,24 @@ export default class PayloadService {
|
||||
* in order to work
|
||||
*/
|
||||
public transformers: Transformers = {
|
||||
async hash(operation, value) {
|
||||
async hash(action, value) {
|
||||
if (!value) return;
|
||||
|
||||
if (operation === 'create' || operation === 'update') {
|
||||
if (action === 'create' || action === 'update') {
|
||||
return await argon2.hash(String(value));
|
||||
}
|
||||
|
||||
return value;
|
||||
},
|
||||
async uuid(operation, value) {
|
||||
if (operation === 'create' && !value) {
|
||||
async uuid(action, value) {
|
||||
if (action === 'create' && !value) {
|
||||
return uuidv4();
|
||||
}
|
||||
|
||||
return value;
|
||||
},
|
||||
async 'file-links'(operation, value, payload) {
|
||||
if (operation === 'read' && payload && payload.storage && payload.filename_disk) {
|
||||
async 'file-links'(action, value, payload) {
|
||||
if (action === 'read' && payload && payload.storage && payload.filename_disk) {
|
||||
const publicKey = `STORAGE_${payload.storage.toUpperCase()}_PUBLIC_URL`;
|
||||
|
||||
return {
|
||||
@@ -70,15 +70,15 @@ export default class PayloadService {
|
||||
// This is an non-existing column, so there isn't any data to save
|
||||
return undefined;
|
||||
},
|
||||
async boolean(operation, value) {
|
||||
if (operation === 'read') {
|
||||
async boolean(action, value) {
|
||||
if (action === 'read') {
|
||||
return value === true || value === 1 || value === '1';
|
||||
}
|
||||
|
||||
return value;
|
||||
},
|
||||
async json(operation, value) {
|
||||
if (operation === 'read') {
|
||||
async json(action, value) {
|
||||
if (action === 'read') {
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
@@ -87,13 +87,19 @@ export default class PayloadService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
},
|
||||
async conceal(action, value) {
|
||||
if (action === 'read') return '**********';
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
processValues(operation: Operation, payloads: Partial<Item>[]): Promise<Partial<Item>[]>;
|
||||
processValues(operation: Operation, payload: Partial<Item>): Promise<Partial<Item>>;
|
||||
processValues(action: Action, payloads: Partial<Item>[]): Promise<Partial<Item>[]>;
|
||||
processValues(action: Action, payload: Partial<Item>): Promise<Partial<Item>>;
|
||||
async processValues(
|
||||
operation: Operation,
|
||||
action: Action,
|
||||
payload: Partial<Item> | Partial<Item>[]
|
||||
): Promise<Partial<Item> | Partial<Item>[]> {
|
||||
const processedPayload = (Array.isArray(payload) ? payload : [payload]) as Partial<Item>[];
|
||||
@@ -108,7 +114,7 @@ export default class PayloadService {
|
||||
.where({ collection: this.collection })
|
||||
.whereNotNull('special');
|
||||
|
||||
if (operation === 'read') {
|
||||
if (action === 'read') {
|
||||
specialFieldsQuery.whereIn('field', fieldsInPayload);
|
||||
}
|
||||
|
||||
@@ -118,14 +124,14 @@ export default class PayloadService {
|
||||
processedPayload.map(async (record: any) => {
|
||||
await Promise.all(
|
||||
specialFieldsInCollection.map(async (field) => {
|
||||
const newValue = await this.processField(field, record, operation);
|
||||
const newValue = await this.processField(field, record, action);
|
||||
if (newValue !== undefined) record[field.field] = newValue;
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
if (['create', 'update'].includes(operation)) {
|
||||
if (['create', 'update'].includes(action)) {
|
||||
processedPayload.forEach((record) => {
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
if (Array.isArray(value) || (typeof value === 'object' && value !== null)) {
|
||||
@@ -145,15 +151,22 @@ export default class PayloadService {
|
||||
async processField(
|
||||
field: Pick<FieldMeta, 'field' | 'special'>,
|
||||
payload: Partial<Item>,
|
||||
operation: Operation
|
||||
action: Action
|
||||
) {
|
||||
if (!field.special) return payload[field.field];
|
||||
|
||||
if (this.transformers.hasOwnProperty(field.special)) {
|
||||
return await this.transformers[field.special](operation, payload[field.field], payload);
|
||||
const fieldSpecials = field.special.split(',').map(s => s.trim());
|
||||
|
||||
|
||||
let value = clone(payload[field.field]);
|
||||
|
||||
for (const special of fieldSpecials) {
|
||||
if (this.transformers.hasOwnProperty(special)) {
|
||||
value = await this.transformers[special](action, value, payload);
|
||||
}
|
||||
}
|
||||
|
||||
return payload[field.field];
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -267,3 +280,4 @@ export default class PayloadService {
|
||||
}
|
||||
}
|
||||
}
|
||||
0
|
||||
|
||||
@@ -5,7 +5,7 @@ import { sendInviteMail } from '../mail';
|
||||
import database from '../database';
|
||||
import argon2 from 'argon2';
|
||||
import { InvalidPayloadException } from '../exceptions';
|
||||
import { Accountability, Query, Item, AbstractServiceOptions } from '../types';
|
||||
import { Accountability, PrimaryKey, Item, AbstractServiceOptions } from '../types';
|
||||
import Knex from 'knex';
|
||||
import env from '../env';
|
||||
|
||||
@@ -22,6 +22,29 @@ export default class UsersService extends ItemsService {
|
||||
this.service = new ItemsService('directus_users', options);
|
||||
}
|
||||
|
||||
update(data: Partial<Item>, keys: PrimaryKey[]): Promise<PrimaryKey[]>;
|
||||
update(data: Partial<Item>, key: PrimaryKey): Promise<PrimaryKey>;
|
||||
update(data: Partial<Item>[]): Promise<PrimaryKey[]>;
|
||||
async update(
|
||||
data: Partial<Item> | Partial<Item>[],
|
||||
key?: PrimaryKey | PrimaryKey[]
|
||||
): Promise<PrimaryKey | PrimaryKey[]> {
|
||||
/**
|
||||
* @NOTE
|
||||
* This is just an extra bit of hardcoded security. We don't want anybody to be able to disable 2fa through
|
||||
* the regular /users endpoint. Period. You should only be able to manage the 2fa status through the /tfa endpoint.
|
||||
*/
|
||||
const payloads = Array.isArray(data) ? data : [data];
|
||||
|
||||
for (const payload of payloads) {
|
||||
if (payload.hasOwnProperty('tfa_secret')) {
|
||||
throw new InvalidPayloadException(`You can't change the tfa_secret manually.`);
|
||||
}
|
||||
}
|
||||
|
||||
return this.service.update(data, key as any);
|
||||
}
|
||||
|
||||
async inviteUser(email: string, role: string) {
|
||||
await this.service.create({ email, role, status: 'invited' });
|
||||
|
||||
@@ -64,7 +87,10 @@ export default class UsersService extends ItemsService {
|
||||
|
||||
await this.knex('directus_users').update({ tfa_secret: secret }).where({ id: pk });
|
||||
|
||||
return await authService.generateOTPAuthURL(pk, secret);
|
||||
return {
|
||||
secret,
|
||||
url: await authService.generateOTPAuthURL(pk, secret),
|
||||
};
|
||||
}
|
||||
|
||||
async disableTFA(pk: string) {
|
||||
|
||||
106
api/src/services/utils.ts
Normal file
106
api/src/services/utils.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { AbstractServiceOptions, Accountability, PrimaryKey } from "../types";
|
||||
import database from '../database';
|
||||
import Knex from 'knex';
|
||||
import { InvalidPayloadException, ForbiddenException } from '../exceptions';
|
||||
import SchemaInspector from 'knex-schema-inspector';
|
||||
|
||||
export default class UtilsService {
|
||||
knex: Knex;
|
||||
accountability: Accountability | null;
|
||||
|
||||
constructor(options?: AbstractServiceOptions) {
|
||||
this.knex = options?.knex || database;
|
||||
this.accountability = options?.accountability || null;
|
||||
}
|
||||
|
||||
async sort(collection: string, { item, to }: { item: PrimaryKey, to: PrimaryKey }) {
|
||||
const schemaInspector = SchemaInspector(this.knex);
|
||||
|
||||
const sortFieldResponse = await this.knex
|
||||
.select('sort_field')
|
||||
.from('directus_collections')
|
||||
.where({ collection })
|
||||
.first();
|
||||
|
||||
const sortField = sortFieldResponse?.sort_field;
|
||||
|
||||
if (!sortField) {
|
||||
throw new InvalidPayloadException(`Collection "${collection}" doesn't have a sort field.`);
|
||||
}
|
||||
|
||||
if (this.accountability?.admin !== true) {
|
||||
const permissions = await this.knex
|
||||
.select('fields')
|
||||
.from('directus_permissions')
|
||||
.where({
|
||||
collection,
|
||||
action: 'update',
|
||||
role: this.accountability?.role || null,
|
||||
})
|
||||
.first();
|
||||
|
||||
if (!permissions) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
const allowedFields = permissions.fields.split(',');
|
||||
|
||||
if (allowedFields[0] !== '*' && allowedFields.includes(sortField) === false) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
}
|
||||
|
||||
const primaryKeyField = await schemaInspector.primary(collection);
|
||||
|
||||
// Make sure all rows have a sort value
|
||||
const countResponse = await this.knex
|
||||
.count('* as count')
|
||||
.from(collection)
|
||||
.whereNull(sortField)
|
||||
.first();
|
||||
|
||||
if (countResponse?.count && +countResponse.count !== 0) {
|
||||
const lastSortValueResponse = await this.knex
|
||||
.max(sortField)
|
||||
.from(collection)
|
||||
.first();
|
||||
|
||||
const rowsWithoutSortValue = await this.knex
|
||||
.select(primaryKeyField, sortField)
|
||||
.from(collection)
|
||||
.whereNull(sortField);
|
||||
|
||||
let lastSortValue = lastSortValueResponse ? Object.values(lastSortValueResponse)[0] : 0;
|
||||
|
||||
for (const row of rowsWithoutSortValue) {
|
||||
lastSortValue++;
|
||||
await this.knex(collection)
|
||||
.update({ [sortField]: lastSortValue })
|
||||
.where({ [primaryKeyField]: row[primaryKeyField] });
|
||||
}
|
||||
}
|
||||
|
||||
const targetSortValueResponse = await this.knex.select(sortField).from(collection).where({ [primaryKeyField]: to }).first();
|
||||
const targetSortValue = targetSortValueResponse[sortField];
|
||||
|
||||
const sourceSortValueResponse = await this.knex.select(sortField).from(collection).where({ [primaryKeyField]: item }).first();
|
||||
const sourceSortValue = sourceSortValueResponse[sortField];
|
||||
|
||||
// Set the target item to the new sort value
|
||||
await this.knex(collection).update({ [sortField]: targetSortValue }).where({ [primaryKeyField]: item });
|
||||
|
||||
if (sourceSortValue < targetSortValue) {
|
||||
await this.knex(collection)
|
||||
.decrement(sortField, 1)
|
||||
.where(sortField, '>', sourceSortValue)
|
||||
.andWhere(sortField, '<=', targetSortValue)
|
||||
.andWhereNot({ [primaryKeyField]: item });
|
||||
} else {
|
||||
await this.knex(collection)
|
||||
.increment(sortField, 1)
|
||||
.where(sortField, '>=', targetSortValue)
|
||||
.andWhere(sortField, '<=', sourceSortValue)
|
||||
.andWhereNot({ [primaryKeyField]: item });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
export type Operation =
|
||||
export type PermissionsAction =
|
||||
| 'create'
|
||||
| 'read'
|
||||
| 'update'
|
||||
| 'validate'
|
||||
| 'delete'
|
||||
| 'comment'
|
||||
| 'explain';
|
||||
@@ -11,8 +10,9 @@ export type Permission = {
|
||||
id: number;
|
||||
role: string | null;
|
||||
collection: string;
|
||||
operation: Operation;
|
||||
action: PermissionsAction;
|
||||
permissions: Record<string, any>;
|
||||
validation: Record<string, any>;
|
||||
limit: number | null;
|
||||
presets: Record<string, any> | null;
|
||||
fields: string | null;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
FieldAST,
|
||||
Query,
|
||||
Relation,
|
||||
Operation,
|
||||
PermissionsAction,
|
||||
Accountability,
|
||||
} from '../types';
|
||||
import database from '../database';
|
||||
@@ -17,7 +17,7 @@ export default async function getASTFromQuery(
|
||||
collection: string,
|
||||
query: Query,
|
||||
accountability?: Accountability | null,
|
||||
operation?: Operation
|
||||
action?: PermissionsAction
|
||||
): Promise<AST> {
|
||||
/**
|
||||
* we might not need al this info at all times, but it's easier to fetch it all once, than trying to fetch it for every
|
||||
@@ -30,7 +30,7 @@ export default async function getASTFromQuery(
|
||||
? await database
|
||||
.select<{ collection: string; fields: string }[]>('collection', 'fields')
|
||||
.from('directus_permissions')
|
||||
.where({ role: accountability.role, operation: operation || 'read' })
|
||||
.where({ role: accountability.role, action: action || 'read' })
|
||||
: null;
|
||||
|
||||
const ast: AST = {
|
||||
|
||||
190
app/package-lock.json
generated
190
app/package-lock.json
generated
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@directus/app",
|
||||
"version": "9.0.0-alpha.20",
|
||||
"version": "9.0.0-alpha.25",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
@@ -1274,6 +1274,11 @@
|
||||
"integrity": "sha512-1dVNHT76Uu5N3eJNTYcvxee+jzX4Z9lfciqRRHCU27ihbUcYi+iSc2iml5Ke1LXe1SyJCLA0+14Jh4tXJgOppA==",
|
||||
"dev": true
|
||||
},
|
||||
"@hapi/formula": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-2.0.0.tgz",
|
||||
"integrity": "sha512-V87P8fv7PI0LH7LiVi8Lkf3x+KCO7pQozXRssAHNXXL9L1K+uyu4XypLXwxqVDKgyQai6qj3/KteNlrqDx4W5A=="
|
||||
},
|
||||
"@hapi/hoek": {
|
||||
"version": "8.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-8.5.1.tgz",
|
||||
@@ -1292,6 +1297,11 @@
|
||||
"@hapi/topo": "3.x.x"
|
||||
}
|
||||
},
|
||||
"@hapi/pinpoint": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/pinpoint/-/pinpoint-2.0.0.tgz",
|
||||
"integrity": "sha512-vzXR5MY7n4XeIvLpfl3HtE3coZYO4raKXW766R6DZw/6aLqR26iuZ109K7a0NtF2Db0jxqh7xz2AxkUwpUFybw=="
|
||||
},
|
||||
"@hapi/topo": {
|
||||
"version": "3.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-3.1.6.tgz",
|
||||
@@ -2730,6 +2740,15 @@
|
||||
"integrity": "sha512-1HcDas8SEj4z1Wc696tH56G8OlRaH/sqZOynNNB+HF0WOeXPaxTtbYzJY2oEfiUxjSKjhCKr+MvR7dCHcEelug==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/qrcode": {
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.3.5.tgz",
|
||||
"integrity": "sha512-92QMnMb9m0ErBU20za5Eqtf4lzUcSkk5w/Cz30q5qod0lWHm2loztmFs2EnCY06yT51GY1+m/oFq2D8qVK2Bjg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"@types/reach__router": {
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/reach__router/-/reach__router-1.3.5.tgz",
|
||||
@@ -5966,8 +5985,7 @@
|
||||
"base64-js": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz",
|
||||
"integrity": "sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g==",
|
||||
"dev": true
|
||||
"integrity": "sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g=="
|
||||
},
|
||||
"batch": {
|
||||
"version": "0.6.1",
|
||||
@@ -6371,11 +6389,29 @@
|
||||
"isarray": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"buffer-alloc": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz",
|
||||
"integrity": "sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==",
|
||||
"requires": {
|
||||
"buffer-alloc-unsafe": "^1.1.0",
|
||||
"buffer-fill": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"buffer-alloc-unsafe": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz",
|
||||
"integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg=="
|
||||
},
|
||||
"buffer-fill": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz",
|
||||
"integrity": "sha1-+PeLdniYiO858gXNY39o5wISKyw="
|
||||
},
|
||||
"buffer-from": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz",
|
||||
"integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==",
|
||||
"dev": true
|
||||
"integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A=="
|
||||
},
|
||||
"buffer-indexof": {
|
||||
"version": "1.1.1",
|
||||
@@ -6582,8 +6618,7 @@
|
||||
"camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"dev": true
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="
|
||||
},
|
||||
"camelcase-keys": {
|
||||
"version": "6.2.2",
|
||||
@@ -7173,7 +7208,6 @@
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz",
|
||||
"integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"string-width": "^3.1.0",
|
||||
"strip-ansi": "^5.2.0",
|
||||
@@ -7183,20 +7217,17 @@
|
||||
"ansi-regex": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz",
|
||||
"integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==",
|
||||
"dev": true
|
||||
"integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg=="
|
||||
},
|
||||
"is-fullwidth-code-point": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz",
|
||||
"integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=",
|
||||
"dev": true
|
||||
"integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8="
|
||||
},
|
||||
"string-width": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz",
|
||||
"integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"emoji-regex": "^7.0.1",
|
||||
"is-fullwidth-code-point": "^2.0.0",
|
||||
@@ -7207,7 +7238,6 @@
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz",
|
||||
"integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ansi-regex": "^4.1.0"
|
||||
}
|
||||
@@ -8334,8 +8364,7 @@
|
||||
"decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=",
|
||||
"dev": true
|
||||
"integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA="
|
||||
},
|
||||
"decamelize-keys": {
|
||||
"version": "1.1.0",
|
||||
@@ -8746,6 +8775,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"dijkstrajs": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.1.tgz",
|
||||
"integrity": "sha1-082BIh4+pAdCz83lVtTpnpjdxxs="
|
||||
},
|
||||
"dir-glob": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-2.0.0.tgz",
|
||||
@@ -9095,8 +9129,7 @@
|
||||
"emoji-regex": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz",
|
||||
"integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==",
|
||||
"dev": true
|
||||
"integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA=="
|
||||
},
|
||||
"emojis-list": {
|
||||
"version": "3.0.0",
|
||||
@@ -10711,8 +10744,7 @@
|
||||
"get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="
|
||||
},
|
||||
"get-own-enumerable-property-symbols": {
|
||||
"version": "3.0.2",
|
||||
@@ -11700,8 +11732,7 @@
|
||||
"ieee754": {
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz",
|
||||
"integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==",
|
||||
"dev": true
|
||||
"integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg=="
|
||||
},
|
||||
"iferr": {
|
||||
"version": "0.1.5",
|
||||
@@ -14089,6 +14120,41 @@
|
||||
"supports-color": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"joi": {
|
||||
"version": "17.2.1",
|
||||
"resolved": "https://registry.npmjs.org/joi/-/joi-17.2.1.tgz",
|
||||
"integrity": "sha512-YT3/4Ln+5YRpacdmfEfrrKh50/kkgX3LgBltjqnlMPIYiZ4hxXZuVJcxmsvxsdeHg9soZfE3qXxHC2tMpCCBOA==",
|
||||
"requires": {
|
||||
"@hapi/address": "^4.1.0",
|
||||
"@hapi/formula": "^2.0.0",
|
||||
"@hapi/hoek": "^9.0.0",
|
||||
"@hapi/pinpoint": "^2.0.0",
|
||||
"@hapi/topo": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hapi/address": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/address/-/address-4.1.0.tgz",
|
||||
"integrity": "sha512-SkszZf13HVgGmChdHo/PxchnSaCJ6cetVqLzyciudzZRT0jcOouIF/Q93mgjw8cce+D+4F4C1Z/WrfFN+O3VHQ==",
|
||||
"requires": {
|
||||
"@hapi/hoek": "^9.0.0"
|
||||
}
|
||||
},
|
||||
"@hapi/hoek": {
|
||||
"version": "9.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.0.4.tgz",
|
||||
"integrity": "sha512-EwaJS7RjoXUZ2cXXKZZxZqieGtc7RbvQhUy8FwDoMQtxWVi14tFjeFCYPZAM1mBCpOpiBpyaZbb9NeHc7eGKgw=="
|
||||
},
|
||||
"@hapi/topo": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.0.0.tgz",
|
||||
"integrity": "sha512-tFJlT47db0kMqVm3H4nQYgn6Pwg10GTZHb1pwmSiv1K4ks6drQOtfEF5ZnPjkvC+y4/bUPHK+bc87QvLcL+WMw==",
|
||||
"requires": {
|
||||
"@hapi/hoek": "^9.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"js-beautify": {
|
||||
"version": "1.11.0",
|
||||
"resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.11.0.tgz",
|
||||
@@ -14796,7 +14862,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz",
|
||||
"integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"p-locate": "^3.0.0",
|
||||
"path-exists": "^3.0.0"
|
||||
@@ -16488,7 +16553,6 @@
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"p-try": "^2.0.0"
|
||||
}
|
||||
@@ -16497,7 +16561,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz",
|
||||
"integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"p-limit": "^2.0.0"
|
||||
}
|
||||
@@ -16529,8 +16592,7 @@
|
||||
"p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"dev": true
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="
|
||||
},
|
||||
"package-json": {
|
||||
"version": "6.5.0",
|
||||
@@ -16720,8 +16782,7 @@
|
||||
"path-exists": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz",
|
||||
"integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=",
|
||||
"dev": true
|
||||
"integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU="
|
||||
},
|
||||
"path-is-absolute": {
|
||||
"version": "1.0.1",
|
||||
@@ -16906,6 +16967,11 @@
|
||||
"integrity": "sha512-2qHaIQr2VLRFoxe2nASzsV6ef4yOOH+Fi9FBOVH6cqeSgUnoyySPZkxzLuzd+RYOQTRpROA0ztTMqxROKSb/nA==",
|
||||
"dev": true
|
||||
},
|
||||
"pngjs": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-3.4.0.tgz",
|
||||
"integrity": "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w=="
|
||||
},
|
||||
"pnp-webpack-plugin": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/pnp-webpack-plugin/-/pnp-webpack-plugin-1.5.0.tgz",
|
||||
@@ -18171,6 +18237,36 @@
|
||||
"integrity": "sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=",
|
||||
"dev": true
|
||||
},
|
||||
"qrcode": {
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.4.4.tgz",
|
||||
"integrity": "sha512-oLzEC5+NKFou9P0bMj5+v6Z40evexeE29Z9cummZXZ9QXyMr3lphkURzxjXgPJC5azpxcshoDWV1xE46z+/c3Q==",
|
||||
"requires": {
|
||||
"buffer": "^5.4.3",
|
||||
"buffer-alloc": "^1.2.0",
|
||||
"buffer-from": "^1.1.1",
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"isarray": "^2.0.1",
|
||||
"pngjs": "^3.3.0",
|
||||
"yargs": "^13.2.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"buffer": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.6.0.tgz",
|
||||
"integrity": "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==",
|
||||
"requires": {
|
||||
"base64-js": "^1.0.2",
|
||||
"ieee754": "^1.1.4"
|
||||
}
|
||||
},
|
||||
"isarray": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
|
||||
"integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"qs": {
|
||||
"version": "6.9.4",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.9.4.tgz",
|
||||
@@ -19250,14 +19346,12 @@
|
||||
"require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=",
|
||||
"dev": true
|
||||
"integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I="
|
||||
},
|
||||
"require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"dev": true
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="
|
||||
},
|
||||
"requires-port": {
|
||||
"version": "1.0.0",
|
||||
@@ -19802,8 +19896,7 @@
|
||||
"set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=",
|
||||
"dev": true
|
||||
"integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc="
|
||||
},
|
||||
"set-value": {
|
||||
"version": "2.0.1",
|
||||
@@ -23965,8 +24058,7 @@
|
||||
"which-module": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz",
|
||||
"integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=",
|
||||
"dev": true
|
||||
"integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho="
|
||||
},
|
||||
"which-pm-runs": {
|
||||
"version": "1.0.0",
|
||||
@@ -24066,7 +24158,6 @@
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz",
|
||||
"integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ansi-styles": "^3.2.0",
|
||||
"string-width": "^3.0.0",
|
||||
@@ -24076,14 +24167,12 @@
|
||||
"ansi-regex": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz",
|
||||
"integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==",
|
||||
"dev": true
|
||||
"integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg=="
|
||||
},
|
||||
"ansi-styles": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
|
||||
"integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"color-convert": "^1.9.0"
|
||||
}
|
||||
@@ -24091,14 +24180,12 @@
|
||||
"is-fullwidth-code-point": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz",
|
||||
"integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=",
|
||||
"dev": true
|
||||
"integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8="
|
||||
},
|
||||
"string-width": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz",
|
||||
"integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"emoji-regex": "^7.0.1",
|
||||
"is-fullwidth-code-point": "^2.0.0",
|
||||
@@ -24109,7 +24196,6 @@
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz",
|
||||
"integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ansi-regex": "^4.1.0"
|
||||
}
|
||||
@@ -24197,8 +24283,7 @@
|
||||
"y18n": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz",
|
||||
"integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==",
|
||||
"dev": true
|
||||
"integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w=="
|
||||
},
|
||||
"yallist": {
|
||||
"version": "3.1.1",
|
||||
@@ -24216,7 +24301,6 @@
|
||||
"version": "13.3.2",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz",
|
||||
"integrity": "sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"cliui": "^5.0.0",
|
||||
"find-up": "^3.0.0",
|
||||
@@ -24233,14 +24317,12 @@
|
||||
"ansi-regex": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz",
|
||||
"integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==",
|
||||
"dev": true
|
||||
"integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg=="
|
||||
},
|
||||
"find-up": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz",
|
||||
"integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"locate-path": "^3.0.0"
|
||||
}
|
||||
@@ -24248,14 +24330,12 @@
|
||||
"is-fullwidth-code-point": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz",
|
||||
"integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=",
|
||||
"dev": true
|
||||
"integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8="
|
||||
},
|
||||
"string-width": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz",
|
||||
"integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"emoji-regex": "^7.0.1",
|
||||
"is-fullwidth-code-point": "^2.0.0",
|
||||
@@ -24266,7 +24346,6 @@
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz",
|
||||
"integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ansi-regex": "^4.1.0"
|
||||
}
|
||||
@@ -24277,7 +24356,6 @@
|
||||
"version": "13.1.2",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.2.tgz",
|
||||
"integrity": "sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@directus/app",
|
||||
"version": "9.0.0-alpha.20",
|
||||
"version": "9.0.0-alpha.25",
|
||||
"private": false,
|
||||
"description": "Directus is an Open-Source Headless CMS & API for Managing Custom Databases",
|
||||
"author": "Rijk van Zanten <rijk@rngr.org>",
|
||||
@@ -50,6 +50,7 @@
|
||||
"date-fns": "^2.14.0",
|
||||
"diff": "^4.0.2",
|
||||
"htmlhint": "^0.14.1",
|
||||
"joi": "^17.2.1",
|
||||
"js-yaml": "^3.14.0",
|
||||
"jshint": "^2.11.1",
|
||||
"jsonlint": "^1.6.3",
|
||||
@@ -63,6 +64,7 @@
|
||||
"nanoid": "^3.1.10",
|
||||
"pinia": "^0.0.7",
|
||||
"portal-vue": "^2.1.7",
|
||||
"qrcode": "^1.4.4",
|
||||
"resize-observer": "^1.0.0",
|
||||
"semver": "^7.3.2",
|
||||
"stylelint-config-prettier": "^8.0.2",
|
||||
@@ -90,6 +92,7 @@
|
||||
"@types/jest": "^26.0.5",
|
||||
"@types/marked": "^1.1.0",
|
||||
"@types/mime-types": "^2.1.0",
|
||||
"@types/qrcode": "^1.3.5",
|
||||
"@types/semver": "^7.3.1",
|
||||
"@typescript-eslint/eslint-plugin": "^2.34.0",
|
||||
"@typescript-eslint/parser": "^2.34.0",
|
||||
@@ -155,5 +158,5 @@
|
||||
"stylelint --fix"
|
||||
]
|
||||
},
|
||||
"gitHead": "9092740d708c1e9ed1575b0932a01f531d224f77"
|
||||
"gitHead": "c22537b6d0dd7dc6cc651a9200a68f6b060353d4"
|
||||
}
|
||||
|
||||
@@ -51,16 +51,17 @@ export const onError = async (error: RequestError) => {
|
||||
/* istanbul ignore next */
|
||||
const status = error.response?.status;
|
||||
/* istanbul ignore next */
|
||||
const code = error.response?.data?.error?.code;
|
||||
const code = error.response?.data?.errors?.[0]?.extensions?.code;
|
||||
|
||||
if (status === 401 && code === 'INVALID_CREDENTIALS' && error.request.responseURL.includes('refresh') === false) {
|
||||
try {
|
||||
await refresh();
|
||||
|
||||
/** @todo retry failed request after successful refresh */
|
||||
} catch {
|
||||
logout({ reason: LogoutReason.ERROR_SESSION_EXPIRED });
|
||||
return Promise.reject();
|
||||
}
|
||||
|
||||
return api.request(error.config);
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 |
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
</template>
|
||||
|
||||
<template #append>
|
||||
<v-icon name="add_box" @click="toggle" />
|
||||
<v-icon name="add_box" outline @click="toggle" />
|
||||
</template>
|
||||
</v-input>
|
||||
</template>
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType, computed } from '@vue/composition-api';
|
||||
import { Field } from '@/types';
|
||||
import interfaces from '@/interfaces';
|
||||
import { getInterfaces } from '@/interfaces';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
@@ -65,8 +65,10 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const interfaces = getInterfaces();
|
||||
|
||||
const interfaceExists = computed(() => {
|
||||
return !!interfaces.find((inter) => inter.id === props.field.meta.interface);
|
||||
return !!interfaces.value.find((inter) => inter.id === props.field.meta.interface);
|
||||
});
|
||||
|
||||
return { interfaceExists };
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType, computed, ref } from '@vue/composition-api';
|
||||
import { defineComponent, PropType, computed, ref, provide } from '@vue/composition-api';
|
||||
import { useFieldsStore } from '@/stores/';
|
||||
import { Field } from '@/types';
|
||||
import { useElementSize } from '@/composables/use-element-size';
|
||||
@@ -83,6 +83,8 @@ export default defineComponent({
|
||||
|
||||
const { toggleBatchField, batchActiveFields } = useBatch();
|
||||
|
||||
provide('form-values', values);
|
||||
|
||||
return {
|
||||
el,
|
||||
formFields,
|
||||
@@ -115,7 +117,7 @@ export default defineComponent({
|
||||
const gridClass = computed<string | null>(() => {
|
||||
if (el.value === null) return null;
|
||||
|
||||
if (width.value > 612 && width.value <= 792) {
|
||||
if (width.value > 588 && width.value <= 792) {
|
||||
return 'grid';
|
||||
} else {
|
||||
return 'grid with-fill';
|
||||
@@ -180,6 +182,8 @@ body {
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/mixins/breakpoint';
|
||||
|
||||
.v-form {
|
||||
&.grid {
|
||||
display: grid;
|
||||
@@ -196,15 +200,27 @@ body {
|
||||
& > .half,
|
||||
& > .half-left,
|
||||
& > .half-space {
|
||||
grid-column: start / half;
|
||||
grid-column: start / fill;
|
||||
|
||||
@include breakpoint(medium) {
|
||||
grid-column: start / half;
|
||||
}
|
||||
}
|
||||
|
||||
& > .half-right {
|
||||
grid-column: half / full;
|
||||
grid-column: start / fill;
|
||||
|
||||
@include breakpoint(medium) {
|
||||
grid-column: half / full;
|
||||
}
|
||||
}
|
||||
|
||||
& > .full {
|
||||
grid-column: start / full;
|
||||
grid-column: start / fill;
|
||||
|
||||
@include breakpoint(medium) {
|
||||
grid-column: start / full;
|
||||
}
|
||||
}
|
||||
|
||||
& > .fill {
|
||||
|
||||
16
app/src/components/v-icon/custom-icons/directus.vue
Normal file
16
app/src/components/v-icon/custom-icons/directus.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template functional>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="2"
|
||||
>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.2 14.15a1.8 1.8 0 01-.35-.12c-.1-.05-.14-.18-.13-.3.02-.41-.01-.78.03-1.2.18-1.85 1.38-1.26 2.46-1.57.58-.16 1.17-.47 1.42-1.06.07-.16.02-.35-.1-.48a13.66 13.66 0 00-2.27-1.98 14.25 14.25 0 00-9.85-2.31.2.2 0 00-.15.32A5.17 5.17 0 0011.93 7c.1.06.06.2-.06.18-.3-.06-.68-.18-1.06-.4a.43.43 0 00-.38-.05l-.44.18a.2.2 0 00-.05.34 5.32 5.32 0 006.14.42c.1-.06.25.07.22.18-.07.21-.15.52-.23.95-.48 2.37-1.87 2.18-3.58 1.59-3.36-1.19-5.3-.22-7-2.1-.19-.21-.5-.29-.7-.09a1.55 1.55 0 00.1 2.29c.15.12.36.07.5-.06.07-.06.13-.1.21-.14.1-.04.16.11.07.18-.4.33-.51.7-.76 1.48-.38 1.16-.22 2.36-1.98 2.67-.93.04-.91.66-1.25 1.58A5.2 5.2 0 01.3 18.27c-.4.41-.44 1.18.14 1.23.18 0 .36-.03.55-.1.99-.4 1.75-1.65 2.47-2.46.8-.9 2.72-.51 4.17-1.4.82-.48 1.3-1.1 1.08-2.07-.02-.12.12-.2.18-.09.1.2.18.41.23.63.05.22.25.39.47.4 1.36.1 3.02 1.3 4.63 1.88.32.11.56-.26.43-.57-.1-.24-.18-.48-.23-.7-.02-.13.16-.16.22-.05a3.5 3.5 0 002.88 1.88c.46.03.97-.02 1.5-.18.63-.18 1.22-.42 1.91-.3.52.1 1 .36 1.3.79.36.5 1.04.7 1.54.38.28-.19.29-.58.13-.87-1.14-2.08-3.61-2.25-4.7-2.52z"/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {};
|
||||
</script>
|
||||
@@ -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>
|
||||
|
||||
22
app/src/components/v-icon/custom-icons/logout.vue
Normal file
22
app/src/components/v-icon/custom-icons/logout.vue
Normal 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>
|
||||
@@ -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'"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
:tabindex="hasClick ? 0 : null"
|
||||
>
|
||||
<component v-if="customIconName" :is="customIconName" />
|
||||
<i v-else :class="{ outline }">{{ name }}</i>
|
||||
<i v-else :class="{ filled }">{{ name }}</i>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import { defineComponent, computed } from '@vue/composition-api';
|
||||
import useSizeClass, { sizeProps } from '@/composables/size-class';
|
||||
|
||||
import CustomIconDirectus from './custom-icons/directus.vue';
|
||||
import CustomIconBox from './custom-icons/box.vue';
|
||||
import CustomIconCommitNode from './custom-icons/commit_node.vue';
|
||||
import CustomIconGrid1 from './custom-icons/grid_1.vue';
|
||||
@@ -29,8 +30,10 @@ import CustomIconSignalWifi3Bar from './custom-icons/signal_wifi_3_bar.vue';
|
||||
import CustomIconFlipHorizontal from './custom-icons/flip_horizontal.vue';
|
||||
import CustomIconFlipVertical from './custom-icons/flip_vertical.vue';
|
||||
import CustomIconFolderMove from './custom-icons/folder_move.vue';
|
||||
import CustomIconLogout from './custom-icons/logout.vue';
|
||||
|
||||
const customIcons: string[] = [
|
||||
'directus',
|
||||
'box',
|
||||
'commit_node',
|
||||
'grid_1',
|
||||
@@ -45,10 +48,12 @@ const customIcons: string[] = [
|
||||
'flip_horizontal',
|
||||
'flip_vertical',
|
||||
'folder_move',
|
||||
'logout',
|
||||
];
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
CustomIconDirectus,
|
||||
CustomIconBox,
|
||||
CustomIconCommitNode,
|
||||
CustomIconGrid1,
|
||||
@@ -63,13 +68,14 @@ export default defineComponent({
|
||||
CustomIconFlipHorizontal,
|
||||
CustomIconFlipVertical,
|
||||
CustomIconFolderMove,
|
||||
CustomIconLogout,
|
||||
},
|
||||
props: {
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
outline: {
|
||||
filled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
@@ -143,7 +149,7 @@ body {
|
||||
font-weight: normal;
|
||||
font-size: var(--v-icon-size);
|
||||
/* stylelint-disable-next-line font-family-no-missing-generic-family-keyword */
|
||||
font-family: 'Material Icons';
|
||||
font-family: 'Material Icons Outline';
|
||||
font-style: normal;
|
||||
line-height: 1;
|
||||
letter-spacing: normal;
|
||||
@@ -152,9 +158,9 @@ body {
|
||||
word-wrap: normal;
|
||||
font-feature-settings: 'liga';
|
||||
|
||||
&.outline {
|
||||
&.filled {
|
||||
/* stylelint-disable-next-line font-family-no-missing-generic-family-keyword */
|
||||
font-family: 'Material Icons Outline';
|
||||
font-family: 'Material Icons';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -120,10 +120,14 @@ body {
|
||||
@keyframes indeterminate {
|
||||
0% {
|
||||
transform: scaleX(0);
|
||||
}
|
||||
|
||||
10% {
|
||||
transform: scaleX(0);
|
||||
animation-timing-function: cubic-bezier(0.1, 0.6, 0.9, 0.5);
|
||||
}
|
||||
|
||||
50% {
|
||||
60% {
|
||||
transform: scaleX(1) translateX(25%);
|
||||
animation-timing-function: cubic-bezier(0.4, 0.1, 0.2, 0.9);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -28,34 +28,9 @@ export function useCollection(collectionKey: string | Ref<string>) {
|
||||
return fields.value?.find((field) => field.meta?.special === 'user_created') || null;
|
||||
});
|
||||
|
||||
const statusField = computed(() => {
|
||||
return fields.value?.find((field) => field.meta?.special === 'status') || null;
|
||||
});
|
||||
|
||||
const sortField = computed(() => {
|
||||
return fields.value?.find((field) => field.meta?.special === 'sort') || null;
|
||||
return info.value?.meta?.sort_field || null;
|
||||
});
|
||||
|
||||
type Status = {
|
||||
background_color: string;
|
||||
browse_badge: string;
|
||||
browse_subdued: string;
|
||||
name: string;
|
||||
published: boolean;
|
||||
required_fields: boolean;
|
||||
soft_delete: boolean;
|
||||
text_color: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
const softDeleteStatus = computed<string | null>(() => {
|
||||
if (statusField.value === null) return null;
|
||||
|
||||
const statuses = Object.values(statusField.value?.meta?.options?.status_mapping || {});
|
||||
return (
|
||||
(statuses.find((status) => (status as Status).soft_delete === true) as Status | undefined)?.value || null
|
||||
);
|
||||
});
|
||||
|
||||
return { info, fields, primaryKeyField, userCreatedField, statusField, softDeleteStatus, sortField };
|
||||
return { info, fields, primaryKeyField, userCreatedField, sortField };
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
|
||||
import { computed, Ref } from '@vue/composition-api';
|
||||
import { isEmpty } from '@/utils/is-empty';
|
||||
import getDefaultInterfaceForType from '@/utils/get-default-interface-for-type';
|
||||
import interfaces from '@/interfaces';
|
||||
import { getInterfaces } from '@/interfaces';
|
||||
import { FormField } from '@/components/v-form/types';
|
||||
import { Field } from '@/types';
|
||||
|
||||
export default function useFormFields(fields: Ref<Field[]>) {
|
||||
const interfaces = getInterfaces();
|
||||
|
||||
const formFields = computed(() => {
|
||||
let formFields = [...fields.value];
|
||||
|
||||
@@ -49,14 +50,14 @@ export default function useFormFields(fields: Ref<Field[]>) {
|
||||
field.meta.width = 'full';
|
||||
}
|
||||
|
||||
let interfaceUsed = interfaces.find((int) => int.id === field.meta.interface);
|
||||
let interfaceUsed = interfaces.value.find((int) => int.id === field.meta.interface);
|
||||
const interfaceExists = interfaceUsed !== undefined;
|
||||
|
||||
if (interfaceExists === false) {
|
||||
field.meta.interface = getDefaultInterfaceForType(field.type);
|
||||
}
|
||||
|
||||
interfaceUsed = interfaces.find((int) => int.id === field.meta.interface);
|
||||
interfaceUsed = interfaces.value.find((int) => int.id === field.meta.interface);
|
||||
|
||||
if (interfaceUsed?.hideLabel === true) {
|
||||
(field as FormField).hideLabel = true;
|
||||
@@ -80,7 +81,7 @@ export default function useFormFields(fields: Ref<Field[]>) {
|
||||
// Filter out the fields that are marked hidden on detail
|
||||
formFields = formFields.filter((field) => {
|
||||
const hidden = field.meta?.hidden;
|
||||
const systemFake = field.field.startsWith('$');
|
||||
const systemFake = field.field?.startsWith('$') || false;
|
||||
return hidden !== true && systemFake === false;
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import useCollection from '@/composables/use-collection';
|
||||
import { AxiosResponse } from 'axios';
|
||||
|
||||
export function useItem(collection: Ref<string>, primaryKey: Ref<string | number | null>) {
|
||||
const { info: collectionInfo, primaryKeyField, softDeleteStatus, statusField } = useCollection(collection);
|
||||
const { info: collectionInfo, primaryKeyField } = useCollection(collection);
|
||||
|
||||
const item = ref<any>(null);
|
||||
const error = ref(null);
|
||||
@@ -45,6 +45,7 @@ export function useItem(collection: Ref<string>, primaryKey: Ref<string | number
|
||||
save,
|
||||
isNew,
|
||||
remove,
|
||||
softDelete,
|
||||
deleting,
|
||||
softDeleting,
|
||||
saveAsCopy,
|
||||
@@ -176,25 +177,18 @@ export function useItem(collection: Ref<string>, primaryKey: Ref<string | number
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(soft = false) {
|
||||
if (soft) {
|
||||
softDeleting.value = true;
|
||||
} else {
|
||||
deleting.value = true;
|
||||
}
|
||||
async function softDelete() {
|
||||
if (!collectionInfo.value?.meta?.soft_delete_field) return;
|
||||
|
||||
softDeleting.value = true;
|
||||
|
||||
const field = collectionInfo.value.meta.soft_delete_field;
|
||||
const value = collectionInfo.value.meta.soft_delete_value;
|
||||
|
||||
try {
|
||||
if (soft) {
|
||||
if (!statusField.value || softDeleteStatus.value === null) {
|
||||
throw new Error('[useItem] You cant soft-delete without a status field');
|
||||
}
|
||||
|
||||
await api.patch(itemEndpoint.value, {
|
||||
[statusField.value.field]: softDeleteStatus.value,
|
||||
});
|
||||
} else {
|
||||
await api.delete(itemEndpoint.value);
|
||||
}
|
||||
await api.patch(itemEndpoint.value, {
|
||||
[field]: value,
|
||||
});
|
||||
|
||||
item.value = null;
|
||||
|
||||
@@ -218,11 +212,39 @@ export function useItem(collection: Ref<string>, primaryKey: Ref<string | number
|
||||
|
||||
throw err;
|
||||
} finally {
|
||||
if (soft) {
|
||||
softDeleting.value = false;
|
||||
} else {
|
||||
deleting.value = false;
|
||||
}
|
||||
softDeleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
deleting.value = true;
|
||||
|
||||
try {
|
||||
await api.delete(itemEndpoint.value);
|
||||
|
||||
item.value = null;
|
||||
|
||||
notify({
|
||||
title: i18n.tc('item_delete_success', isBatch.value ? 2 : 1),
|
||||
text: i18n.tc('item_in', isBatch.value ? 2 : 1, {
|
||||
collection: collection.value,
|
||||
primaryKey: isBatch.value ? (primaryKey.value as string).split(',').join(', ') : primaryKey.value,
|
||||
}),
|
||||
type: 'success',
|
||||
});
|
||||
} catch (err) {
|
||||
notify({
|
||||
title: i18n.tc('item_delete_failed', isBatch.value ? 2 : 1),
|
||||
text: i18n.tc('item_in', isBatch.value ? 2 : 1, {
|
||||
collection: collection.value,
|
||||
primaryKey: isBatch.value ? (primaryKey.value as string).split(',').join(', ') : primaryKey.value,
|
||||
}),
|
||||
type: 'error',
|
||||
});
|
||||
|
||||
throw err;
|
||||
} finally {
|
||||
deleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
useSettingsStore,
|
||||
useLatencyStore,
|
||||
useRelationsStore,
|
||||
usePermissionsStore,
|
||||
} from '@/stores';
|
||||
import { register as registerModules, unregister as unregisterModules } from '@/modules/register';
|
||||
|
||||
import { setLanguage, Language } from '@/lang';
|
||||
|
||||
@@ -30,6 +32,7 @@ export function useStores(
|
||||
useSettingsStore,
|
||||
useLatencyStore,
|
||||
useRelationsStore,
|
||||
usePermissionsStore,
|
||||
]
|
||||
) {
|
||||
return stores.map((useStore) => useStore()) as GenericStore[];
|
||||
@@ -57,6 +60,8 @@ export async function hydrate(stores = useStores()) {
|
||||
setLanguage((userStore.state.currentUser?.locale as Language) || 'en-US');
|
||||
|
||||
await Promise.all(stores.filter(({ id }) => id !== 'userStore').map((store) => store.hydrate?.()));
|
||||
|
||||
registerModules();
|
||||
} catch (error) {
|
||||
appStore.state.error = error;
|
||||
} finally {
|
||||
@@ -76,5 +81,7 @@ export async function dehydrate(stores = useStores()) {
|
||||
await store.dehydrate?.();
|
||||
}
|
||||
|
||||
unregisterModules();
|
||||
|
||||
appStore.state.hydrated = false;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,65 +1,12 @@
|
||||
import InterfaceCheckboxes from './checkboxes';
|
||||
import InterfaceCode from './code';
|
||||
import InterfaceCollections from './collections';
|
||||
import InterfaceColor from './color';
|
||||
import InterfaceDateTime from './datetime';
|
||||
import InterfaceDivider from './divider/';
|
||||
import InterfaceDropdown from './dropdown/';
|
||||
import InterfaceDropdownMultiselect from './dropdown-multiselect/';
|
||||
import InterfaceFile from './file';
|
||||
import InterfaceFiles from './files';
|
||||
import InterfaceHash from './hash';
|
||||
import InterfaceIcon from './icon';
|
||||
import InterfaceImage from './image';
|
||||
import InterfaceManyToMany from './many-to-many';
|
||||
import InterfaceManyToOne from './many-to-one';
|
||||
import InterfaceNotice from './notice';
|
||||
import InterfaceNumeric from './numeric/';
|
||||
import InterfaceOneToMany from './one-to-many';
|
||||
import InterfaceRadioButtons from './radio-buttons';
|
||||
import InterfaceRepeater from './repeater';
|
||||
import InterfaceSlider from './slider/';
|
||||
import InterfaceSlug from './slug';
|
||||
import InterfaceStatus from './status';
|
||||
import InterfaceTags from './tags';
|
||||
import InterfaceTextarea from './textarea/';
|
||||
import InterfaceTextInput from './text-input/';
|
||||
import InterfaceToggle from './toggle/';
|
||||
import InterfaceTranslations from './translations';
|
||||
import InterfaceUser from './user';
|
||||
import InterfaceWYSIWYG from './wysiwyg/';
|
||||
import { ref, Ref } from '@vue/composition-api';
|
||||
import { InterfaceConfig } from './types';
|
||||
|
||||
export const interfaces = [
|
||||
InterfaceCheckboxes,
|
||||
InterfaceCode,
|
||||
InterfaceCollections,
|
||||
InterfaceColor,
|
||||
InterfaceDateTime,
|
||||
InterfaceDivider,
|
||||
InterfaceDropdown,
|
||||
InterfaceDropdownMultiselect,
|
||||
InterfaceFile,
|
||||
InterfaceFiles,
|
||||
InterfaceHash,
|
||||
InterfaceIcon,
|
||||
InterfaceImage,
|
||||
InterfaceManyToMany,
|
||||
InterfaceManyToOne,
|
||||
InterfaceNotice,
|
||||
InterfaceNumeric,
|
||||
InterfaceOneToMany,
|
||||
InterfaceRadioButtons,
|
||||
InterfaceRepeater,
|
||||
InterfaceSlider,
|
||||
InterfaceSlug,
|
||||
InterfaceStatus,
|
||||
InterfaceTags,
|
||||
InterfaceTextarea,
|
||||
InterfaceTextInput,
|
||||
InterfaceToggle,
|
||||
InterfaceTranslations,
|
||||
InterfaceUser,
|
||||
InterfaceWYSIWYG,
|
||||
];
|
||||
let interfaces: Ref<InterfaceConfig[]>;
|
||||
|
||||
export default interfaces;
|
||||
export function getInterfaces() {
|
||||
if (!interfaces) {
|
||||
interfaces = ref([]);
|
||||
}
|
||||
|
||||
return interfaces;
|
||||
}
|
||||
|
||||
12
app/src/interfaces/interface-options/index.ts
Normal file
12
app/src/interfaces/interface-options/index.ts
Normal 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,
|
||||
}));
|
||||
66
app/src/interfaces/interface-options/interface-options.vue
Normal file
66
app/src/interfaces/interface-options/interface-options.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<v-notice v-if="!selectedInterface">
|
||||
{{ $t('select_interface') }}
|
||||
</v-notice>
|
||||
|
||||
<v-notice v-else-if="!selectedInterface.options">
|
||||
{{ $t('no_options_available') }}
|
||||
</v-notice>
|
||||
|
||||
<div class="inset" v-else>
|
||||
<v-form
|
||||
v-if="Array.isArray(selectedInterface.options)"
|
||||
:fields="selectedInterface.options"
|
||||
primary-key="+"
|
||||
:edits="value"
|
||||
@input="$listeners.input"
|
||||
/>
|
||||
|
||||
<component
|
||||
:value="value"
|
||||
@input="$listeners.input"
|
||||
:field-data="fieldData"
|
||||
:is="`interface-options-${selectedInterface.id}`"
|
||||
v-else
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, inject, ref } from '@vue/composition-api';
|
||||
import { getInterfaces } from '@/interfaces';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
interfaceField: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props, { parent }) {
|
||||
const interfaces = getInterfaces();
|
||||
|
||||
const values = inject('form-values', ref<Record<string, any>>({}));
|
||||
|
||||
const selectedInterface = computed(() => {
|
||||
if (!values.value[props.interfaceField]) return;
|
||||
|
||||
return interfaces.value.find((inter) => inter.id === values.value[props.interfaceField]);
|
||||
});
|
||||
|
||||
return { selectedInterface, values };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.inset {
|
||||
padding: 8px;
|
||||
border: var(--border-width) solid var(--border-normal);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
</style>
|
||||
12
app/src/interfaces/interface/index.ts
Normal file
12
app/src/interfaces/interface/index.ts
Normal 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: [],
|
||||
}));
|
||||
34
app/src/interfaces/interface/interface.vue
Normal file
34
app/src/interfaces/interface/interface.vue
Normal 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>
|
||||
132
app/src/interfaces/markdown/demo.md
Normal file
132
app/src/interfaces/markdown/demo.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# h1 Heading
|
||||
## h2 Heading
|
||||
### h3 Heading
|
||||
#### h4 Heading
|
||||
##### h5 Heading
|
||||
###### h6 Heading
|
||||
|
||||
|
||||
## Horizontal Rules
|
||||
|
||||
___
|
||||
|
||||
---
|
||||
|
||||
***
|
||||
|
||||
|
||||
## Emphasis
|
||||
|
||||
**This is bold text**
|
||||
|
||||
__This is bold text__
|
||||
|
||||
*This is italic text*
|
||||
|
||||
_This is italic text_
|
||||
|
||||
~~Strikethrough~~
|
||||
|
||||
|
||||
## Blockquotes
|
||||
|
||||
|
||||
> Blockquotes can also be nested...
|
||||
>> ...by using additional greater-than signs right next to each other...
|
||||
> > > ...or with spaces between arrows.
|
||||
|
||||
|
||||
## Lists
|
||||
|
||||
Unordered
|
||||
|
||||
+ Create a list by starting a line with `+`, `-`, or `*`
|
||||
+ Sub-lists are made by indenting 2 spaces:
|
||||
- Marker character change forces new list start:
|
||||
* Ac tristique libero volutpat at
|
||||
+ Facilisis in pretium nisl aliquet
|
||||
- Nulla volutpat aliquam velit
|
||||
+ Very easy!
|
||||
|
||||
Ordered
|
||||
|
||||
1. Lorem ipsum dolor sit amet
|
||||
2. Consectetur adipiscing elit
|
||||
3. Integer molestie lorem at massa
|
||||
|
||||
|
||||
1. You can use sequential numbers...
|
||||
1. ...or keep all the numbers as `1.`
|
||||
|
||||
Start numbering with offset:
|
||||
|
||||
57. foo
|
||||
1. bar
|
||||
|
||||
|
||||
## Code
|
||||
|
||||
Inline `code`
|
||||
|
||||
Indented code
|
||||
|
||||
// Some comments
|
||||
line 1 of code
|
||||
line 2 of code
|
||||
line 3 of code
|
||||
|
||||
|
||||
Block code "fences"
|
||||
|
||||
```
|
||||
Sample text here...
|
||||
```
|
||||
|
||||
Syntax highlighting
|
||||
|
||||
``` js
|
||||
var foo = function (bar) {
|
||||
return bar++;
|
||||
};
|
||||
|
||||
console.log(foo(5));
|
||||
```
|
||||
|
||||
## Tables
|
||||
|
||||
| Option | Description |
|
||||
| ------ | ----------- |
|
||||
| data | path to data files to supply the data that will be passed into templates. |
|
||||
| engine | engine to be used for processing templates. Handlebars is the default. |
|
||||
| ext | extension to be used for dest files. |
|
||||
|
||||
Right aligned columns
|
||||
|
||||
| Option | Description |
|
||||
| ------:| -----------:|
|
||||
| data | path to data files to supply the data that will be passed into templates. |
|
||||
| engine | engine to be used for processing templates. Handlebars is the default. |
|
||||
| ext | extension to be used for dest files. |
|
||||
|
||||
|
||||
## Links
|
||||
|
||||
[link text](http://dev.nodeca.com)
|
||||
|
||||
[link with title](http://nodeca.github.io/pica/demo/ "title text!")
|
||||
|
||||
Autoconverted link https://github.com/nodeca/pica (enable linkify to see)
|
||||
|
||||
|
||||
## Images
|
||||
|
||||

|
||||

|
||||
|
||||
Like links, Images also have a footnote style syntax
|
||||
|
||||
![Alt text][id]
|
||||
|
||||
With a reference later in the document defining the URL location:
|
||||
|
||||
[id]: https://octodex.github.com/images/dojocat.jpg "The Dojocat"
|
||||
30
app/src/interfaces/markdown/index.ts
Normal file
30
app/src/interfaces/markdown/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import InterfaceMarkdown from './markdown.vue';
|
||||
import { defineInterface } from '@/interfaces/define';
|
||||
|
||||
export default defineInterface(({ i18n }) => ({
|
||||
id: 'markdown',
|
||||
name: i18n.t('markdown'),
|
||||
icon: 'text_fields',
|
||||
component: InterfaceMarkdown,
|
||||
types: ['text'],
|
||||
options: [
|
||||
{
|
||||
field: 'placeholder',
|
||||
name: i18n.t('placeholder'),
|
||||
type: 'string',
|
||||
meta: {
|
||||
width: 'half',
|
||||
interface: 'text-input',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'tabbed',
|
||||
name: i18n.t('tabbed'),
|
||||
type: 'boolean',
|
||||
meta: {
|
||||
width: 'half',
|
||||
interface: 'toggle',
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
51
app/src/interfaces/markdown/markdown.story.ts
Normal file
51
app/src/interfaces/markdown/markdown.story.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { withKnobs, boolean, text, optionsKnob } from '@storybook/addon-knobs';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import Vue from 'vue';
|
||||
import InterfaceMarkdown from './markdown.vue';
|
||||
import markdown from './readme.md';
|
||||
import withPadding from '../../../.storybook/decorators/with-padding';
|
||||
import { defineComponent, ref } from '@vue/composition-api';
|
||||
import RawValue from '../../../.storybook/raw-value.vue';
|
||||
import i18n from '@/lang';
|
||||
|
||||
Vue.component('interface-markdown', InterfaceMarkdown);
|
||||
|
||||
export default {
|
||||
title: 'Interfaces / Markdown',
|
||||
decorators: [withKnobs, withPadding],
|
||||
parameters: {
|
||||
notes: markdown,
|
||||
},
|
||||
};
|
||||
|
||||
export const basic = () =>
|
||||
defineComponent({
|
||||
components: { RawValue },
|
||||
i18n,
|
||||
props: {
|
||||
disabled: {
|
||||
default: boolean('Disabled', false, 'Options'),
|
||||
},
|
||||
placeholder: {
|
||||
default: text('Placeholder', 'Enter a value...', 'Options'),
|
||||
},
|
||||
tabbed: {
|
||||
default: boolean('Tabbed', false, 'Options'),
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const value = ref('');
|
||||
const onInput = action('input');
|
||||
return { onInput, value };
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<interface-markdown
|
||||
v-model="value"
|
||||
v-bind="{ placeholder, tabbed, disabled }"
|
||||
@input="onInput"
|
||||
/>
|
||||
<raw-value>{{ value }}</raw-value>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
24
app/src/interfaces/markdown/markdown.test.ts
Normal file
24
app/src/interfaces/markdown/markdown.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import VueCompositionAPI from '@vue/composition-api';
|
||||
import { shallowMount, createLocalVue } from '@vue/test-utils';
|
||||
import InterfaceMarkdown from './markdown.vue';
|
||||
|
||||
import VTextarea from '@/components/v-textarea';
|
||||
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(VueCompositionAPI);
|
||||
localVue.component('v-textarea', VTextarea);
|
||||
|
||||
describe('Interfaces / Markdown', () => {
|
||||
it('Renders a v-markdown', () => {
|
||||
const component = shallowMount(InterfaceMarkdown, {
|
||||
localVue,
|
||||
propsData: {
|
||||
placeholder: 'Enter value...',
|
||||
},
|
||||
listeners: {
|
||||
input: () => {},
|
||||
},
|
||||
});
|
||||
expect(component.find(VTextarea).exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
345
app/src/interfaces/markdown/markdown.vue
Normal file
345
app/src/interfaces/markdown/markdown.vue
Normal file
@@ -0,0 +1,345 @@
|
||||
<template>
|
||||
<div class="interface-markdown" :class="{ tabbed }">
|
||||
<div v-if="tabbed" class="toolbar">
|
||||
<v-tabs v-model="currentTab">
|
||||
<v-tab>
|
||||
<v-icon name="code" left />
|
||||
{{ $t('edit') }}
|
||||
</v-tab>
|
||||
<v-tab>
|
||||
<v-icon name="visibility" outline left />
|
||||
{{ $t('preview') }}
|
||||
</v-tab>
|
||||
</v-tabs>
|
||||
</div>
|
||||
<v-textarea
|
||||
v-show="showEdit"
|
||||
:placeholder="placeholder"
|
||||
:value="value"
|
||||
:disabled="disabled"
|
||||
@input="$listeners.input"
|
||||
/>
|
||||
<div v-show="showPreview" class="preview-container">
|
||||
<div class="preview" v-html="html"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import marked from 'marked';
|
||||
import { defineComponent, computed, ref } from '@vue/composition-api';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
tabbed: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const currentTab = ref([0]);
|
||||
|
||||
const html = computed(() => (props.value ? marked(props.value) : ''));
|
||||
const showEdit = computed(() => !props.tabbed || currentTab.value[0] === 0);
|
||||
const showPreview = computed(() => !props.tabbed || currentTab.value[0] !== 0);
|
||||
|
||||
return { html, currentTab, showEdit, showPreview };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.interface-markdown {
|
||||
--v-textarea-min-height: var(--input-height-tall);
|
||||
--v-textarea-max-height: 400px;
|
||||
|
||||
--v-tab-background-color: var(--background-subdued);
|
||||
--v-tab-background-color-active: var(--background-subdued);
|
||||
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.toolbar {
|
||||
width: 100%;
|
||||
height: 42px;
|
||||
border: var(--border-width) solid var(--border-normal);
|
||||
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
||||
background-color: var(--background-subdued);
|
||||
}
|
||||
|
||||
.v-textarea {
|
||||
height: unset;
|
||||
min-height: var(--input-height-tall);
|
||||
border-radius: var(--border-radius) 0 0 var(--border-radius);
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
min-height: var(--v-textarea-min-height);
|
||||
max-height: var(--v-textarea-max-height);
|
||||
padding: var(--input-padding);
|
||||
overflow-y: auto;
|
||||
border: var(--border-width) solid var(--border-normal);
|
||||
border-radius: 0 var(--border-radius) var(--border-radius) 0;
|
||||
}
|
||||
|
||||
.v-textarea,
|
||||
.preview-container {
|
||||
flex-basis: 100px;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
&:not(.tabbed) .preview-container {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
&.tabbed .v-textarea {
|
||||
border-top: none;
|
||||
border-radius: 0 0 var(--border-radius) var(--border-radius);
|
||||
}
|
||||
|
||||
&.tabbed .preview-container {
|
||||
border-top: none;
|
||||
border-radius: 0 0 var(--border-radius) var(--border-radius);
|
||||
}
|
||||
|
||||
::v-deep {
|
||||
.preview {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
& > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
& > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
position: relative;
|
||||
margin: 20px 0 10px;
|
||||
padding: 0;
|
||||
font-weight: 600;
|
||||
cursor: text;
|
||||
}
|
||||
pre {
|
||||
padding: 6px 10px;
|
||||
overflow: auto;
|
||||
font-size: 13px;
|
||||
line-height: 19px;
|
||||
background-color: var(--background-page);
|
||||
border: 1px solid var(--background-normal);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
code,
|
||||
tt {
|
||||
margin: 0 2px;
|
||||
padding: 0 5px;
|
||||
white-space: nowrap;
|
||||
background-color: var(--background-page);
|
||||
border: 1px solid var(--background-normal);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
pre code {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
white-space: pre;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
pre code,
|
||||
pre tt {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
}
|
||||
h1 tt,
|
||||
h1 code {
|
||||
font-size: inherit;
|
||||
}
|
||||
h2 tt,
|
||||
h2 code {
|
||||
font-size: inherit;
|
||||
}
|
||||
h3 tt,
|
||||
h3 code {
|
||||
font-size: inherit;
|
||||
}
|
||||
h4 tt,
|
||||
h4 code {
|
||||
font-size: inherit;
|
||||
}
|
||||
h5 tt,
|
||||
h5 code {
|
||||
font-size: inherit;
|
||||
}
|
||||
h6 tt,
|
||||
h6 code {
|
||||
font-size: inherit;
|
||||
}
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
}
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
}
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
}
|
||||
h4 {
|
||||
font-size: 16px;
|
||||
}
|
||||
h5 {
|
||||
font-size: 14px;
|
||||
}
|
||||
h6 {
|
||||
color: var(--foreground-normal);
|
||||
font-size: 14px;
|
||||
}
|
||||
p,
|
||||
blockquote,
|
||||
ul,
|
||||
ol,
|
||||
dl,
|
||||
li,
|
||||
table,
|
||||
pre {
|
||||
margin: 15px 0;
|
||||
}
|
||||
& > h2:first-child {
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
& > h1:first-child {
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
& > h3:first-child,
|
||||
& > h4:first-child,
|
||||
& > h5:first-child,
|
||||
& > h6:first-child {
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
a:first-child h1,
|
||||
a:first-child h2,
|
||||
a:first-child h3,
|
||||
a:first-child h4,
|
||||
a:first-child h5,
|
||||
a:first-child h6 {
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
h1 p,
|
||||
h2 p,
|
||||
h3 p,
|
||||
h4 p,
|
||||
h5 p,
|
||||
h6 p {
|
||||
margin-top: 0;
|
||||
}
|
||||
li p.first {
|
||||
display: inline-block;
|
||||
}
|
||||
ul,
|
||||
ol {
|
||||
padding-left: 30px;
|
||||
li {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
ul :first-child,
|
||||
ol :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
ul :last-child,
|
||||
ol :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
blockquote {
|
||||
padding: 0 15px;
|
||||
color: var(--foreground-normal);
|
||||
border-left: 4px solid var(--background-normal);
|
||||
}
|
||||
blockquote > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
blockquote > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
table {
|
||||
padding: 0;
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
}
|
||||
table tr {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: white;
|
||||
border-top: 1px solid var(--background-normal);
|
||||
}
|
||||
table tr:nth-child(2n) {
|
||||
background-color: var(--background-page);
|
||||
}
|
||||
table tr th {
|
||||
margin: 0;
|
||||
padding: 6px 13px;
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
border: 1px solid var(--background-normal);
|
||||
}
|
||||
table tr td {
|
||||
margin: 0;
|
||||
padding: 6px 13px;
|
||||
text-align: left;
|
||||
border: 1px solid var(--background-normal);
|
||||
}
|
||||
table tr th :first-child,
|
||||
table tr td :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
table tr th :last-child,
|
||||
table tr td :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
.highlight pre {
|
||||
padding: 6px 10px;
|
||||
overflow: auto;
|
||||
font-size: 13px;
|
||||
line-height: 19px;
|
||||
background-color: var(--background-page);
|
||||
border: 1px solid var(--background-normal);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
hr {
|
||||
margin: 20px auto;
|
||||
border: none;
|
||||
border-top: 1px solid var(--background-normal);
|
||||
}
|
||||
b,
|
||||
strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
9
app/src/interfaces/markdown/readme.md
Normal file
9
app/src/interfaces/markdown/readme.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Markdown
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Description | Default |
|
||||
| ------------- | ------------------------------------------------------------------- | ------------ |
|
||||
| `placeholder` | Text to show when no input is entered | `null` |
|
||||
| `tabbed` | If the view should be tabbed | `false` |
|
||||
| `disabled` | Disables the input | `false` |
|
||||
@@ -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'),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
143
app/src/interfaces/repeater/options.vue
Normal file
143
app/src/interfaces/repeater/options.vue
Normal 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>
|
||||
12
app/src/interfaces/tfa-setup/index.ts
Normal file
12
app/src/interfaces/tfa-setup/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import InterfaceTFASetup from './tfa-setup.vue';
|
||||
import { defineInterface } from '@/interfaces/define';
|
||||
|
||||
export default defineInterface({
|
||||
id: 'tfa-setup',
|
||||
name: 'tfa-setup',
|
||||
icon: 'box',
|
||||
component: InterfaceTFASetup,
|
||||
types: ['text'],
|
||||
options: [],
|
||||
system: true,
|
||||
});
|
||||
148
app/src/interfaces/tfa-setup/tfa-setup.vue
Normal file
148
app/src/interfaces/tfa-setup/tfa-setup.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-checkbox block :input-value="!!tfaEnabled" @click.native="toggle">
|
||||
{{ $t('enabled') }}
|
||||
<div class="spacer" />
|
||||
<template #append>
|
||||
<v-icon name="launch" class="checkbox-icon" :class="{ enabled: tfaEnabled }" />
|
||||
</template>
|
||||
</v-checkbox>
|
||||
|
||||
<v-dialog persistent v-model="enableActive">
|
||||
<v-card>
|
||||
<v-progress-circular class="loader" indeterminate v-if="loading === true" />
|
||||
<template v-show="loading === false">
|
||||
<v-card-title>
|
||||
{{ $t('tfa_scan_code') }}
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<canvas class="qr" :id="canvasID" />
|
||||
<output class="secret">{{ secret }}</output>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-button @click="enableActive = false">{{ $t('done') }}</v-button>
|
||||
</v-card-actions>
|
||||
</template>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-dialog persistent v-model="disableActive">
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
{{ $t('enter_otp_to_disable_tfa') }}
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-input type="text" :placeholder="$t('otp')" v-model="otp" />
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-button class="disable" :loading="loading" @click="disableTFA" :disabled="otp.length !== 6">
|
||||
{{ $t('disable_tfa') }}
|
||||
</v-button>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, watch } from '@vue/composition-api';
|
||||
import api from '@/api';
|
||||
import qrcode from 'qrcode';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const tfaEnabled = ref(!!props.value);
|
||||
const enableActive = ref(false);
|
||||
const disableActive = ref(false);
|
||||
const loading = ref(false);
|
||||
const canvasID = nanoid();
|
||||
const secret = ref<string>();
|
||||
const otp = ref('');
|
||||
const error = ref<any>();
|
||||
|
||||
watch(
|
||||
() => props.value,
|
||||
() => {
|
||||
tfaEnabled.value = !!props.value;
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
return { tfaEnabled, toggle, enableActive, disableActive, loading, canvasID, secret, disableTFA, otp, error };
|
||||
|
||||
function toggle() {
|
||||
if (tfaEnabled.value === false) {
|
||||
enableActive.value = true;
|
||||
enableTFA();
|
||||
} else {
|
||||
disableActive.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function enableTFA() {
|
||||
loading.value = true;
|
||||
const response = await api.post('/users/me/tfa/enable');
|
||||
const url = response.data.data.otpauth_url;
|
||||
secret.value = response.data.data.secret;
|
||||
await qrcode.toCanvas(document.getElementById(canvasID), url);
|
||||
loading.value = false;
|
||||
tfaEnabled.value = true;
|
||||
}
|
||||
|
||||
async function disableTFA() {
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
await api.post('/users/me/tfa/disable', { otp: otp.value });
|
||||
|
||||
tfaEnabled.value = false;
|
||||
disableActive.value = false;
|
||||
} catch (err) {
|
||||
error.value = err;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.checkbox-icon {
|
||||
--v-icon-color: var(--foreground-subdued);
|
||||
|
||||
&.enabled {
|
||||
--v-icon-color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.loader {
|
||||
margin: 100px auto;
|
||||
}
|
||||
|
||||
.qr {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.secret {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
color: var(--foreground-subdued);
|
||||
font-family: var(--family-monospace);
|
||||
letter-spacing: 2.6px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.disable {
|
||||
--v-button-background-color: var(--warning);
|
||||
--v-button-background-color-hover: var(--warning-125);
|
||||
}
|
||||
</style>
|
||||
@@ -12,10 +12,9 @@ export type InterfaceConfig = {
|
||||
relationship?: null | 'm2o' | 'o2m' | 'm2m';
|
||||
hideLabel?: boolean;
|
||||
hideLoader?: boolean;
|
||||
system?: boolean;
|
||||
};
|
||||
|
||||
export type InterfaceContext = { i18n: VueI18n };
|
||||
|
||||
export type InterfaceDefineParam = InterfaceDefineParamGeneric<InterfaceConfig>;
|
||||
|
||||
export type InterfaceDefineParamGeneric<T> = T | ((context: InterfaceContext) => T);
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
/* stylelint-disable font-family-no-missing-generic-family-keyword */
|
||||
|
||||
.tox .tox-tbtn {
|
||||
margin: 2px 0 4px 0;
|
||||
}
|
||||
|
||||
.tox .tox-tbtn svg {
|
||||
fill: var(--foreground-normal);
|
||||
}
|
||||
@@ -84,14 +88,14 @@
|
||||
.tox .tox-toolbar,
|
||||
.tox .tox-toolbar__primary,
|
||||
.tox .tox-toolbar__overflow {
|
||||
background: url("data:image/svg+xml;charset=utf8,%3Csvg height='39px' viewBox='0 0 40 39px' width='40' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='0' y='37px' width='100' height='2' fill='%23cfd8dc'/%3E%3C/svg%3E")
|
||||
background: url("data:image/svg+xml;charset=utf8,%3Csvg height='40px' viewBox='0 0 40 40px' width='40' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='0' y='38px' width='100' height='2' fill='%23cfd8dc'/%3E%3C/svg%3E")
|
||||
left 0 top 0 var(--background-subdued);
|
||||
}
|
||||
|
||||
body.dark .tox .tox-toolbar,
|
||||
body.dark .tox .tox-toolbar__primary,
|
||||
body.dark .tox .tox-toolbar__overflow {
|
||||
background: url("data:image/svg+xml;charset=utf8,%3Csvg height='39px' viewBox='0 0 40 39px' width='40' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='0' y='37px' width='100' height='2' fill='%23455a64'/%3E%3C/svg%3E")
|
||||
background: url("data:image/svg+xml;charset=utf8,%3Csvg height='40px' viewBox='0 0 40 40px' width='40' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='0' y='38px' width='100' height='2' fill='%23455a64'/%3E%3C/svg%3E")
|
||||
left 0 top 0 var(--background-subdued);
|
||||
}
|
||||
|
||||
@@ -99,7 +103,7 @@ body.dark .tox .tox-toolbar__overflow {
|
||||
body.auto .tox .tox-toolbar,
|
||||
body.auto .tox .tox-toolbar__primary,
|
||||
body.auto .tox .tox-toolbar__overflow {
|
||||
background: url("data:image/svg+xml;charset=utf8,%3Csvg height='39px' viewBox='0 0 40 39px' width='40' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='0' y='37px' width='100' height='2' fill='%23455a64'/%3E%3C/svg%3E")
|
||||
background: url("data:image/svg+xml;charset=utf8,%3Csvg height='40px' viewBox='0 0 40 40px' width='40' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='0' y='38px' width='100' height='2' fill='%23455a64'/%3E%3C/svg%3E")
|
||||
left 0 top 0 var(--background-subdued);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,8 +16,37 @@
|
||||
|
||||
"rename_folder": "Rename Folder",
|
||||
|
||||
"public": "Public",
|
||||
"public_description": "Controls what API data is available without authenticating.",
|
||||
|
||||
"not_allowed": "Not Allowed",
|
||||
|
||||
"move_to_trash": "Move to Trash",
|
||||
"move_to_trash_confirm": "Are you sure you want to move this item to the trash?",
|
||||
"move_to_trash_confirm_count": "No Items Selected | Are you sure you want to move this item to the trash? | Are you sure you want to move these {count} items to the trash?",
|
||||
|
||||
"nested_files_folders_will_be_moved": "Nested files and folders will be moved one level up.",
|
||||
|
||||
"markdown": "Markdown",
|
||||
"tabbed": "Tabbed",
|
||||
|
||||
"all_access": "All Access",
|
||||
"no_access": "No Access",
|
||||
"use_custom": "Use Custom",
|
||||
"edit_custom": "Edit Custom",
|
||||
|
||||
"item_permissions": "Item Permissions",
|
||||
"field_permissions": "Field Permissions",
|
||||
"field_validation": "Field Validation",
|
||||
"field_presets": "Field Presets",
|
||||
|
||||
"permissions_for_role": "Items the {role} Role can {action}.",
|
||||
"fields_for_role": "Fields the {role} Role can {action}.",
|
||||
"validation_for_role": "Field {action} rules the {role} Role must obey.",
|
||||
"presets_for_role": "Field value defaults for the {role} Role.",
|
||||
|
||||
"presentation_and_aliases": "Presentation & Aliases",
|
||||
|
||||
"revision_post_update": "Here is what this item looked like after the update...",
|
||||
"changes_made": "These are the specific changes that were made...",
|
||||
"no_relational_data": "Keep in mind that this does not include relational data.",
|
||||
@@ -413,9 +442,15 @@
|
||||
|
||||
"select_an_item": "Select an item...",
|
||||
"edit": "Edit",
|
||||
"preview": "Preview",
|
||||
|
||||
"enabled": "Enabled",
|
||||
|
||||
"enable_tfa": "Enable 2FA",
|
||||
"disable_tfa": "Disable 2FA",
|
||||
"tfa_scan_code": "Scan the code in your authenticator app to finish setting up 2FA",
|
||||
"enter_otp_to_disable_tfa": "Enter the OTP to disable 2FA",
|
||||
|
||||
"use_bold_style": "Use bold style",
|
||||
|
||||
"formatted_value": "Formatted Value",
|
||||
@@ -423,22 +458,16 @@
|
||||
"auto_format_casing": "Auto-format casing",
|
||||
|
||||
"errors": {
|
||||
"3": "Only super admins have access to this",
|
||||
"4": "Super Admin Token not provided",
|
||||
"11": "Can't Reach Database",
|
||||
"12": "Field has invalid regular expression",
|
||||
"100": "Incorrect Email/Password",
|
||||
"101": "Logged-out from Inactivity",
|
||||
"102": "Logged-out from Inactivity",
|
||||
"103": "User Suspended",
|
||||
"105": "Reset link expired",
|
||||
"106": "Incorrect Email/Password",
|
||||
"107": "User Not Found",
|
||||
"111": "Enter One-Time Password",
|
||||
"112": "Wrong One-Time Password",
|
||||
"114": "Incorrect Email/Password",
|
||||
"115": "SSO is not allowed when 2FA is enabled",
|
||||
"503": "Email couldn't be sent. Please verify the API's configuration",
|
||||
"COLLECTION_NOT_FOUND": "Collection doesn't exist.",
|
||||
"FIELD_NOT_FOUND": "Field not found.",
|
||||
"NO_PERMISSION": "Forbidden.",
|
||||
"INVALID_CREDENTIALS": "Wrong username or password.",
|
||||
"INVALID_OTP": "Wrong one-time password.",
|
||||
"INVALID_PAYLOAD": "Invalid payload.",
|
||||
"INVALID_QUERY": "Invalid query.",
|
||||
"ITEM_LIMIT_REACHED": "Item limit reached.",
|
||||
"ITEM_NOT_FOUND": "Item not found.",
|
||||
"ROUTE_NOT_FOUND": "Not found.",
|
||||
"-1": "Couldn't Reach API"
|
||||
},
|
||||
|
||||
@@ -455,8 +484,8 @@
|
||||
|
||||
"presets": "Presets",
|
||||
|
||||
"unexpected_error": "An unexpected error occured",
|
||||
"unexpected_error_copy": "Something went wrong.. Please try again later.",
|
||||
"unexpected_error": "Unexpected Error",
|
||||
"unexpected_error_copy": "An unexpected error has occured. Please try again later.",
|
||||
"copy_details": "Copy Details",
|
||||
|
||||
"no_app_access": "No App Access",
|
||||
@@ -760,19 +789,19 @@
|
||||
"keep_editing": "Keep Editing",
|
||||
|
||||
"page_help_collections_overview": "**Collections Overview** — Lists of all collections you have access to.",
|
||||
"page_help_collections_browse": "**Browse Items** — Lists all {collection} items you have access to. Customize layout, filters, and sorting to tailor your view, and even save bookmarks of these different configurations for quick access.<br><br><a href='https://docs.directus.io/guides/user-guide.html#items' target='_blank'>Learn More</a>",
|
||||
"page_help_collections_browse": "**Browse Items** — Lists all {collection} items you have access to. Customize layout, filters, and sorting to tailor your view, and even save bookmarks of these different configurations for quick access.",
|
||||
"page_help_collections_detail": "**Item Detail** — A form for viewing and managing this item. This sidebar also contains a full history of revisions, and embedded comments.",
|
||||
"page_help_activity_browse": "**Browse Activity** — A comprehensive listing of all your user's system and content activity.",
|
||||
"page_help_activity_detail": "**Activity Detail** — Shows accountability info, revision data, and the update message for this activity record.",
|
||||
"page_help_files_browse": "**File Library** — Lists all file assets uploaded to this project. Customize layout, filters, and sorting to tailor your view, and even save bookmarks of these different configurations for quick access.",
|
||||
"page_help_files_detail": "**File Detail** — A form for managing file metadata, editing the original asset, and updating access settings.",
|
||||
"page_help_settings_project": "**Project Settings** — Your project's global configuration options.<br><br><a href='https://docs.directus.io/guides/admin-guide.html#global-settings' target='_blank'>Learn More</a>",
|
||||
"page_help_settings_project": "**Project Settings** — Your project's global configuration options.",
|
||||
"page_help_settings_datamodel_collections": "**Data Model: Collections** — Lists all collections available. This includes visible, hidden, and system collections, as well as unmanaged database tables that can be added.",
|
||||
"page_help_settings_datamodel_fields": "**Data Model: Collection** — A form for managing this collection and its fields.",
|
||||
"page_help_settings_roles_browse": "**Browse Roles** — Lists the Admin, Public and custom User Roles.",
|
||||
"page_help_settings_roles_detail": "**Role Detail** — Manage a role's permissions and other settings.",
|
||||
"page_help_settings_presets_browse": "**Browse Presets** — Lists all presets in the project, including: user, role, and global bookmarks, as well as default views.",
|
||||
"page_help_settings_presets_detail": "**Preset Detail** — A form for managing bookmarks and default collection presets.<br><br>To create a default preset, choose a role... TK TK",
|
||||
"page_help_settings_presets_detail": "**Preset Detail** — A form for managing bookmarks and default collection presets.",
|
||||
"page_help_settings_webhooks_browse": "**Browse Webhooks** — Lists all webhooks within the project.",
|
||||
"page_help_settings_webhooks_detail": "**Webhook Detail** — A form for creating and managing project webhooks.",
|
||||
"page_help_users_browse": "**User Directory** — Lists all system users within this project.",
|
||||
@@ -912,7 +941,7 @@
|
||||
"connection": "Connection",
|
||||
"contains": "Contains",
|
||||
"continue": "Continue",
|
||||
"continue_as": "<b>{name}</b> is already authenticated for this project. If you recognize this account, please press continue.",
|
||||
"continue_as": "<b>{name}</b> is already authenticated. If you recognize this account, press continue.",
|
||||
"creating_item": "Creating Item",
|
||||
"creating_item_page_title": "Creating Item: {collection}",
|
||||
"creating_role": "Creating Role",
|
||||
@@ -964,6 +993,12 @@
|
||||
"dialog_beginning": "Beginning of dialog window.",
|
||||
"display_name": "Display Name",
|
||||
"directus_version": "Directus Version",
|
||||
"server_stack": "Server Stack",
|
||||
"operating_system": "Operating System",
|
||||
"installed_on": "Installed On",
|
||||
"database_client": "Database Client",
|
||||
"database_host": "Database Host",
|
||||
"database_port": "Database Port",
|
||||
"done": "Done",
|
||||
"dont_manage": "Don't Manage",
|
||||
"dont_manage_copy": "Privileges, preferences, and settings for this collection will be permanently removed from the system! Are you sure?",
|
||||
@@ -1102,6 +1137,7 @@
|
||||
"no_items_selected": "No items selected",
|
||||
"no_related_entries": "Has no related entries",
|
||||
"not_authenticated": "Not Authenticated",
|
||||
"authenticated": "Authenticated",
|
||||
"not_between": "Not between",
|
||||
"not_contains": "Doesn't contain",
|
||||
"not_empty": "Is not empty",
|
||||
|
||||
@@ -100,13 +100,13 @@ export async function setLanguage(lang: Language): Promise<boolean> {
|
||||
|
||||
export default i18n;
|
||||
|
||||
export function translateAPIError(error: RequestError | number) {
|
||||
export function translateAPIError(error: RequestError | string) {
|
||||
const defaultMsg = i18n.t('unexpected_error');
|
||||
|
||||
let code = error;
|
||||
|
||||
if (typeof error === 'object') {
|
||||
code = error?.response?.data?.error?.code;
|
||||
code = error?.response?.data?.errors?.[0]?.extensions?.code;
|
||||
}
|
||||
|
||||
if (!error) return defaultMsg;
|
||||
|
||||
@@ -334,8 +334,8 @@ export default defineComponent({
|
||||
const limit = createViewQueryOption<number>('limit', 25);
|
||||
|
||||
const fields = computed<string[]>(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const fields = [primaryKeyField.value!.field];
|
||||
if (!primaryKeyField.value) return [];
|
||||
const fields = [primaryKeyField.value.field];
|
||||
|
||||
if (imageSource.value) {
|
||||
fields.push(`${imageSource.value}.type`);
|
||||
@@ -386,6 +386,7 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
function getLinkForItem(item: Record<string, any>) {
|
||||
if (!primaryKeyField.value) return;
|
||||
return `/collections/${props.collection}/${item[primaryKeyField.value!.field]}`;
|
||||
}
|
||||
|
||||
@@ -401,6 +402,8 @@ export default defineComponent({
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/mixins/breakpoint';
|
||||
|
||||
.layout-cards {
|
||||
padding: var(--content-padding);
|
||||
padding-top: 0;
|
||||
@@ -446,9 +449,14 @@ export default defineComponent({
|
||||
|
||||
.item-count {
|
||||
position: relative;
|
||||
display: none;
|
||||
margin: 0 8px;
|
||||
color: var(--foreground-subdued);
|
||||
white-space: nowrap;
|
||||
|
||||
@include breakpoint(small) {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
<div class="cards-header">
|
||||
<div class="start">
|
||||
<div class="selected" v-if="_selection.length > 0" @click="_selection = []">
|
||||
<v-icon name="cancel" />
|
||||
<v-icon name="cancel" outline />
|
||||
<span class="label">{{ $tc('n_items_selected', _selection.length) }}</span>
|
||||
</div>
|
||||
<button class="select-all" v-else @click="$emit('select-all')">
|
||||
<v-icon name="check_circle" />
|
||||
<v-icon name="check_circle" outline />
|
||||
<span class="label">{{ $t('select_all') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
:server-sort="itemCount === limit || totalPages > 1"
|
||||
:item-key="primaryKeyField.field"
|
||||
:show-manual-sort="_filters && _filters.length === 0 && sortField !== null"
|
||||
:manual-sort-key="sortField && sortField.field"
|
||||
:manual-sort-key="sortField"
|
||||
selection-use-keys
|
||||
@click:row="onRowClick"
|
||||
@update:sort="onSortChange"
|
||||
@@ -227,7 +227,7 @@ export default defineComponent({
|
||||
page,
|
||||
fields: fieldsWithRelational,
|
||||
filters: _filters,
|
||||
searchQuery,
|
||||
searchQuery: _searchQuery,
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -304,8 +304,7 @@ export default defineComponent({
|
||||
|
||||
const sort = computed({
|
||||
get() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return _viewQuery.value?.sort || primaryKeyField.value!.field;
|
||||
return _viewQuery.value?.sort || primaryKeyField.value?.field;
|
||||
},
|
||||
set(newSort: string) {
|
||||
page.value = 1;
|
||||
@@ -337,13 +336,15 @@ export default defineComponent({
|
||||
if (typeof _viewQuery.value.fields === 'string') {
|
||||
return (_viewQuery.value.fields as string).split(',');
|
||||
}
|
||||
|
||||
if (Array.isArray(_viewQuery.value.fields)) return _viewQuery.value.fields;
|
||||
}
|
||||
|
||||
const fields =
|
||||
_viewQuery.value?.fields ||
|
||||
availableFields.value
|
||||
.filter((field: Field) => {
|
||||
return field.schema?.is_primary_key === false && field.meta.special !== 'sort';
|
||||
return field.schema?.is_primary_key === false;
|
||||
})
|
||||
.slice(0, 4)
|
||||
.map(({ field }) => field);
|
||||
@@ -365,7 +366,7 @@ export default defineComponent({
|
||||
|
||||
function useTable() {
|
||||
const tableSort = computed(() => {
|
||||
if (sort.value.startsWith('-')) {
|
||||
if (sort.value?.startsWith('-')) {
|
||||
return { by: sort.value.substring(1), desc: true };
|
||||
} else {
|
||||
return { by: sort.value, desc: false };
|
||||
@@ -499,6 +500,8 @@ export default defineComponent({
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/mixins/breakpoint';
|
||||
|
||||
.layout-tabular {
|
||||
display: contents;
|
||||
margin: var(--content-padding);
|
||||
@@ -574,6 +577,11 @@ export default defineComponent({
|
||||
margin: 0 8px;
|
||||
color: var(--foreground-subdued);
|
||||
white-space: nowrap;
|
||||
display: none;
|
||||
|
||||
@include breakpoint(small) {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
|
||||
@@ -64,7 +64,6 @@
|
||||
{{ $t('login') }}
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
|
||||
</v-list>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
import ActivityNavigation from './navigation.vue';
|
||||
|
||||
export { ActivityNavigation };
|
||||
export default ActivityNavigation;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineModule } from '@/modules/define';
|
||||
import ActivityBrowse from './routes/browse/';
|
||||
import ActivityDetail from './routes/detail/';
|
||||
import ActivityBrowse from './routes/browse.vue';
|
||||
import ActivityDetail from './routes/detail.vue';
|
||||
|
||||
export default defineModule(({ i18n }) => ({
|
||||
id: 'activity',
|
||||
|
||||
@@ -34,20 +34,17 @@
|
||||
|
||||
<template #drawer>
|
||||
<drawer-detail icon="info_outline" :title="$t('information')" close>
|
||||
<div class="format-markdown" v-html="marked($t('page_help_activity_browse'))" />
|
||||
<div class="page-description" v-html="marked($t('page_help_activity_browse'))" />
|
||||
</drawer-detail>
|
||||
<layout-drawer-detail @input="viewType = $event" :value="viewType" />
|
||||
<portal-target name="drawer" />
|
||||
<drawer-detail icon="help_outline" :title="$t('help_and_docs')">
|
||||
<div class="format-markdown" v-html="marked($t('page_help_activity_browse'))" />
|
||||
</drawer-detail>
|
||||
</template>
|
||||
</private-view>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, ref } from '@vue/composition-api';
|
||||
import ActivityNavigation from '../../components/navigation/';
|
||||
import ActivityNavigation from '../components/navigation.vue';
|
||||
import { i18n } from '@/lang';
|
||||
import { LayoutComponent } from '@/layouts/types';
|
||||
import usePreset from '@/composables/use-collection-preset';
|
||||
@@ -1,4 +0,0 @@
|
||||
import ActivityBrowse from './browse.vue';
|
||||
|
||||
export { ActivityBrowse };
|
||||
export default ActivityBrowse;
|
||||
@@ -1,4 +0,0 @@
|
||||
import ActivityDetail from './detail.vue';
|
||||
|
||||
export { ActivityDetail };
|
||||
export default ActivityDetail;
|
||||
@@ -24,13 +24,24 @@
|
||||
<v-list-item-content>{{ bookmark.title }}</v-list-item-content>
|
||||
</v-list-item>
|
||||
</template>
|
||||
|
||||
<div v-if="!customNavItems && !navItems.length && !bookmarks.length" class="empty">
|
||||
<template v-if="isAdmin">
|
||||
<v-button fullWidth outlined dashed to="/settings/data-model/+">{{ $t('create_collection') }}</v-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t('no_collections_copy') }}
|
||||
</template>
|
||||
</div>
|
||||
|
||||
</v-list>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed } from '@vue/composition-api';
|
||||
import useNavigation from '../../composables/use-navigation';
|
||||
import useNavigation from '../composables/use-navigation';
|
||||
import { usePresetsStore } from '@/stores/';
|
||||
import { useUserStore } from '@/stores';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
@@ -42,6 +53,8 @@ export default defineComponent({
|
||||
setup() {
|
||||
const presetsStore = usePresetsStore();
|
||||
const { customNavItems, navItems } = useNavigation();
|
||||
const userStore = useUserStore();
|
||||
const isAdmin = computed(() => userStore.state.currentUser?.role.admin === true);
|
||||
|
||||
const bookmarks = computed(() => {
|
||||
return presetsStore.state.collectionPresets
|
||||
@@ -56,7 +69,7 @@ export default defineComponent({
|
||||
});
|
||||
});
|
||||
|
||||
return { navItems, bookmarks, customNavItems };
|
||||
return { navItems, bookmarks, customNavItems, isAdmin };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -66,4 +79,12 @@ export default defineComponent({
|
||||
padding-left: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--foreground-subdued);
|
||||
.v-button {
|
||||
--v-button-background-color: var(--foreground-subdued);
|
||||
--v-button-background-color-hover: var(--primary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +0,0 @@
|
||||
import CollectionsNavigation from './navigation.vue';
|
||||
|
||||
export { CollectionsNavigation };
|
||||
export default CollectionsNavigation;
|
||||
@@ -37,7 +37,7 @@ export default function useNavigation() {
|
||||
const navItem: NavItem = {
|
||||
collection: collection,
|
||||
name: collectionInfo.name,
|
||||
icon: collectionInfo.meta?.icon || 'box',
|
||||
icon: collectionInfo.meta?.icon || 'label',
|
||||
to: `/collections/${collection}`,
|
||||
};
|
||||
|
||||
@@ -56,7 +56,7 @@ export default function useNavigation() {
|
||||
const navItem: NavItem = {
|
||||
collection: collection.collection,
|
||||
name: collection.name,
|
||||
icon: collection.meta?.icon || 'box',
|
||||
icon: collection.meta?.icon || 'label',
|
||||
to: `/collections/${collection.collection}`,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { defineModule } from '@/modules/define';
|
||||
import CollectionsOverview from './routes/overview/';
|
||||
import CollectionsBrowseOrDetail from './routes/browse-or-detail/';
|
||||
import CollectionsDetail from './routes/detail/';
|
||||
import CollectionsItemNotFound from './routes/not-found';
|
||||
import CollectionsOverview from './routes/overview.vue';
|
||||
import CollectionsBrowseOrDetail from './routes/browse-or-detail.vue';
|
||||
import CollectionsDetail from './routes/detail.vue';
|
||||
import CollectionsItemNotFound from './routes/not-found.vue';
|
||||
import { NavigationGuard } from 'vue-router';
|
||||
|
||||
const checkForSystem: NavigationGuard = (to, from, next) => {
|
||||
@@ -69,4 +69,5 @@ export default defineModule(({ i18n }) => ({
|
||||
beforeEnter: checkForSystem,
|
||||
},
|
||||
],
|
||||
order: 5,
|
||||
}));
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, computed } from '@vue/composition-api';
|
||||
import Vue from 'vue';
|
||||
import CollectionsBrowse from '../browse';
|
||||
import CollectionsDetail from '../detail';
|
||||
import CollectionsBrowse from './browse.vue';
|
||||
import CollectionsDetail from './detail.vue';
|
||||
import { useCollectionsStore } from '@/stores/';
|
||||
|
||||
export default defineComponent({
|
||||
@@ -1,4 +0,0 @@
|
||||
import CollectionsBrowseOrDetail from './browse-or-detail.vue';
|
||||
|
||||
export { CollectionsBrowseOrDetail };
|
||||
export default CollectionsBrowseOrDetail;
|
||||
@@ -1,3 +0,0 @@
|
||||
# Browse or Detail
|
||||
|
||||
Renders either the browse page or the detail page depending on whether or not the collection is a singleton
|
||||
@@ -48,8 +48,15 @@
|
||||
|
||||
<v-dialog v-model="confirmDelete" v-if="selection.length > 0">
|
||||
<template #activator="{ on }">
|
||||
<v-button rounded icon class="action-delete" @click="on" v-tooltip.bottom="$t('delete')">
|
||||
<v-icon name="delete" />
|
||||
<v-button
|
||||
:disabled="batchDeleteAllowed !== true"
|
||||
rounded
|
||||
icon
|
||||
class="action-delete"
|
||||
@click="on"
|
||||
v-tooltip.bottom="batchDeleteAllowed ? $t('delete') : $t('not_allowed')"
|
||||
>
|
||||
<v-icon name="delete_forever" outline />
|
||||
</v-button>
|
||||
</template>
|
||||
|
||||
@@ -67,18 +74,56 @@
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-dialog
|
||||
v-model="confirmSoftDelete"
|
||||
v-if="selection.length > 0 && currentCollection.meta && currentCollection.meta.soft_delete_field"
|
||||
>
|
||||
<template #activator="{ on }">
|
||||
<v-button
|
||||
:disabled="batchSoftDeleteAllowed !== true"
|
||||
rounded
|
||||
icon
|
||||
class="action-soft-delete"
|
||||
@click="on"
|
||||
v-tooltip.bottom="batchEditAllowed ? $t('move_to_trash') : $t('not_allowed')"
|
||||
>
|
||||
<v-icon name="delete" outline />
|
||||
</v-button>
|
||||
</template>
|
||||
|
||||
<v-card>
|
||||
<v-card-title>{{ $tc('move_to_trash_confirm_count', selection.length) }}</v-card-title>
|
||||
|
||||
<v-card-actions>
|
||||
<v-button @click="confirmSoftDelete = false" secondary>
|
||||
{{ $t('cancel') }}
|
||||
</v-button>
|
||||
<v-button @click="batchDelete" class="action-soft-delete" :loading="softDeleting">
|
||||
{{ $t('move_to_trash') }}
|
||||
</v-button>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-button
|
||||
rounded
|
||||
icon
|
||||
class="action-batch"
|
||||
v-if="selection.length > 1"
|
||||
:to="batchLink"
|
||||
v-tooltip.bottom="$t('edit')"
|
||||
v-tooltip.bottom="batchEditAllowed ? $t('edit') : $t('not_allowed')"
|
||||
:disabled="batchEditAllowed === false"
|
||||
>
|
||||
<v-icon name="edit" />
|
||||
<v-icon name="edit" outline />
|
||||
</v-button>
|
||||
|
||||
<v-button rounded icon :to="addNewLink" v-tooltip.bottom="$t('create_item')">
|
||||
<v-button
|
||||
rounded
|
||||
icon
|
||||
:to="addNewLink"
|
||||
v-tooltip.bottom="createAllowed ? $t('create_item') : $t('not_allowed')"
|
||||
:disabled="createAllowed === false"
|
||||
>
|
||||
<v-icon name="add" />
|
||||
</v-button>
|
||||
</template>
|
||||
@@ -138,13 +183,8 @@
|
||||
|
||||
<template #drawer>
|
||||
<drawer-detail icon="info_outline" :title="$t('information')" close>
|
||||
Page Info Here...
|
||||
</drawer-detail>
|
||||
<layout-drawer-detail @input="viewType = $event" :value="viewType" />
|
||||
<portal-target name="drawer" />
|
||||
<drawer-detail icon="help_outline" :title="$t('help_and_docs')">
|
||||
<div
|
||||
class="format-markdown"
|
||||
class="page-description"
|
||||
v-html="
|
||||
marked(
|
||||
$t('page_help_collections_browse', {
|
||||
@@ -154,16 +194,30 @@
|
||||
"
|
||||
/>
|
||||
</drawer-detail>
|
||||
<layout-drawer-detail @input="viewType = $event" :value="viewType" />
|
||||
<portal-target name="drawer" />
|
||||
</template>
|
||||
|
||||
<v-dialog v-if="deleteError" active>
|
||||
<v-card>
|
||||
<v-card-title>{{ $t('something_went_wrong') }}</v-card-title>
|
||||
<v-card-text>
|
||||
<v-error :error="deleteError" />
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-button @click="deleteError = null">{{ $t('done') }}</v-button>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</private-view>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, ref, watch, toRefs } from '@vue/composition-api';
|
||||
import CollectionsNavigation from '../../components/navigation/';
|
||||
import CollectionsNavigation from '../components/navigation.vue';
|
||||
import api from '@/api';
|
||||
import { LayoutComponent } from '@/layouts/types';
|
||||
import CollectionsNotFound from '../not-found/';
|
||||
import CollectionsNotFound from './not-found.vue';
|
||||
import useCollection from '@/composables/use-collection';
|
||||
import usePreset from '@/composables/use-collection-preset';
|
||||
import LayoutDrawerDetail from '@/views/private/components/layout-drawer-detail';
|
||||
@@ -172,6 +226,7 @@ import BookmarkAdd from '@/views/private/components/bookmark-add';
|
||||
import BookmarkEdit from '@/views/private/components/bookmark-edit';
|
||||
import router from '@/router';
|
||||
import marked from 'marked';
|
||||
import { usePermissionsStore, useUserStore } from '@/stores';
|
||||
|
||||
type Item = {
|
||||
[field: string]: any;
|
||||
@@ -198,6 +253,8 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const userStore = useUserStore();
|
||||
const permissionsStore = usePermissionsStore();
|
||||
const layout = ref<LayoutComponent | null>(null);
|
||||
|
||||
const { collection } = toRefs(props);
|
||||
@@ -218,7 +275,15 @@ export default defineComponent({
|
||||
saveCurrentAsBookmark,
|
||||
title: bookmarkName,
|
||||
} = usePreset(collection, bookmarkID);
|
||||
const { confirmDelete, deleting, batchDelete } = useBatchDelete();
|
||||
const {
|
||||
confirmDelete,
|
||||
deleting,
|
||||
batchDelete,
|
||||
confirmSoftDelete,
|
||||
softDelete,
|
||||
softDeleting,
|
||||
error: deleteError,
|
||||
} = useBatchDelete();
|
||||
|
||||
const {
|
||||
bookmarkDialogActive,
|
||||
@@ -238,6 +303,8 @@ export default defineComponent({
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const { batchEditAllowed, batchSoftDeleteAllowed, batchDeleteAllowed, createAllowed } = usePermissions();
|
||||
|
||||
return {
|
||||
addNewLink,
|
||||
batchDelete,
|
||||
@@ -265,6 +332,14 @@ export default defineComponent({
|
||||
breadcrumb,
|
||||
marked,
|
||||
clearFilters,
|
||||
confirmSoftDelete,
|
||||
softDelete,
|
||||
softDeleting,
|
||||
batchEditAllowed,
|
||||
batchSoftDeleteAllowed,
|
||||
batchDeleteAllowed,
|
||||
deleteError,
|
||||
createAllowed,
|
||||
};
|
||||
|
||||
function useBreadcrumb() {
|
||||
@@ -294,28 +369,50 @@ export default defineComponent({
|
||||
const confirmDelete = ref(false);
|
||||
const deleting = ref(false);
|
||||
|
||||
return { confirmDelete, deleting, batchDelete };
|
||||
const confirmSoftDelete = ref(false);
|
||||
const softDeleting = ref(false);
|
||||
|
||||
const error = ref<any>();
|
||||
|
||||
return { confirmDelete, deleting, batchDelete, confirmSoftDelete, softDeleting, softDelete, error };
|
||||
|
||||
async function batchDelete() {
|
||||
deleting.value = true;
|
||||
|
||||
confirmDelete.value = false;
|
||||
|
||||
const batchPrimaryKeys = selection.value;
|
||||
|
||||
try {
|
||||
await api.delete(`/items/${props.collection}/${batchPrimaryKeys}`);
|
||||
|
||||
await layout.value?.refresh?.();
|
||||
|
||||
selection.value = [];
|
||||
confirmDelete.value = false;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
error.value = err;
|
||||
} finally {
|
||||
deleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function softDelete() {
|
||||
if (!currentCollection.value?.meta?.soft_delete_field) return;
|
||||
|
||||
softDeleting.value = true;
|
||||
|
||||
const batchPrimaryKeys = selection.value;
|
||||
|
||||
try {
|
||||
await api.patch(`/items/${props.collection}/${batchPrimaryKeys}`);
|
||||
await layout.value?.refresh?.();
|
||||
|
||||
selection.value = [];
|
||||
confirmSoftDelete.value = false;
|
||||
} catch (err) {
|
||||
error.value = err;
|
||||
} finally {
|
||||
softDeleting.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function useLinks() {
|
||||
@@ -377,6 +474,54 @@ export default defineComponent({
|
||||
filters.value = [];
|
||||
searchQuery.value = null;
|
||||
}
|
||||
|
||||
function usePermissions() {
|
||||
const batchEditAllowed = computed(() => {
|
||||
const admin = userStore.state?.currentUser?.role.admin === true;
|
||||
if (admin) return true;
|
||||
|
||||
const updatePermissions = permissionsStore.state.permissions.find(
|
||||
(permission) => permission.action === 'update' && permission.collection === collection.value
|
||||
);
|
||||
return !!updatePermissions;
|
||||
});
|
||||
|
||||
const batchSoftDeleteAllowed = computed(() => {
|
||||
if (!currentCollection.value?.meta?.soft_delete_field) return false;
|
||||
const admin = userStore.state?.currentUser?.role.admin === true;
|
||||
if (admin) return true;
|
||||
|
||||
const updatePermissions = permissionsStore.state.permissions.find(
|
||||
(permission) => permission.action === 'update' && permission.collection === collection.value
|
||||
);
|
||||
if (!updatePermissions) return false;
|
||||
if (!updatePermissions.fields) return false;
|
||||
if (updatePermissions.fields === '*') return true;
|
||||
return updatePermissions.fields.split(',').includes(currentCollection.value.meta.soft_delete_field);
|
||||
});
|
||||
|
||||
const batchDeleteAllowed = computed(() => {
|
||||
const admin = userStore.state?.currentUser?.role.admin === true;
|
||||
if (admin) return true;
|
||||
|
||||
const deletePermissions = permissionsStore.state.permissions.find(
|
||||
(permission) => permission.action === 'delete' && permission.collection === collection.value
|
||||
);
|
||||
return !!deletePermissions;
|
||||
});
|
||||
|
||||
const createAllowed = computed(() => {
|
||||
const admin = userStore.state?.currentUser?.role.admin === true;
|
||||
if (admin) return true;
|
||||
|
||||
const createPermissions = permissionsStore.state.permissions.find(
|
||||
(permission) => permission.action === 'create' && permission.collection === collection.value
|
||||
);
|
||||
return !!createPermissions;
|
||||
});
|
||||
|
||||
return { batchEditAllowed, batchSoftDeleteAllowed, batchDeleteAllowed, createAllowed };
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -389,6 +534,13 @@ export default defineComponent({
|
||||
--v-button-color-hover: var(--danger);
|
||||
}
|
||||
|
||||
.action-soft-delete {
|
||||
--v-button-background-color: var(--warning-25);
|
||||
--v-button-color: var(--warning);
|
||||
--v-button-background-color-hover: var(--warning-50);
|
||||
--v-button-color-hover: var(--warning);
|
||||
}
|
||||
|
||||
.action-batch {
|
||||
--v-button-background-color: var(--warning-25);
|
||||
--v-button-color: var(--warning);
|
||||
@@ -1,4 +0,0 @@
|
||||
import CollectionsBrowse from './browse.vue';
|
||||
|
||||
export { CollectionsBrowse };
|
||||
export default CollectionsBrowse;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user