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 a0abd860fc..830098f2e7 100644 --- a/api/package.json +++ b/api/package.json @@ -1,6 +1,6 @@ { "name": "directus", - "version": "9.0.0-rc.74", + "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.74", - "@directus/drive": "9.0.0-rc.74", - "@directus/drive-azure": "9.0.0-rc.74", - "@directus/drive-gcs": "9.0.0-rc.74", - "@directus/drive-s3": "9.0.0-rc.74", - "@directus/format-title": "9.0.0-rc.74", - "@directus/schema": "9.0.0-rc.74", - "@directus/specs": "9.0.0-rc.74", + "@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", diff --git a/api/src/app.ts b/api/src/app.ts index aa07e71e6e..d6a21961c8 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -104,10 +104,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/env.ts b/api/src/env.ts index f028959f4d..5873a5e1de 100644 --- a/api/src/env.ts +++ b/api/src/env.ts @@ -220,14 +220,21 @@ function processValues(env: Record) { // - boolean values to boolean // - 'null' to null // - number values (> 0 <= Number.MAX_SAFE_INTEGER) to number - if (value === 'true' || value === 'false') { - env[key] = !!value; + 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 && 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/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/package.json b/app/package.json index 3792dfa7a8..60bc2ee700 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "@directus/app", - "version": "9.0.0-rc.74", + "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,7 +18,7 @@ "access": "public" }, "scripts": { - "dev": "vite", + "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", @@ -28,8 +28,8 @@ }, "gitHead": "24621f3934dc77eb23441331040ed13c676ceffd", "devDependencies": { - "@directus/docs": "9.0.0-rc.74", - "@directus/format-title": "9.0.0-rc.74", + "@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", @@ -82,10 +82,10 @@ "sass": "^1.34.1", "tinymce": "^5.7.1", "typescript": "^4.2.4", - "vite": "^2.1.5", + "vite": "^2.3.7", "vue": "^3.0.5", "vue-i18n": "^9.1.6", "vue-router": "^4.0.6", - "vuedraggable": "^4.0.1" + "vuedraggable": "^4.0.3" } } 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/components/v-button/v-button.vue b/app/src/components/v-button/v-button.vue index 344d313deb..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'; -import { RouteLocation } from 'vue-router'; +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'], @@ -93,10 +92,18 @@ export default defineComponent({ type: String, default: null, }, + active: { + type: Boolean, + default: undefined, + }, exact: { type: Boolean, default: false, }, + query: { + type: Boolean, + default: false, + }, secondary: { type: Boolean, default: false, @@ -125,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'>(() => { @@ -139,7 +149,23 @@ export default defineComponent({ 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; @@ -157,11 +183,11 @@ export default defineComponent({ --v-button-height: 44px; --v-button-color: var(--foreground-inverted); --v-button-color-hover: var(--foreground-inverted); - --v-button-color-activated: var(--foreground-inverted); + --v-button-color-active: var(--foreground-inverted); --v-button-color-disabled: var(--foreground-subdued); --v-button-background-color: var(--primary); --v-button-background-color-hover: var(--primary-125); - --v-button-background-color-activated: var(--primary); + --v-button-background-color-active: var(--primary); --v-button-background-color-disabled: var(--background-normal); --v-button-font-size: 16px; --v-button-font-weight: 600; @@ -177,10 +203,10 @@ export default defineComponent({ .secondary { --v-button-color: var(--foreground-normal); --v-button-color-hover: var(--foreground-normal); - --v-button-color-activated: var(--foreground-normal); + --v-button-color-active: var(--foreground-normal); --v-button-background-color: var(--border-subdued); --v-button-background-color-hover: var(--background-normal-alt); - --v-button-background-color-activated: var(--background-normal-alt); + --v-button-background-color-active: var(--background-normal-alt); } .v-button.full-width { @@ -248,7 +274,7 @@ export default defineComponent({ background-color: transparent; } -.outlined:not(.activated):hover { +.outlined:not(.active):hover { color: var(--v-button-background-color-hover); background-color: transparent; border-color: var(--v-button-background-color-hover); @@ -338,12 +364,11 @@ export default defineComponent({ --v-progress-circular-background-color: transparent; } -.activated, .active { - --v-button-color: var(--v-button-color-activated) !important; - --v-button-color-hover: var(--v-button-color-activated) !important; - --v-button-background-color: var(--v-button-background-color-activated) !important; - --v-button-background-color-hover: var(--v-button-background-color-activated) !important; + --v-button-color: var(--v-button-color-active) !important; + --v-button-color-hover: var(--v-button-color-active) !important; + --v-button-background-color: var(--v-button-background-color-active) !important; + --v-button-background-color-hover: var(--v-button-background-color-active) !important; } .tile { diff --git a/app/src/components/v-drawer/v-drawer.vue b/app/src/components/v-drawer/v-drawer.vue index 19c298466e..f2068a767f 100644 --- a/app/src/components/v-drawer/v-drawer.vue +++ b/app/src/components/v-drawer/v-drawer.vue @@ -149,7 +149,7 @@ body { .header-icon { --v-button-background-color: var(--background-normal); - --v-button-background-color-activated: var(--background-normal); + --v-button-background-color-active: var(--background-normal); --v-button-background-color-hover: var(--background-normal-alt); --v-button-color-disabled: var(--foreground-normal); } diff --git a/app/src/components/v-field-select/v-field-select.vue b/app/src/components/v-field-select/v-field-select.vue index 9cb069602f..3131ab4cdc 100644 --- a/app/src/components/v-field-select/v-field-select.vue +++ b/app/src/components/v-field-select/v-field-select.vue @@ -46,7 +46,7 @@ import { useI18n } from 'vue-i18n'; import { defineComponent, toRefs, ref, PropType, computed } from 'vue'; import FieldListItem from '../v-field-template/field-list-item.vue'; import { Field, Collection, Relation } from '@/types'; -import Draggable from 'vuedraggable/src/vuedraggable.js'; +import Draggable from 'vuedraggable'; import useFieldTree from '@/composables/use-field-tree'; import useCollection from '@/composables/use-collection'; import { FieldTree } from '../v-field-template/types'; diff --git a/app/src/components/v-list/v-list-item.vue b/app/src/components/v-list/v-list-item.vue index 8dabfade3b..aa80c2f00e 100644 --- a/app/src/components/v-list/v-list-item.vue +++ b/app/src/components/v-list/v-list-item.vue @@ -2,12 +2,10 @@ -import { RouteLocation } from 'vue-router'; +import { RouteLocation, useLink, useRoute } from 'vue-router'; import { defineComponent, PropType, computed } from 'vue'; import { useGroupable } from '@/composables/groupable'; +import { isEqual } from 'lodash'; export default defineComponent({ props: { @@ -56,7 +55,7 @@ export default defineComponent({ }, active: { type: Boolean, - default: false, + default: undefined, }, dashed: { type: Boolean, @@ -66,6 +65,10 @@ export default defineComponent({ type: Boolean, default: false, }, + query: { + type: Boolean, + default: false, + }, download: { type: String, default: null, @@ -80,6 +83,10 @@ export default defineComponent({ }, }, setup(props) { + const route = useRoute(); + + const { route: linkRoute, isActive, isExactActive } = useLink(props); + const component = computed(() => { if (props.to) return 'router-link'; if (props.href) return 'a'; @@ -92,7 +99,23 @@ export default defineComponent({ const isLink = computed(() => Boolean(props.to || props.href || props.clickable)); - return { component, isLink }; + 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; + } else { + return isExactActive.value && isQueryActive; + } + } + + return false; + }); + + return { component, isLink, isActiveRoute }; }, }); @@ -204,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 { @@ -212,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-table/v-table.vue b/app/src/components/v-table/v-table.vue index 3774ad800a..930e5ff358 100644 --- a/app/src/components/v-table/v-table.vue +++ b/app/src/components/v-table/v-table.vue @@ -91,8 +91,7 @@ import TableHeader from './table-header/'; import TableRow from './table-row/'; import { sortBy, clone, forEach, pick } from 'lodash'; import { i18n } from '@/lang/'; -// @TODO Use module import once vuedraggable exports an esm build or vite fixes umd imports -import Draggable from 'vuedraggable/src/vuedraggable.js'; +import Draggable from 'vuedraggable'; import hideDragImage from '@/utils/hide-drag-image'; const HeaderDefaults: Header = { diff --git a/app/src/composables/use-form-fields/use-form-fields.ts b/app/src/composables/use-form-fields/use-form-fields.ts index 184900b233..04cf4e0353 100644 --- a/app/src/composables/use-form-fields/use-form-fields.ts +++ b/app/src/composables/use-form-fields/use-form-fields.ts @@ -5,12 +5,14 @@ import { getInterfaces } from '@/interfaces'; import { InterfaceConfig } from '@/interfaces/types'; import { Field } from '@/types'; import { getDefaultInterfaceForType } from '@/utils/get-default-interface-for-type'; -import { clone } from 'lodash'; +import { clone, orderBy } from 'lodash'; import { computed, ComputedRef, Ref } from 'vue'; export default function useFormFields(fields: Ref): { formFields: ComputedRef } { const { interfaces } = getInterfaces(); + const systemFieldsCount = computed(() => fields.value.filter((field) => field.meta?.system === true).length); + const formFields = computed(() => { let formFields = clone(fields.value); @@ -42,6 +44,10 @@ export default function useFormFields(fields: Ref): { formFields: Compu } } + if (field.meta?.sort && field.meta?.system !== true) { + field.meta.sort = field.meta.sort + systemFieldsCount.value; + } + return field; }); @@ -52,6 +58,8 @@ export default function useFormFields(fields: Ref): { formFields: Compu return hidden !== true && systemFake === false; }); + formFields = orderBy(formFields, 'meta.sort'); + return formFields; }); diff --git a/app/src/displays/formatted-value/formatted-value.vue b/app/src/displays/formatted-value/formatted-value.vue index 4c346388c8..8cd6f836d4 100644 --- a/app/src/displays/formatted-value/formatted-value.vue +++ b/app/src/displays/formatted-value/formatted-value.vue @@ -1,5 +1,5 @@ - + - + -
- - {{ t('tfa_scan_code') }} - - - - {{ secret }} - - - {{ t('done') }} - +
+
+ + {{ t('tfa_scan_code') }} + + + + {{ secret }} + + + + + {{ t('cancel') }} + {{ t('done') }} + +
- + - - {{ t('enter_otp_to_disable_tfa') }} - - - - - - - - {{ t('disable_tfa') }} - - +
+ + {{ t('enter_otp_to_disable_tfa') }} + + + + + + + + {{ t('disable_tfa') }} + + +
@@ -85,6 +92,7 @@ export default defineComponent({ const userStore = useUserStore(); const tfaEnabled = ref(!!props.value); + const tfaGenerated = ref(false); const enableActive = ref(false); const disableActive = ref(false); const loading = ref(false); @@ -110,6 +118,9 @@ export default defineComponent({ return { t, tfaEnabled, + tfaGenerated, + generateTFA, + cancelAndClose, enableTFA, toggle, password, @@ -132,17 +143,46 @@ export default defineComponent({ } } + async function generateTFA() { + if (loading.value === true) return; + + loading.value = true; + + try { + const response = await api.post('/users/me/tfa/generate', { password: password.value }); + const url = response.data.data.otpauth_url; + secret.value = response.data.data.secret; + await qrcode.toCanvas(document.getElementById(canvasID), url); + tfaGenerated.value = true; + error.value = null; + } catch (err) { + error.value = err; + } finally { + loading.value = false; + } + } + + function cancelAndClose() { + tfaGenerated.value = false; + enableActive.value = false; + password.value = ''; + otp.value = ''; + secret.value = ''; + } + async function enableTFA() { if (loading.value === true) return; loading.value = true; try { - const response = await api.post('/users/me/tfa/enable', { password: password.value }); - const url = response.data.data.otpauth_url; - secret.value = response.data.data.secret; - await qrcode.toCanvas(document.getElementById(canvasID), url); + await api.post('/users/me/tfa/enable', { otp: otp.value, secret: secret.value }); tfaEnabled.value = true; + tfaGenerated.value = false; + enableActive.value = false; + password.value = ''; + otp.value = ''; + secret.value = ''; error.value = null; } catch (err) { error.value = err; @@ -159,6 +199,7 @@ export default defineComponent({ tfaEnabled.value = false; disableActive.value = false; + otp.value = ''; } catch (err) { error.value = err; } finally { @@ -189,7 +230,7 @@ export default defineComponent({ .secret { display: block; - margin: 0 auto; + margin: 0 auto 16px auto; color: var(--foreground-subdued); font-family: var(--family-monospace); letter-spacing: 2.6px; diff --git a/app/src/interfaces/input-code/input-code.vue b/app/src/interfaces/input-code/input-code.vue index bfd955ee18..88a54c1c97 100644 --- a/app/src/interfaces/input-code/input-code.vue +++ b/app/src/interfaces/input-code/input-code.vue @@ -10,9 +10,9 @@