diff --git a/.eslintignore b/.eslintignore index f06235c460..b98800ed6f 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,2 +1,3 @@ node_modules dist +app/vite.config.js diff --git a/.eslintrc.js b/.eslintrc.js index a98c7f4f43..f0428946c8 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -25,7 +25,7 @@ module.exports = { overrides: [ // Parse rollup configration as module { - files: ['rollup.config.js'], + files: ['rollup.config.js', 'vite.config.js'], parserOptions: { sourceType: 'module', }, @@ -38,7 +38,7 @@ module.exports = { parser: '@typescript-eslint/parser', }, extends: [ - 'plugin:vue/essential', + 'plugin:vue/vue3-essential', 'eslint:recommended', 'plugin:@typescript-eslint/recommended', 'plugin:prettier-vue/recommended', @@ -58,8 +58,6 @@ module.exports = { '@typescript-eslint/no-non-null-assertion': 0, // Allow unused variables when they begin with an underscore '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], - // Disable validity checks on v-slot directive (consider to enable this rule later on) - 'vue/valid-v-slot': 0, }, }, ], diff --git a/.stylelintrc.json b/.stylelintrc.json index 2d65959985..541eb314fd 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -1,29 +1,22 @@ { - "extends": [ - "stylelint-config-standard", - "stylelint-config-rational-order", - "stylelint-config-prettier" - ], + "extends": ["stylelint-config-standard", "stylelint-config-rational-order", "stylelint-config-prettier"], "plugins": ["stylelint-order", "stylelint-scss"], "rules": { "indentation": "tab", - "order/order": [ - "dollar-variables", - "custom-properties", - "declarations", - "at-variables", - "rules" - ], + "order/order": ["dollar-variables", "custom-properties", "declarations", "at-variables", "rules"], "at-rule-no-unknown": null, "scss/at-rule-no-unknown": true, - "selector-pseudo-element-no-unknown": [ + "selector-pseudo-class-no-unknown": [ true, { - "ignorePseudoElements": ["v-deep"] + "ignorePseudoClasses": ["deep", "slotted", "global"] } ], "string-quotes": "single", "length-zero-no-unit": null, - "no-descending-specificity": true + "no-descending-specificity": true, + "rule-empty-line-before": ["always", { "except": "first-nested" }], + "block-closing-brace-empty-line-before": "never", + "block-opening-brace-newline-after": "always-multi-line" } } diff --git a/api/example.env b/api/example.env index 86ca3ffe95..9249c9483c 100644 --- a/api/example.env +++ b/api/example.env @@ -143,6 +143,7 @@ EMAIL_SENDMAIL_PATH="/usr/sbin/sendmail" # EMAIL_SMTP_HOST="localhost" # EMAIL_SMTP_PORT=465 # EMAIL_SMTP_SECURE=false # Use TLS +# EMAIL_SMTP_IGNORE_TLS=false # EMAIL_SMTP_USER="username" # EMAIL_SMTP_PASSWORD="password" diff --git a/api/package.json b/api/package.json index 6de84a92f4..830098f2e7 100644 --- a/api/package.json +++ b/api/package.json @@ -1,6 +1,6 @@ { "name": "directus", - "version": "9.0.0-rc.73", + "version": "9.0.0-rc.75", "license": "GPL-3.0-only", "homepage": "https://github.com/directus/directus#readme", "description": "Directus is a real-time API and App dashboard for managing SQL database content.", @@ -66,14 +66,14 @@ "example.env" ], "dependencies": { - "@directus/app": "9.0.0-rc.73", - "@directus/drive": "9.0.0-rc.73", - "@directus/drive-azure": "9.0.0-rc.73", - "@directus/drive-gcs": "9.0.0-rc.73", - "@directus/drive-s3": "9.0.0-rc.73", - "@directus/format-title": "9.0.0-rc.73", - "@directus/schema": "9.0.0-rc.73", - "@directus/specs": "9.0.0-rc.73", + "@directus/app": "9.0.0-rc.75", + "@directus/drive": "9.0.0-rc.75", + "@directus/drive-azure": "9.0.0-rc.75", + "@directus/drive-gcs": "9.0.0-rc.75", + "@directus/drive-s3": "9.0.0-rc.75", + "@directus/format-title": "9.0.0-rc.75", + "@directus/schema": "9.0.0-rc.75", + "@directus/specs": "9.0.0-rc.75", "@godaddy/terminus": "^4.9.0", "argon2": "^0.28.1", "async": "^3.2.0", @@ -90,9 +90,9 @@ "date-fns": "^2.21.1", "deep-map": "^2.0.0", "destroy": "^1.0.4", - "dotenv": "^9.0.2", + "dotenv": "^10.0.0", "eventemitter2": "^6.4.3", - "execa": "^5.0.1", + "execa": "^5.1.1", "exif-reader": "^1.0.3", "express": "^4.17.1", "express-pino-logger": "^6.0.0", @@ -110,13 +110,14 @@ "jsonwebtoken": "^8.5.1", "keyv": "^4.0.3", "knex": "^0.95.6", - "knex-schema-inspector": "^1.5.6", + "knex-schema-inspector": "^1.5.7", "liquidjs": "^9.25.0", "lodash": "^4.17.21", "macos-release": "^2.4.1", "mime-types": "^2.1.31", "ms": "^2.1.3", "nanoid": "^3.1.23", + "node-cron": "^3.0.0", "node-machine-id": "^1.1.12", "nodemailer": "^6.6.1", "openapi3-ts": "^2.0.0", @@ -135,7 +136,7 @@ "optionalDependencies": { "@keyv/redis": "^2.1.2", "connect-memcached": "^1.0.0", - "connect-redis": "^5.2.0", + "connect-redis": "^6.0.0", "connect-session-knex": "^2.1.0", "ioredis": "^4.27.2", "keyv-memcache": "^1.2.5", @@ -169,9 +170,10 @@ "@types/mime-types": "^2.1.0", "@types/ms": "^0.7.31", "@types/node": "^15.12.0", + "@types/node-cron": "^2.0.3", "@types/nodemailer": "^6.4.1", "@types/qs": "^6.9.6", - "@types/sharp": "^0.28.1", + "@types/sharp": "^0.28.3", "@types/stream-json": "^1.7.0", "@types/uuid": "^8.3.0", "@types/uuid-validate": "^0.0.1", diff --git a/api/src/app.ts b/api/src/app.ts index 3b41921aa0..25ba402862 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -110,10 +110,9 @@ export default async function createApp(): Promise { const adminPath = require.resolve('@directus/app/dist/index.html'); const publicUrl = env.PUBLIC_URL.endsWith('/') ? env.PUBLIC_URL : env.PUBLIC_URL + '/'; - // Prefix all href/src in the index html with the APIs public path + // Set the App's base path according to the APIs public URL let html = fse.readFileSync(adminPath, 'utf-8'); - html = html.replace(/href="\//g, `href="${publicUrl}`); - html = html.replace(/src="\//g, `src="${publicUrl}`); + html = html.replace(//, `\n\t\t`); app.get('/', (req, res, next) => { if (env.ROOT_REDIRECT) { diff --git a/api/src/controllers/users.ts b/api/src/controllers/users.ts index 96ce928c46..b148471f72 100644 --- a/api/src/controllers/users.ts +++ b/api/src/controllers/users.ts @@ -296,7 +296,7 @@ router.post( ); router.post( - '/me/tfa/enable/', + '/me/tfa/generate/', asyncHandler(async (req, res, next) => { if (!req.accountability?.user) { throw new InvalidCredentialsException(); @@ -317,7 +317,7 @@ router.post( }); await authService.verifyPassword(req.accountability.user, req.body.password); - const { url, secret } = await service.enableTFA(req.accountability.user); + const { url, secret } = await service.generateTFA(req.accountability.user); res.locals.payload = { data: { secret, otpauth_url: url } }; return next(); @@ -325,6 +325,33 @@ router.post( respond ); +router.post( + '/me/tfa/enable/', + asyncHandler(async (req, res, next) => { + if (!req.accountability?.user) { + throw new InvalidCredentialsException(); + } + + if (!req.body.secret) { + throw new InvalidPayloadException(`"secret" is required`); + } + + if (!req.body.otp) { + throw new InvalidPayloadException(`"otp" is required`); + } + + const service = new UsersService({ + accountability: req.accountability, + schema: req.schema, + }); + + await service.enableTFA(req.accountability.user, req.body.otp, req.body.secret); + + return next(); + }), + respond +); + router.post( '/me/tfa/disable', asyncHandler(async (req, res, next) => { diff --git a/api/src/database/migrations/20210608A-add-deep-clone-config.ts b/api/src/database/migrations/20210608A-add-deep-clone-config.ts new file mode 100644 index 0000000000..fa9affe79f --- /dev/null +++ b/api/src/database/migrations/20210608A-add-deep-clone-config.ts @@ -0,0 +1,13 @@ +import { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable('directus_collections', (table) => { + table.json('item_duplication_fields').nullable(); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable('directus_collections', (table) => { + table.dropColumn('item_duplication_fields'); + }); +} diff --git a/api/src/database/system-data/fields/collections.yaml b/api/src/database/system-data/fields/collections.yaml index aa2c2193e8..c6ef4294f5 100644 --- a/api/src/database/system-data/fields/collections.yaml +++ b/api/src/database/system-data/fields/collections.yaml @@ -179,3 +179,19 @@ fields: - text: '$t:field_options.directus_collections.do_not_track_anything' value: null width: half + + - field: duplication_divider + special: + - alias + - no-data + interface: presentation-divider + options: + icon: content_copy + title: Duplication + + - field: item_duplication_fields + special: + - json + interface: code + options: + language: JSON diff --git a/api/src/env.ts b/api/src/env.ts index 25b9873a37..5873a5e1de 100644 --- a/api/src/env.ts +++ b/api/src/env.ts @@ -7,11 +7,10 @@ import dotenv from 'dotenv'; import fs from 'fs'; import { clone, toNumber, toString } from 'lodash'; import path from 'path'; -import logger from './logger'; import { requireYAML } from './utils/require-yaml'; import { toArray } from './utils/to-array'; -const acceptableEnvTypes = ['string', 'number', 'regex', 'array']; +const acceptedEnvTypes = ['string', 'number', 'regex', 'array']; const defaults: Record = { CONFIG_PATH: path.resolve(process.cwd(), '.env'), @@ -125,7 +124,7 @@ function getEnv() { return exported; } - logger.warn( + throw new Error( `Invalid JS configuration file export type. Requires one of "function", "object", received: "${typeof exported}"` ); } @@ -141,11 +140,11 @@ function getEnv() { return data as Record; } - logger.warn('Invalid YAML configuration. Root has to ben an object.'); + throw new Error('Invalid YAML configuration. Root has to be an object.'); } // Default to env vars plain text files - return dotenv.parse(fs.readFileSync(configPath).toString()); + return dotenv.parse(fs.readFileSync(configPath, { encoding: 'utf8' })); } function getVariableType(variable: string) { @@ -175,12 +174,33 @@ function getEnvironmentValueByType(envVariableString: string) { function processValues(env: Record) { env = clone(env); - for (const [key, value] of Object.entries(env)) { - if (typeof value === 'string' && acceptableEnvTypes.some((envType) => value.includes(`${envType}:`))) { + for (let [key, value] of Object.entries(env)) { + // If key ends with '_FILE', try to get the value from the file defined in this variable + // and store it in the variable with the same name but without '_FILE' at the end + let newKey; + if (key.length > 5 && key.endsWith('_FILE')) { + try { + value = fs.readFileSync(value, { encoding: 'utf8' }); + newKey = key.slice(0, -5); + if (newKey in env) { + throw new Error( + `Duplicate environment variable encountered: you can't use "${key}" and "${newKey}" simultaneously.` + ); + } + key = newKey; + } catch { + throw new Error(`Failed to read value from file "${value}", defined in environment variable "${key}".`); + } + } + + // Convert values with a type prefix + // (see https://docs.directus.io/reference/environment-variables/#environment-syntax-prefix) + if (typeof value === 'string' && acceptedEnvTypes.some((envType) => value.includes(`${envType}:`))) { env[key] = getEnvironmentValueByType(value); continue; } + // Convert values where the key is defined in typeMap if (typeMap[key]) { switch (typeMap[key]) { case 'number': @@ -193,14 +213,42 @@ function processValues(env: Record) { env[key] = toArray(value); break; } - continue; } - if (value === 'true') env[key] = true; - if (value === 'false') env[key] = false; - if (value === 'null') env[key] = null; - if (String(value).startsWith('0') === false && isNaN(value) === false && value.length > 0) env[key] = Number(value); + // Try to convert remaining values: + // - boolean values to boolean + // - 'null' to null + // - number values (> 0 <= Number.MAX_SAFE_INTEGER) to number + if (value === 'true') { + env[key] = true; + continue; + } + + if (value === 'false') { + env[key] = false; + continue; + } + + if (value === 'null') { + env[key] = null; + continue; + } + + if ( + String(value).startsWith('0') === false && + isNaN(value) === false && + value.length > 0 && + value <= Number.MAX_SAFE_INTEGER + ) { + env[key] = Number(value); + continue; + } + + // If '_FILE' variable hasn't been processed yet, store it as it is (string) + if (newKey) { + env[key] = value; + } } return env; diff --git a/api/src/extensions.ts b/api/src/extensions.ts index 279d9f733b..4e5b971306 100644 --- a/api/src/extensions.ts +++ b/api/src/extensions.ts @@ -11,6 +11,7 @@ import * as services from './services'; import { EndpointRegisterFunction, HookRegisterFunction } from './types'; import { getSchema } from './utils/get-schema'; import listFolders from './utils/list-folders'; +import { schedule, validate } from 'node-cron'; export async function ensureFoldersExist(): Promise { const folders = ['endpoints', 'hooks', 'interfaces', 'modules', 'layouts', 'displays']; @@ -94,8 +95,19 @@ function registerHooks(hooks: string[]) { } const events = register({ services, exceptions, env, database: getDatabase(), getSchema }); + for (const [event, handler] of Object.entries(events)) { - emitter.on(event, handler); + if (event.startsWith('cron(')) { + const cron = event.match(/\(([^)]+)\)/)?.[1]; + + if (!cron || validate(cron) === false) { + logger.warn(`Couldn't register cron hook. Provided cron is invalid: ${cron}`); + } else { + schedule(cron, handler); + } + } else { + emitter.on(event, handler); + } } } } diff --git a/api/src/grant.ts b/api/src/grant.ts index bb1814f678..e8b702a961 100644 --- a/api/src/grant.ts +++ b/api/src/grant.ts @@ -4,6 +4,7 @@ import env from './env'; import { toArray } from './utils/to-array'; +import { getConfigFromEnv } from './utils/get-config-from-env'; const enabledProviders = toArray(env.OAUTH_PROVIDERS).map((provider) => provider.toLowerCase()); @@ -16,23 +17,8 @@ const config: any = { }, }; -for (const [key, value] of Object.entries(env)) { - if (key.startsWith('OAUTH') === false) continue; - - const parts = key.split('_'); - const provider = parts[1].toLowerCase(); - - if (enabledProviders.includes(provider) === false) continue; - - // OAUTH SETTING = VALUE - parts.splice(0, 2); - - const configKey = parts.join('_').toLowerCase(); - - config[provider] = { - ...(config[provider] || {}), - [configKey]: value, - }; +for (const provider of enabledProviders) { + config[provider] = getConfigFromEnv(`OAUTH_${provider.toUpperCase()}_`, undefined, 'underscore'); } export default config; diff --git a/api/src/mailer.ts b/api/src/mailer.ts index 1926768058..1b62abb55d 100644 --- a/api/src/mailer.ts +++ b/api/src/mailer.ts @@ -2,54 +2,48 @@ import nodemailer, { Transporter } from 'nodemailer'; import env from './env'; import logger from './logger'; -let transporter: Transporter | null = null; +let transporter: Transporter; -if (env.EMAIL_TRANSPORT === 'sendmail') { - transporter = nodemailer.createTransport({ - sendmail: true, - newline: env.EMAIL_SENDMAIL_NEW_LINE || 'unix', - path: env.EMAIL_SENDMAIL_PATH || '/usr/sbin/sendmail', - }); -} else if (env.EMAIL_TRANSPORT.toLowerCase() === 'smtp') { - let auth: boolean | { user?: string; pass?: string } = false; +export default function getMailer(): Transporter { + if (transporter) return transporter; - if (env.EMAIL_SMTP_USER || env.EMAIL_SMTP_PASSWORD) { - auth = { - user: env.EMAIL_SMTP_USER, - pass: env.EMAIL_SMTP_PASSWORD, - }; + if (env.EMAIL_TRANSPORT === 'sendmail') { + transporter = nodemailer.createTransport({ + sendmail: true, + newline: env.EMAIL_SENDMAIL_NEW_LINE || 'unix', + path: env.EMAIL_SENDMAIL_PATH || '/usr/sbin/sendmail', + }); + } else if (env.EMAIL_TRANSPORT.toLowerCase() === 'smtp') { + let auth: boolean | { user?: string; pass?: string } = false; + + if (env.EMAIL_SMTP_USER || env.EMAIL_SMTP_PASSWORD) { + auth = { + user: env.EMAIL_SMTP_USER, + pass: env.EMAIL_SMTP_PASSWORD, + }; + } + + transporter = nodemailer.createTransport({ + pool: env.EMAIL_SMTP_POOL, + host: env.EMAIL_SMTP_HOST, + port: env.EMAIL_SMTP_PORT, + secure: env.EMAIL_SMTP_SECURE, + ignoreTLS: env.EMAIL_SMTP_IGNORE_TLS, + auth: auth, + } as Record); + } else if (env.EMAIL_TRANSPORT.toLowerCase() === 'mailgun') { + const mg = require('nodemailer-mailgun-transport'); + transporter = nodemailer.createTransport( + mg({ + auth: { + api_key: env.EMAIL_MAILGUN_API_KEY, + domain: env.EMAIL_MAILGUN_DOMAIN, + }, + }) as any + ); + } else { + logger.warn('Illegal transport given for email. Check the EMAIL_TRANSPORT env var.'); } - transporter = nodemailer.createTransport({ - pool: env.EMAIL_SMTP_POOL, - host: env.EMAIL_SMTP_HOST, - port: env.EMAIL_SMTP_PORT, - secure: env.EMAIL_SMTP_SECURE, - auth: auth, - } as Record); -} else if (env.EMAIL_TRANSPORT.toLowerCase() === 'mailgun') { - const mg = require('nodemailer-mailgun-transport'); - transporter = nodemailer.createTransport( - mg({ - auth: { - api_key: env.EMAIL_MAILGUN_API_KEY, - domain: env.EMAIL_MAILGUN_DOMAIN, - }, - }) as any - ); -} else { - logger.warn('Illegal transport given for email. Check the EMAIL_TRANSPORT env var.'); + return transporter; } - -if (transporter) { - transporter.verify((error) => { - if (error) { - logger.warn(`Couldn't connect to email server.`); - logger.warn(`Email verification error: ${error}`); - } else { - logger.info(`Email connection established`); - } - }); -} - -export default transporter; diff --git a/api/src/services/authentication.ts b/api/src/services/authentication.ts index 413cc301b7..d30d58d590 100644 --- a/api/src/services/authentication.ts +++ b/api/src/services/authentication.ts @@ -248,20 +248,25 @@ export class AuthenticationService { } async generateOTPAuthURL(pk: string, secret: string): Promise { - const user = await this.knex.select('first_name', 'last_name').from('directus_users').where({ id: pk }).first(); - const name = `${user.first_name} ${user.last_name}`; - return authenticator.keyuri(name, 'Directus', secret); + const user = await this.knex.select('email').from('directus_users').where({ id: pk }).first(); + const project = await this.knex.select('project_name').from('directus_settings').limit(1).first(); + return authenticator.keyuri(user.email, project?.project_name || 'Directus', secret); } - async verifyOTP(pk: string, otp: string): Promise { - const user = await this.knex.select('tfa_secret').from('directus_users').where({ id: pk }).first(); + async verifyOTP(pk: string, otp: string, secret?: string): Promise { + let tfaSecret: string; + if (!secret) { + const user = await this.knex.select('tfa_secret').from('directus_users').where({ id: pk }).first(); - if (!user.tfa_secret) { - throw new InvalidPayloadException(`User "${pk}" doesn't have TFA enabled.`); + if (!user.tfa_secret) { + throw new InvalidPayloadException(`User "${pk}" doesn't have TFA enabled.`); + } + tfaSecret = user.tfa_secret; + } else { + tfaSecret = secret; } - const secret = user.tfa_secret; - return authenticator.check(otp, secret); + return authenticator.check(otp, tfaSecret); } async verifyPassword(pk: string, password: string): Promise { diff --git a/api/src/services/collections.ts b/api/src/services/collections.ts index 0c136e6306..6ed9c097df 100644 --- a/api/src/services/collections.ts +++ b/api/src/services/collections.ts @@ -399,6 +399,19 @@ export class CollectionsService { } } + const m2aRelationsThatIncludeThisCollection = this.schema.relations.filter((relation) => { + return relation.meta?.one_allowed_collections?.includes(collectionKey); + }); + + for (const relation of m2aRelationsThatIncludeThisCollection) { + const newAllowedCollections = relation + .meta!.one_allowed_collections!.filter((collection) => collectionKey !== collection) + .join(','); + await trx('directus_relations') + .update({ one_allowed_collections: newAllowedCollections }) + .where({ id: relation.meta!.id }); + } + await collectionItemsService.deleteOne(collectionKey); await trx.schema.dropTable(collectionKey); }); diff --git a/api/src/services/fields.ts b/api/src/services/fields.ts index c2d866cfb3..ec0d9c89ce 100644 --- a/api/src/services/fields.ts +++ b/api/src/services/fields.ts @@ -417,8 +417,6 @@ export class FieldsService { if (field.schema?.has_auto_increment) { column = table.increments(field.field); - } else if (field.schema?.data_type) { - column = table.specificType(field.field, field.schema.data_type); } else if (field.type === 'string') { column = table.string(field.field, field.schema?.max_length ?? undefined); } else if (['float', 'decimal'].includes(field.type)) { diff --git a/api/src/services/graphql.ts b/api/src/services/graphql.ts index 63e22d3fd7..c22f36ac3b 100644 --- a/api/src/services/graphql.ts +++ b/api/src/services/graphql.ts @@ -1481,9 +1481,9 @@ export class GraphQLService { return true; }, }, - users_me_tfa_enable: { + users_me_tfa_generate: { type: new GraphQLObjectType({ - name: 'users_me_tfa_enable_data', + name: 'users_me_tfa_generate_data', fields: { secret: { type: GraphQLString }, otpauth_url: { type: GraphQLString }, @@ -1503,10 +1503,27 @@ export class GraphQLService { schema: this.schema, }); await authService.verifyPassword(this.accountability.user, args.password); - const { url, secret } = await service.enableTFA(this.accountability.user); + const { url, secret } = await service.generateTFA(this.accountability.user); return { secret, otpauth_url: url }; }, }, + users_me_tfa_enable: { + type: GraphQLBoolean, + args: { + otp: GraphQLNonNull(GraphQLString), + secret: GraphQLNonNull(GraphQLString), + }, + resolve: async (_, args) => { + if (!this.accountability?.user) return null; + const service = new UsersService({ + accountability: this.accountability, + schema: this.schema, + }); + + await service.enableTFA(this.accountability.user, args.otp, args.secret); + return true; + }, + }, users_me_tfa_disable: { type: GraphQLBoolean, args: { diff --git a/api/src/services/mail/index.ts b/api/src/services/mail/index.ts index 8cd29da5d4..4569a5870e 100644 --- a/api/src/services/mail/index.ts +++ b/api/src/services/mail/index.ts @@ -7,8 +7,8 @@ import env from '../../env'; import { InvalidPayloadException } from '../../exceptions'; import logger from '../../logger'; import { AbstractServiceOptions, Accountability, SchemaOverview } from '../../types'; -import mailer from '../../mailer'; -import { SendMailOptions } from 'nodemailer'; +import getMailer from '../../mailer'; +import { Transporter, SendMailOptions } from 'nodemailer'; const liquidEngine = new Liquid({ root: [path.resolve(env.EXTENSIONS_PATH, 'templates'), path.resolve(__dirname, 'templates')], @@ -26,16 +26,23 @@ export class MailService { schema: SchemaOverview; accountability: Accountability | null; knex: Knex; + mailer: Transporter; constructor(opts: AbstractServiceOptions) { this.schema = opts.schema; this.accountability = opts.accountability || null; this.knex = opts?.knex || getDatabase(); + this.mailer = getMailer(); + + this.mailer.verify((error) => { + if (error) { + logger.warn(`Email connection failed:`); + logger.warn(error); + } + }); } async send(options: EmailOptions): Promise { - if (!mailer) return; - const { template, ...emailOptions } = options; let { html } = options; @@ -55,7 +62,7 @@ export class MailService { } try { - await mailer.sendMail({ ...emailOptions, from, html }); + await this.mailer.sendMail({ ...emailOptions, from, html }); } catch (error) { logger.warn('[Email] Unexpected error while sending an email:'); logger.warn(error); diff --git a/api/src/services/server.ts b/api/src/services/server.ts index 20129a3742..2bf4ef5913 100644 --- a/api/src/services/server.ts +++ b/api/src/services/server.ts @@ -14,7 +14,7 @@ import { rateLimiter } from '../middleware/rate-limiter'; import storage from '../storage'; import { AbstractServiceOptions, Accountability, SchemaOverview } from '../types'; import { toArray } from '../utils/to-array'; -import mailer from '../mailer'; +import getMailer from '../mailer'; import { SettingsService } from './settings'; export class ServerService { @@ -316,8 +316,10 @@ export class ServerService { ], }; + const mailer = getMailer(); + try { - await mailer?.verify(); + await mailer.verify(); } catch (err) { checks['email:connection'][0].status = 'error'; checks['email:connection'][0].output = err; diff --git a/api/src/services/users.ts b/api/src/services/users.ts index 667d14a942..7088dcc505 100644 --- a/api/src/services/users.ts +++ b/api/src/services/users.ts @@ -10,10 +10,12 @@ import { ForbiddenException, InvalidPayloadException, UnprocessableEntityException, + InvalidCredentialsException, } from '../exceptions'; import { RecordNotUniqueException } from '../exceptions/database/record-not-unique'; import logger from '../logger'; import { AbstractServiceOptions, Accountability, Item, PrimaryKey, Query, SchemaOverview } from '../types'; +import isUrlAllowed from '../utils/is-url-allowed'; import { toArray } from '../utils/to-array'; import { AuthenticationService } from './authentication'; import { ItemsService, MutationOptions } from './items'; @@ -226,9 +228,7 @@ export class UsersService extends ItemsService { async inviteUser(email: string | string[], role: string, url: string | null, subject?: string | null): Promise { const emails = toArray(email); - const urlWhitelist = toArray(env.USER_INVITE_URL_ALLOW_LIST); - - if (url && urlWhitelist.includes(url) === false) { + if (url && isUrlAllowed(url, env.USER_INVITE_URL_ALLOW_LIST) === false) { throw new InvalidPayloadException(`Url "${url}" can't be used to invite users.`); } @@ -305,9 +305,7 @@ export class UsersService extends ItemsService { const payload = { email, scope: 'password-reset' }; const token = jwt.sign(payload, env.SECRET as string, { expiresIn: '1d' }); - const urlWhitelist = toArray(env.PASSWORD_RESET_URL_ALLOW_LIST); - - if (url && urlWhitelist.includes(url) === false) { + if (url && isUrlAllowed(url, env.PASSWORD_RESET_URL_ALLOW_LIST) === false) { throw new InvalidPayloadException(`Url "${url}" can't be used to reset passwords.`); } @@ -350,7 +348,7 @@ export class UsersService extends ItemsService { } } - async enableTFA(pk: string): Promise> { + async generateTFA(pk: string): Promise> { const user = await this.knex.select('tfa_secret').from('directus_users').where({ id: pk }).first(); if (user?.tfa_secret !== null) { @@ -364,14 +362,36 @@ export class UsersService extends ItemsService { }); const secret = authService.generateTFASecret(); - await this.knex('directus_users').update({ tfa_secret: secret }).where({ id: pk }); - return { secret, url: await authService.generateOTPAuthURL(pk, secret), }; } + async enableTFA(pk: string, otp: string, secret: string): Promise { + const authService = new AuthenticationService({ + schema: this.schema, + }); + + if (!pk) { + throw new InvalidCredentialsException(); + } + + const otpValid = await authService.verifyOTP(pk, otp, secret); + + if (otpValid === false) { + throw new InvalidPayloadException(`"otp" is invalid`); + } + + const userSecret = await this.knex.select('tfa_secret').from('directus_users').where({ id: pk }).first(); + + if (userSecret?.tfa_secret !== null) { + throw new InvalidPayloadException('TFA Secret is already set for this user'); + } + + await this.knex('directus_users').update({ tfa_secret: secret }).where({ id: pk }); + } + async disableTFA(pk: string): Promise { await this.knex('directus_users').update({ tfa_secret: null }).where({ id: pk }); } diff --git a/api/src/types/collection.ts b/api/src/types/collection.ts index 6d16a42d1d..e589efb85e 100644 --- a/api/src/types/collection.ts +++ b/api/src/types/collection.ts @@ -8,6 +8,7 @@ export type CollectionMeta = { singleton: boolean; icon: string | null; translations: Record; + item_duplication_fields: string[] | null; accountability: 'all' | 'accountability' | null; }; diff --git a/api/src/utils/get-config-from-env.ts b/api/src/utils/get-config-from-env.ts index 89c26ed26b..e7580b7ea9 100644 --- a/api/src/utils/get-config-from-env.ts +++ b/api/src/utils/get-config-from-env.ts @@ -2,7 +2,11 @@ import camelcase from 'camelcase'; import { set } from 'lodash'; import env from '../env'; -export function getConfigFromEnv(prefix: string, omitPrefix?: string | string[]): any { +export function getConfigFromEnv( + prefix: string, + omitPrefix?: string | string[], + type: 'camelcase' | 'underscore' = 'camelcase' +): Record { const config: any = {}; for (const [key, value] of Object.entries(env)) { @@ -23,12 +27,22 @@ export function getConfigFromEnv(prefix: string, omitPrefix?: string | string[]) if (key.includes('__')) { const path = key .split('__') - .map((key, index) => (index === 0 ? camelcase(camelcase(key.slice(prefix.length))) : camelcase(key))); + .map((key, index) => (index === 0 ? transform(transform(key.slice(prefix.length))) : transform(key))); set(config, path.join('.'), value); } else { - config[camelcase(key.slice(prefix.length))] = value; + config[transform(key.slice(prefix.length))] = value; } } return config; + + function transform(key: string): string { + if (type === 'camelcase') { + return camelcase(key); + } else if (type === 'underscore') { + return key.toLowerCase(); + } + + return key; + } } diff --git a/api/src/utils/get-local-type.ts b/api/src/utils/get-local-type.ts index e739a40331..f5759267ff 100644 --- a/api/src/utils/get-local-type.ts +++ b/api/src/utils/get-local-type.ts @@ -93,6 +93,14 @@ export default function getLocalType( ): typeof types[number] | 'unknown' { const type = localTypeMap[column.data_type.toLowerCase().split('(')[0]]; + const special = field?.special; + if (special) { + if (special.includes('json')) return 'json'; + if (special.includes('hash')) return 'hash'; + if (special.includes('csv')) return 'csv'; + if (special.includes('uuid')) return 'uuid'; + } + /** Handle Postgres numeric decimals */ if (column.data_type === 'numeric' && column.numeric_precision !== null && column.numeric_scale !== null) { return 'decimal'; @@ -103,11 +111,6 @@ export default function getLocalType( return 'text'; } - if (field?.special?.includes('json')) return 'json'; - if (field?.special?.includes('hash')) return 'hash'; - if (field?.special?.includes('csv')) return 'csv'; - if (field?.special?.includes('uuid')) return 'uuid'; - if (type) { return type.type; } diff --git a/api/src/utils/is-url-allowed.ts b/api/src/utils/is-url-allowed.ts new file mode 100644 index 0000000000..52a9018914 --- /dev/null +++ b/api/src/utils/is-url-allowed.ts @@ -0,0 +1,29 @@ +import { toArray } from './to-array'; +import logger from '../logger'; + +/** + * Check if url matches allow list either exactly or by domain+path + */ +export default function isUrlAllowed(url: string, allowList: string | string[]): boolean { + console.log(url, allowList); + + const urlAllowList = toArray(allowList); + + if (urlAllowList.includes(url)) return true; + + const parsedWhitelist = urlAllowList.map((allowedURL) => { + try { + const { hostname, pathname } = new URL(allowedURL); + return hostname + pathname; + } catch { + logger.warn(`Invalid URL used "${url}"`); + } + }); + + try { + const { hostname, pathname } = new URL(url); + return parsedWhitelist.includes(hostname + pathname); + } catch { + return false; + } +} diff --git a/app/.browserslistrc b/app/.browserslistrc deleted file mode 100644 index d6471a38cc..0000000000 --- a/app/.browserslistrc +++ /dev/null @@ -1,2 +0,0 @@ -> 1% -last 2 versions diff --git a/app/.gitignore b/app/.gitignore index e282d9bb44..53f7466aca 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -1,22 +1,5 @@ node_modules -/dist -coverage -public/img/docs - -# local env files -.env.local -.env.*.local - -# Log files -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# Editor directories and files -.idea -.vscode -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? +.DS_Store +dist +dist-ssr +*.local \ No newline at end of file diff --git a/app/babel.config.js b/app/babel.config.js deleted file mode 100644 index 7da92c5fa0..0000000000 --- a/app/babel.config.js +++ /dev/null @@ -1,12 +0,0 @@ -module.exports = { - presets: [ - [ - '@vue/app', - { - targets: { esmodules: true }, - polyfills: [], - }, - ], - ], - plugins: ['@babel/plugin-proposal-optional-chaining'], -}; diff --git a/app/index.html b/app/index.html new file mode 100644 index 0000000000..8e0471e0e8 --- /dev/null +++ b/app/index.html @@ -0,0 +1,149 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Loading… + + + + + +
+ +
+ + + + + diff --git a/app/package.json b/app/package.json index f65af2bed1..33fecdbf2b 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "@directus/app", - "version": "9.0.0-rc.73", + "version": "9.0.0-rc.75", "private": false, "description": "Directus is an Open-Source Headless CMS & API for Managing Custom Databases", "author": "Rijk van Zanten ", @@ -18,28 +18,30 @@ "access": "public" }, "scripts": { + "dev": "cross-env NODE_ENV=development vite", + "build": "vite build", + "serve": "vite preview", "copy-docs-images": "rimraf public/img/docs && copyfiles -u 3 \"../docs/assets/**/*\" \"public/img/docs\" --verbose", "predev": "npm run copy-docs-images", "prebuild": "npm run copy-docs-images", - "dev": "vue-cli-service serve", - "build": "vue-cli-service build", "prepublishOnly": "npm run build" }, "gitHead": "24621f3934dc77eb23441331040ed13c676ceffd", "devDependencies": { - "@directus/docs": "9.0.0-rc.73", - "@directus/format-title": "9.0.0-rc.73", + "@directus/docs": "9.0.0-rc.75", + "@directus/format-title": "9.0.0-rc.75", "@fullcalendar/core": "^5.7.2", "@fullcalendar/daygrid": "^5.7.2", "@fullcalendar/interaction": "^5.7.2", "@fullcalendar/list": "^5.7.2", "@fullcalendar/timegrid": "^5.7.2", - "@popperjs/core": "^2.9.1", + "@popperjs/core": "^2.9.2", + "@rollup/plugin-yaml": "^3.0.0", "@sindresorhus/slugify": "^2.1.0", - "@tinymce/tinymce-vue": "^3.2.8", + "@tinymce/tinymce-vue": "^4.0.0", "@types/base-64": "^1.0.0", "@types/bytes": "^3.1.0", - "@types/codemirror": "^0.0.109", + "@types/codemirror": "^5.60.0", "@types/color": "^3.0.1", "@types/diff": "^5.0.0", "@types/dompurify": "^2.2.2", @@ -49,15 +51,14 @@ "@types/mime-types": "^2.1.0", "@types/ms": "^0.7.31", "@types/qrcode": "^1.4.0", - "@types/tiny-async-pool": "^1.0.0", + "@vitejs/plugin-vue": "^1.2.1", "@vue/cli-plugin-babel": "^4.5.13", "@vue/cli-plugin-router": "^4.5.8", "@vue/cli-plugin-typescript": "^4.5.13", "@vue/cli-plugin-vuex": "^4.5.8", "@vue/cli-service": "^4.5.13", - "@vue/composition-api": "^0.6.7", - "@vue/test-utils": "^1.2.0", - "apexcharts": "^3.26.3", + "@vue/compiler-sfc": "^3.0.5", + "apexcharts": "^3.26.3", "axios": "^0.21.1", "base-64": "^1.0.0", "codemirror": "^5.61.1", @@ -68,33 +69,24 @@ "escape-string-regexp": "^5.0.0", "front-matter": "^4.0.2", "html-entities": "^2.3.2", - "joi": "^17.4.0", "jsonlint-mod": "^1.7.6", "marked": "^2.0.7", "micromustache": "^8.0.3", + "mime": "^2.5.2", "mitt": "^2.1.0", "nanoid": "^3.1.23", - "pinia": "^0.0.7", - "portal-vue": "^2.1.7", - "prettier": "^2.3.0", + "pinia": "^2.0.0-alpha.13", + "prettier": "^2.3.1", "pretty-ms": "^7.0.1", "qrcode": "^1.4.4", - "raw-loader": "^4.0.2", - "resize-observer": "^1.0.2", "rimraf": "^3.0.2", "sass": "^1.34.1", - "sass-loader": "^9.0.2", - "stylelint": "^13.13.1", - "tiny-async-pool": "^1.2.0", - "tinymce": "^5.8.1", - "vue": "^2.6.12", - "vue-cli-plugin-yaml": "^1.0.2", - "vue-i18n": "^8.24.4", - "vue-loader": "^15.9.7", - "vue-router": "^3.4.8", - "vue-template-compiler": "^2.6.10", - "vuedraggable": "^2.24.3", - "vuepress": "^1.5.2", - "webpack-assets-manifest": "^3.1.1" + "tinymce": "^5.7.1", + "typescript": "^4.2.4", + "vite": "^2.3.7", + "vue": "^3.0.5", + "vue-i18n": "^9.1.6", + "vue-router": "^4.0.6", + "vuedraggable": "^4.0.3" } } diff --git a/app/public/.htaccess b/app/public/.htaccess deleted file mode 100644 index ae945dc0c2..0000000000 --- a/app/public/.htaccess +++ /dev/null @@ -1,12 +0,0 @@ - - - RewriteEngine on - - # If file or directory exists behave normally - RewriteCond %{REQUEST_FILENAME} !-f - RewriteCond %{REQUEST_FILENAME} !-d - - # Otherwise use index.html (you might need to update path) - RewriteRule . /index.html [L,QSA] - - diff --git a/app/public/index.html b/app/public/index.html deleted file mode 100644 index fc9b64f9be..0000000000 --- a/app/public/index.html +++ /dev/null @@ -1,53 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Loading… - - - - -
- - - diff --git a/app/src/api.ts b/app/src/api.ts index 128f92a8cd..5c2cd5cbfb 100644 --- a/app/src/api.ts +++ b/app/src/api.ts @@ -52,9 +52,7 @@ export const onError = async (error: RequestError): Promise => { // access, or that your session doesn't exist / has expired. // In case of the second, we should force the app to logout completely and redirect to the login // view. - /* istanbul ignore next */ const status = error.response?.status; - /* istanbul ignore next */ const code = error.response?.data?.errors?.[0]?.extensions?.code; if ( diff --git a/app/src/app.vue b/app/src/app.vue index 213997f66e..41daa83313 100644 --- a/app/src/app.vue +++ b/app/src/app.vue @@ -1,13 +1,13 @@ - - - - diff --git a/app/src/components/v-button/v-button.vue b/app/src/components/v-button/v-button.vue index 22312d8309..976dfdf3d3 100644 --- a/app/src/components/v-button/v-button.vue +++ b/app/src/components/v-button/v-button.vue @@ -4,19 +4,17 @@ -import { defineComponent, computed, PropType } from '@vue/composition-api'; -import { Location } from 'vue-router'; +import { defineComponent, computed, PropType } from 'vue'; +import { RouteLocation, useRoute, useLink } from 'vue-router'; import useSizeClass, { sizeProps } from '@/composables/size-class'; import { useGroupable } from '@/composables/groupable'; import { notEmpty } from '@/utils/is-empty'; +import { isEqual } from 'lodash'; export default defineComponent({ + emits: ['click'], props: { autofocus: { type: Boolean, @@ -85,17 +85,25 @@ export default defineComponent({ default: false, }, to: { - type: [String, Object] as PropType, + type: [String, Object] as PropType, default: null, }, href: { type: String, default: null, }, + active: { + type: Boolean, + default: undefined, + }, exact: { type: Boolean, default: false, }, + query: { + type: Boolean, + default: false, + }, secondary: { type: Boolean, default: false, @@ -124,6 +132,9 @@ export default defineComponent({ ...sizeProps, }, setup(props, { emit }) { + const route = useRoute(); + + const { route: linkRoute, isActive, isExactActive } = useLink(props); const sizeClass = useSizeClass(props); const component = computed<'a' | 'router-link' | 'button'>(() => { @@ -135,10 +146,26 @@ export default defineComponent({ const { active, toggle } = useGroupable({ value: props.value, - group: 'button-group', + group: 'item-group', }); - return { sizeClass, onClick, component, active, toggle }; + const isActiveRoute = computed(() => { + if (props.active !== undefined) return props.active; + + if (props.to) { + const isQueryActive = !props.query || isEqual(route.query, linkRoute.value.query); + + if (!props.exact) { + return (isActive.value && isQueryActive) || active.value; + } else { + return (isExactActive.value && isQueryActive) || active.value; + } + } + + return false; + }); + + return { sizeClass, onClick, component, isActiveRoute, toggle }; function onClick(event: MouseEvent) { if (props.loading === true) return; @@ -150,204 +177,201 @@ export default defineComponent({ }); - - diff --git a/app/src/components/v-card/v-card-actions.vue b/app/src/components/v-card/v-card-actions.vue index c17a11bc55..3e833e2e1e 100644 --- a/app/src/components/v-card/v-card-actions.vue +++ b/app/src/components/v-card/v-card-actions.vue @@ -1,15 +1,15 @@ - @@ -177,13 +204,13 @@ body { } &.dense { - ::v-deep .v-text-overflow { + :deep(.v-text-overflow) { color: var(--foreground-normal); } &:hover, &.active { - ::v-deep .v-text-overflow { + :deep(.v-text-overflow) { color: var(--primary); } } @@ -200,7 +227,7 @@ body { border-radius: var(--border-radius); transition: border-color var(--fast) var(--transition); - .v-icon { + :slotted(.v-icon) { color: var(--foreground-subdued); &:hover { @@ -208,15 +235,15 @@ body { } } - .drag-handle { + :slotted(.drag-handle) { cursor: grab; } - .drag-handle:active { + :slotted(.drag-handle:active) { cursor: grabbing; } - .spacer { + :slotted(.spacer) { flex-grow: 1; } diff --git a/app/src/components/v-list/v-list.vue b/app/src/components/v-list/v-list.vue index 7c0815cebe..fb8bb2dc9f 100644 --- a/app/src/components/v-list/v-list.vue +++ b/app/src/components/v-list/v-list.vue @@ -5,16 +5,13 @@ - - diff --git a/app/src/components/v-menu/use-popper.ts b/app/src/components/v-menu/use-popper.ts index 64caa37981..8fede4e67f 100644 --- a/app/src/components/v-menu/use-popper.ts +++ b/app/src/components/v-menu/use-popper.ts @@ -1,3 +1,4 @@ +import { createPopper } from '@popperjs/core/lib/popper-lite'; import { Instance, Modifier, Placement } from '@popperjs/core'; import arrow from '@popperjs/core/lib/modifiers/arrow'; import computeStyles from '@popperjs/core/lib/modifiers/computeStyles'; @@ -6,8 +7,7 @@ import flip from '@popperjs/core/lib/modifiers/flip'; import offset from '@popperjs/core/lib/modifiers/offset'; import popperOffsets from '@popperjs/core/lib/modifiers/popperOffsets'; import preventOverflow from '@popperjs/core/lib/modifiers/preventOverflow'; -import { createPopper } from '@popperjs/core/lib/popper-base'; -import { onUnmounted, ref, Ref, watch } from '@vue/composition-api'; +import { onUnmounted, ref, Ref, watch } from 'vue'; export function usePopper( reference: Ref, @@ -53,7 +53,7 @@ export function usePopper( popperInstance.value.forceUpdate(); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion observer.observe(popper.value!, { - attributes: true, + attributes: false, childList: true, characterData: true, subtree: true, diff --git a/app/src/components/v-menu/v-menu.vue b/app/src/components/v-menu/v-menu.vue index 6c1baebf16..022381411c 100644 --- a/app/src/components/v-menu/v-menu.vue +++ b/app/src/components/v-menu/v-menu.vue @@ -18,53 +18,54 @@ /> - -
-
-
- + + +
+
+
+ +
-
- +
+
- - - diff --git a/app/src/components/v-progress/circular/v-progress-circular.vue b/app/src/components/v-progress/circular/v-progress-circular.vue index 1f17dab46a..8d238d3cfa 100644 --- a/app/src/components/v-progress/circular/v-progress-circular.vue +++ b/app/src/components/v-progress/circular/v-progress-circular.vue @@ -23,10 +23,11 @@ - - diff --git a/app/src/components/v-sheet/v-sheet.vue b/app/src/components/v-sheet/v-sheet.vue index 8a9a2c0273..5be539f32b 100644 --- a/app/src/components/v-sheet/v-sheet.vue +++ b/app/src/components/v-sheet/v-sheet.vue @@ -5,7 +5,7 @@ - - - diff --git a/app/src/components/v-text-overflow.vue b/app/src/components/v-text-overflow.vue index b98af618e4..9de4457540 100644 --- a/app/src/components/v-text-overflow.vue +++ b/app/src/components/v-text-overflow.vue @@ -5,7 +5,7 @@ diff --git a/app/src/displays/related-values/related-values.vue b/app/src/displays/related-values/related-values.vue index 3961a4b024..3665c49214 100644 --- a/app/src/displays/related-values/related-values.vue +++ b/app/src/displays/related-values/related-values.vue @@ -18,7 +18,7 @@ - + @@ -26,15 +26,15 @@ - + diff --git a/app/src/interfaces/_system/system-display-template/system-display-template.vue b/app/src/interfaces/_system/system-display-template/system-display-template.vue index 1f78ae9e24..eb7f2ca1e4 100644 --- a/app/src/interfaces/_system/system-display-template/system-display-template.vue +++ b/app/src/interfaces/_system/system-display-template/system-display-template.vue @@ -1,18 +1,26 @@ diff --git a/app/src/interfaces/_system/system-field/system-field.vue b/app/src/interfaces/_system/system-field/system-field.vue index f274e13d59..1e62a657af 100644 --- a/app/src/interfaces/_system/system-field/system-field.vue +++ b/app/src/interfaces/_system/system-field/system-field.vue @@ -1,15 +1,15 @@ - diff --git a/app/src/views/private/components/header-bar/header-bar.vue b/app/src/views/private/components/header-bar/header-bar.vue index 4deb4a6aa6..5594e984db 100644 --- a/app/src/views/private/components/header-bar/header-bar.vue +++ b/app/src/views/private/components/header-bar/header-bar.vue @@ -1,14 +1,14 @@ diff --git a/app/src/views/private/components/module-bar-avatar/module-bar-avatar.vue b/app/src/views/private/components/module-bar-avatar/module-bar-avatar.vue index 4e00162fce..cf8b60314b 100644 --- a/app/src/views/private/components/module-bar-avatar/module-bar-avatar.vue +++ b/app/src/views/private/components/module-bar-avatar/module-bar-avatar.vue @@ -9,19 +9,19 @@ x-large :class="{ show: hover }" class="sign-out" - v-tooltip.right="$t('sign_out')" + v-tooltip.right="t('sign_out')" > - {{ $t('sign_out_confirm') }} + {{ t('sign_out_confirm') }} - {{ $t('cancel') }} + {{ t('cancel') }} - {{ $t('sign_out') }} + {{ t('sign_out') }} @@ -36,26 +36,29 @@ diff --git a/app/src/views/private/components/notification-dialogs/notification-dialogs.vue b/app/src/views/private/components/notification-dialogs/notification-dialogs.vue index 4b72966085..8ff8d7b554 100644 --- a/app/src/views/private/components/notification-dialogs/notification-dialogs.vue +++ b/app/src/views/private/components/notification-dialogs/notification-dialogs.vue @@ -1,6 +1,6 @@