forked from vikunja/frontend
Compare commits
22 Commits
main
...
feature/lo
Author | SHA1 | Date | |
---|---|---|---|
cd92d224a2 | |||
19a161ff78 | |||
310578d349 | |||
9c5613ad98 | |||
0322daf4d4 | |||
6041ad1482 | |||
9a3069c20d | |||
27cd9535bf | |||
c46273ca34 | |||
a4ec41e937 | |||
3eb0d58f79 | |||
5558d91f44 | |||
9c04fb4e40 | |||
1fc1c20c87 | |||
a1814ea29d | |||
fda0b81d9c | |||
8397608fef | |||
66d5e851e8 | |||
1d916e7e03 | |||
aa12bffcbc | |||
05e054f501 | |||
f7eb160509 |
28
.drone.yml
28
.drone.yml
|
@ -116,16 +116,36 @@ steps:
|
|||
YARN_CACHE_FOLDER: .cache/yarn/
|
||||
CYPRESS_CACHE_FOLDER: .cache/cypress/
|
||||
CYPRESS_DEFAULT_COMMAND_TIMEOUT: 60000
|
||||
CYPRESS_RECORD_KEY:
|
||||
from_secret: cypress_project_key
|
||||
commands:
|
||||
- sed -i 's/localhost/api/g' dist/index.html
|
||||
- yarn serve:dist & npx wait-on http://localhost:5000
|
||||
- yarn test:frontend --browser chrome --record
|
||||
- yarn test:frontend --browser chrome
|
||||
depends_on:
|
||||
- dependencies
|
||||
- build-prod
|
||||
|
||||
- name: upload-test-results
|
||||
image: plugins/s3
|
||||
pull: true
|
||||
settings:
|
||||
bucket: drone-test-results
|
||||
access_key:
|
||||
from_secret: test_results_aws_access_key_id
|
||||
secret_key:
|
||||
from_secret: test_results_aws_secret_access_key
|
||||
endpoint: https://s3.fr-par.scw.cloud
|
||||
region: fr-par
|
||||
path_style: true
|
||||
source: cypress/screenshots/**/**/*
|
||||
strip_prefix: cypress/screenshots/
|
||||
target: /${DRONE_REPO}/${DRONE_PULL_REQUEST}_${DRONE_BRANCH}/${DRONE_BUILD_NUMBER}/
|
||||
depends_on:
|
||||
- test-frontend
|
||||
when:
|
||||
status:
|
||||
- failure
|
||||
- success
|
||||
|
||||
- name: deploy-preview
|
||||
image: node:16
|
||||
pull: true
|
||||
|
@ -645,6 +665,6 @@ steps:
|
|||
from_secret: crowdin_key
|
||||
---
|
||||
kind: signature
|
||||
hmac: 997e1badebe484ac29557c4af356e63db4d3d57f3d32e92d482f117f8cec64da
|
||||
hmac: 188ee90100c5fc5922a445e531e7a47453121edddb2a64a182eb23ed2bf602de
|
||||
|
||||
...
|
||||
|
|
|
@ -7,6 +7,5 @@
|
|||
"video": false,
|
||||
"retries": {
|
||||
"runMode": 2
|
||||
},
|
||||
"projectId": "181c7x"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,7 +25,6 @@ context('Registration', () => {
|
|||
cy.get('#username').type(fixture.username)
|
||||
cy.get('#email').type(fixture.email)
|
||||
cy.get('#password').type(fixture.password)
|
||||
cy.get('#passwordValidation').type(fixture.password)
|
||||
cy.get('#register-submit').click()
|
||||
cy.url().should('include', '/')
|
||||
cy.clock(1625656161057) // 13:00
|
||||
|
@ -43,7 +42,6 @@ context('Registration', () => {
|
|||
cy.get('#username').type(fixture.username)
|
||||
cy.get('#email').type(fixture.email)
|
||||
cy.get('#password').type(fixture.password)
|
||||
cy.get('#passwordValidation').type(fixture.password)
|
||||
cy.get('#register-submit').click()
|
||||
cy.get('div.message.danger').contains('A user with this username already exists.')
|
||||
})
|
||||
|
|
|
@ -8,14 +8,12 @@ describe('User Settings', () => {
|
|||
})
|
||||
|
||||
it('Changes the user avatar', () => {
|
||||
cy.intercept(`${Cypress.env('API_URL')}/user/settings/avatar/upload`).as('uploadAvatar')
|
||||
|
||||
cy.visit('/user/settings/avatar')
|
||||
|
||||
cy.get('input[name=avatarProvider][value=upload]')
|
||||
.click()
|
||||
cy.get('input[type=file]', {timeout: 1000})
|
||||
.selectFile('cypress/fixtures/image.jpg', {force: true}) // The input is not visible, but on purpose
|
||||
cy.get('input[type=file]', { timeout: 1000 })
|
||||
.attachFile('image.jpg')
|
||||
cy.get('.vue-handler-wrapper.vue-handler-wrapper--south .vue-simple-handler.vue-simple-handler--south')
|
||||
.trigger('mousedown', {which: 1})
|
||||
.trigger('mousemove', {clientY: 100})
|
||||
|
@ -24,7 +22,7 @@ describe('User Settings', () => {
|
|||
.contains('Upload Avatar')
|
||||
.click()
|
||||
|
||||
cy.wait('@uploadAvatar')
|
||||
cy.wait(3000) // Wait for the request to finish
|
||||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
})
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
|
||||
import './commands'
|
||||
import 'cypress-file-upload'
|
||||
import '@4tw/cypress-drag-drop'
|
||||
|
||||
// see https://github.com/cypress-io/cypress/issues/702#issuecomment-587127275
|
||||
|
|
49
package.json
49
package.json
|
@ -18,20 +18,20 @@
|
|||
"browserslist:update": "npx browserslist@latest --update-db"
|
||||
},
|
||||
"dependencies": {
|
||||
"@github/hotkey": "2.0.0",
|
||||
"@github/hotkey": "1.6.1",
|
||||
"@kyvg/vue3-notification": "2.3.4",
|
||||
"@sentry/tracing": "6.17.4",
|
||||
"@sentry/vue": "6.17.4",
|
||||
"@sentry/tracing": "6.16.1",
|
||||
"@sentry/vue": "6.16.1",
|
||||
"@types/is-touch-device": "1.0.0",
|
||||
"@vue/compat": "3.2.29",
|
||||
"@vueuse/core": "7.5.5",
|
||||
"@vueuse/router": "7.5.5",
|
||||
"@vueuse/core": "7.5.4",
|
||||
"@vueuse/router": "7.5.4",
|
||||
"bulma-css-variables": "0.9.33",
|
||||
"camel-case": "4.1.2",
|
||||
"codemirror": "5.65.1",
|
||||
"copy-to-clipboard": "3.3.1",
|
||||
"date-fns": "2.28.0",
|
||||
"dompurify": "2.3.5",
|
||||
"dompurify": "2.3.4",
|
||||
"easymde": "2.16.1",
|
||||
"flatpickr": "4.6.9",
|
||||
"flexsearch": "0.7.21",
|
||||
|
@ -39,16 +39,16 @@
|
|||
"is-touch-device": "1.0.1",
|
||||
"lodash.clonedeep": "4.5.0",
|
||||
"lodash.debounce": "4.0.8",
|
||||
"marked": "4.0.12",
|
||||
"marked": "4.0.10",
|
||||
"register-service-worker": "1.7.2",
|
||||
"snake-case": "3.0.4",
|
||||
"ufo": "0.7.10",
|
||||
"ufo": "0.7.9",
|
||||
"v-tooltip": "4.0.0-beta.17",
|
||||
"vue": "3.2.29",
|
||||
"vue-advanced-cropper": "2.8.0",
|
||||
"vue-drag-resize": "2.0.3",
|
||||
"vue-flatpickr-component": "9.0.5",
|
||||
"vue-i18n": "9.2.0-beta.30",
|
||||
"vue-i18n": "9.2.0-beta.28",
|
||||
"vue-router": "4.0.12",
|
||||
"vuedraggable": "4.1.0",
|
||||
"vuex": "4.0.2",
|
||||
|
@ -56,39 +56,40 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@4tw/cypress-drag-drop": "2.1.0",
|
||||
"@faker-js/faker": "6.0.0-alpha.5",
|
||||
"@faker-js/faker": "6.0.0-alpha.3",
|
||||
"@fortawesome/fontawesome-svg-core": "1.2.36",
|
||||
"@fortawesome/free-regular-svg-icons": "5.15.4",
|
||||
"@fortawesome/free-solid-svg-icons": "5.15.4",
|
||||
"@fortawesome/vue-fontawesome": "3.0.0-5",
|
||||
"@types/flexsearch": "0.7.2",
|
||||
"@typescript-eslint/eslint-plugin": "5.10.2",
|
||||
"@typescript-eslint/parser": "5.10.2",
|
||||
"@typescript-eslint/eslint-plugin": "5.10.0",
|
||||
"@typescript-eslint/parser": "5.10.0",
|
||||
"@vitejs/plugin-legacy": "1.6.4",
|
||||
"@vitejs/plugin-vue": "2.1.0",
|
||||
"@vue/eslint-config-typescript": "10.0.0",
|
||||
"autoprefixer": "10.4.2",
|
||||
"axios": "0.25.0",
|
||||
"browserslist": "4.19.1",
|
||||
"caniuse-lite": "1.0.30001307",
|
||||
"cypress": "9.4.1",
|
||||
"esbuild": "0.14.18",
|
||||
"eslint": "8.8.0",
|
||||
"eslint-plugin-vue": "8.4.1",
|
||||
"caniuse-lite": "1.0.30001301",
|
||||
"cypress": "9.3.1",
|
||||
"cypress-file-upload": "5.0.8",
|
||||
"esbuild": "0.14.13",
|
||||
"eslint": "8.7.0",
|
||||
"eslint-plugin-vue": "8.3.0",
|
||||
"express": "4.17.2",
|
||||
"happy-dom": "2.31.1",
|
||||
"netlify-cli": "8.15.0",
|
||||
"postcss": "8.4.6",
|
||||
"postcss-preset-env": "7.3.1",
|
||||
"rollup": "2.67.0",
|
||||
"netlify-cli": "8.8.2",
|
||||
"happy-dom": "2.28.0",
|
||||
"postcss": "8.4.5",
|
||||
"postcss-preset-env": "7.2.3",
|
||||
"rollup": "2.66.0",
|
||||
"rollup-plugin-visualizer": "5.5.4",
|
||||
"sass": "1.49.7",
|
||||
"sass": "1.49.0",
|
||||
"slugify": "1.6.5",
|
||||
"typescript": "4.5.5",
|
||||
"vite": "2.7.13",
|
||||
"vite-plugin-pwa": "0.11.13",
|
||||
"vite-svg-loader": "3.1.2",
|
||||
"vitest": "0.2.7",
|
||||
"vitest": "0.2.0",
|
||||
"vue-tsc": "0.31.1",
|
||||
"wait-on": "6.0.0",
|
||||
"workbox-cli": "6.4.2"
|
||||
|
|
|
@ -90,15 +90,13 @@
|
|||
v-bind="dragOptions"
|
||||
:modelValue="activeLists[nk]"
|
||||
@update:modelValue="(lists) => updateActiveLists(n, lists)"
|
||||
group="namespace-lists"
|
||||
:group="`namespace-${n.id}-lists`"
|
||||
@start="() => drag = true"
|
||||
@end="saveListPosition"
|
||||
@end="e => saveListPosition(e, nk)"
|
||||
handle=".handle"
|
||||
:disabled="n.id < 0 || null"
|
||||
tag="transition-group"
|
||||
item-key="id"
|
||||
:data-namespace-id="n.id"
|
||||
:data-namespace-index="nk"
|
||||
:component-data="{
|
||||
type: 'transition',
|
||||
tag: 'ul',
|
||||
|
@ -200,7 +198,7 @@ export default {
|
|||
loading: state => state[LOADING] && state[LOADING_MODULE] === 'namespaces',
|
||||
}),
|
||||
activeLists() {
|
||||
return this.namespaces.map(({lists}) => lists?.filter(item => typeof item !== 'undefined' && !item.isArchived))
|
||||
return this.namespaces.map(({lists}) => lists?.filter(item => !item.isArchived))
|
||||
},
|
||||
namespaceTitles() {
|
||||
return this.namespaces.map((namespace) => this.getNamespaceTitle(namespace))
|
||||
|
@ -243,15 +241,15 @@ export default {
|
|||
this.listsVisible[namespaceId] = !this.listsVisible[namespaceId]
|
||||
},
|
||||
updateActiveLists(namespace, activeLists) {
|
||||
// This is a bit hacky: since we do have to filter out the archived items from the list
|
||||
// this is a bit hacky: since we do have to filter out the archived items from the list
|
||||
// for vue draggable updating it is not as simple as replacing it.
|
||||
// To work around this, we merge the active lists with the archived ones. Doing so breaks the order
|
||||
// because now all archived lists are sorted after the active ones. This is fine because they are sorted
|
||||
// later when showing them anyway, and it makes the merging happening here a lot easier.
|
||||
const lists = [
|
||||
...activeLists,
|
||||
...namespace.lists.filter(l => l.isArchived),
|
||||
]
|
||||
// instead we iterate over the non archived items in the old list and replace them with the ones in their new order
|
||||
const lists = namespace.lists.map((item) => {
|
||||
if (item.isArchived) {
|
||||
return item
|
||||
}
|
||||
return activeLists.shift()
|
||||
})
|
||||
|
||||
const newNamespace = {
|
||||
...namespace,
|
||||
|
@ -261,11 +259,8 @@ export default {
|
|||
this.$store.commit('namespaces/setNamespaceById', newNamespace)
|
||||
},
|
||||
|
||||
async saveListPosition(e) {
|
||||
const namespaceId = parseInt(e.to.dataset.namespaceId)
|
||||
const newNamespaceIndex = parseInt(e.to.dataset.namespaceIndex)
|
||||
|
||||
const listsActive = this.activeLists[newNamespaceIndex]
|
||||
async saveListPosition(e, namespaceIndex) {
|
||||
const listsActive = this.activeLists[namespaceIndex]
|
||||
const list = listsActive[e.newIndex]
|
||||
const listBefore = listsActive[e.newIndex - 1] ?? null
|
||||
const listAfter = listsActive[e.newIndex + 1] ?? null
|
||||
|
@ -278,7 +273,6 @@ export default {
|
|||
await this.$store.dispatch('lists/updateList', {
|
||||
...list,
|
||||
position,
|
||||
namespaceId,
|
||||
})
|
||||
} finally {
|
||||
this.listUpdating[list.id] = false
|
||||
|
|
85
src/components/input/password.vue
Normal file
85
src/components/input/password.vue
Normal file
|
@ -0,0 +1,85 @@
|
|||
<template>
|
||||
<div class="password-field">
|
||||
<input
|
||||
class="input"
|
||||
id="password"
|
||||
name="password"
|
||||
:placeholder="$t('user.auth.passwordPlaceholder')"
|
||||
required
|
||||
:type="passwordFieldType"
|
||||
autocomplete="current-password"
|
||||
@keyup.enter="e => $emit('submit', e)"
|
||||
:tabindex="props.tabindex"
|
||||
@focusout="validate"
|
||||
@input="handleInput"
|
||||
/>
|
||||
<a
|
||||
@click="togglePasswordFieldType"
|
||||
class="password-field-type-toggle"
|
||||
aria-label="passwordFieldType === 'password' ? $t('user.auth.showPassword') : $t('user.auth.hidePassword')"
|
||||
v-tooltip="passwordFieldType === 'password' ? $t('user.auth.showPassword') : $t('user.auth.hidePassword')">
|
||||
<icon :icon="passwordFieldType === 'password' ? 'eye' : 'eye-slash'"/>
|
||||
</a>
|
||||
</div>
|
||||
<p class="help is-danger" v-if="!isValid">
|
||||
{{ $t('user.auth.passwordRequired') }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {ref, watch} from 'vue'
|
||||
import {useDebounceFn} from '@vueuse/core'
|
||||
|
||||
const props = defineProps({
|
||||
tabindex: String,
|
||||
modelValue: String,
|
||||
// This prop is a workaround to trigger validation from the outside when the user never had focus in the input.
|
||||
validateInitially: Boolean,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['submit', 'update:modelValue'])
|
||||
|
||||
const passwordFieldType = ref<String>('password')
|
||||
const password = ref<String>('')
|
||||
const isValid = ref<Boolean>(!props.validateInitially)
|
||||
|
||||
watch(
|
||||
() => props.validateInitially,
|
||||
(doValidate: Boolean) => {
|
||||
if (doValidate) {
|
||||
validate()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function validate() {
|
||||
useDebounceFn(() => {
|
||||
isValid.value = password.value !== ''
|
||||
}, 100)()
|
||||
}
|
||||
|
||||
function togglePasswordFieldType() {
|
||||
passwordFieldType.value = passwordFieldType.value === 'password'
|
||||
? 'text'
|
||||
: 'password'
|
||||
}
|
||||
|
||||
function handleInput(e) {
|
||||
password.value = e.target.value
|
||||
emit('update:modelValue', e.target.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.password-field {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.password-field-type-toggle {
|
||||
position: absolute;
|
||||
color: var(--grey-400);
|
||||
top: 50%;
|
||||
right: 1rem;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
</style>
|
|
@ -1,18 +1,35 @@
|
|||
<template>
|
||||
<div class="message-wrapper">
|
||||
<div class="message" :class="variant">
|
||||
<div class="message" :class="[variant, textAlignClass]">
|
||||
<slot/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps({
|
||||
import {computed, PropType} from 'vue'
|
||||
|
||||
const TEXT_ALIGN_MAP = Object.freeze({
|
||||
left: '',
|
||||
center: 'has-text-centered',
|
||||
right: 'has-text-right',
|
||||
})
|
||||
|
||||
type textAlignVariants = keyof typeof TEXT_ALIGN_MAP
|
||||
|
||||
const props = defineProps({
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'info',
|
||||
},
|
||||
textAlign: {
|
||||
type: String as PropType<textAlignVariants>,
|
||||
default: 'left',
|
||||
},
|
||||
})
|
||||
|
||||
const textAlignClass = computed(() => TEXT_ALIGN_MAP[props.textAlign])
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
@ -14,6 +14,9 @@
|
|||
<div>
|
||||
<h2 class="title" v-if="title">{{ title }}</h2>
|
||||
<api-config/>
|
||||
<Message v-if="motd !== ''" class="is-hidden-tablet mb-4">
|
||||
{{ motd }}
|
||||
</Message>
|
||||
<slot/>
|
||||
</div>
|
||||
<legal/>
|
||||
|
@ -38,8 +41,8 @@ const store = useStore()
|
|||
const {t} = useI18n()
|
||||
|
||||
const motd = computed(() => store.state.config.motd)
|
||||
// @ts-ignore
|
||||
const title = computed(() => t(route.meta.title ?? ''))
|
||||
|
||||
const title = computed(() => t(route.meta?.title as string || ''))
|
||||
useTitle(() => title.value)
|
||||
</script>
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<x-button
|
||||
variant="secondary"
|
||||
:icon="iconName"
|
||||
:icon="icon"
|
||||
v-tooltip="tooltipText"
|
||||
@click="changeSubscription"
|
||||
:disabled="disabled || null"
|
||||
|
@ -16,7 +16,7 @@
|
|||
v-else
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="iconName"/>
|
||||
<icon :icon="icon"/>
|
||||
</span>
|
||||
{{ buttonText }}
|
||||
</a>
|
||||
|
@ -73,7 +73,7 @@ const tooltipText = computed(() => {
|
|||
})
|
||||
|
||||
const buttonText = computed(() => props.subscription !== null ? t('task.subscription.unsubscribe') : t('task.subscription.subscribe'))
|
||||
const iconName = computed(() => props.subscription !== null ? ['far', 'bell-slash'] : 'bell')
|
||||
const icon = computed(() => props.subscription !== null ? ['far', 'bell-slash'] : 'bell')
|
||||
const disabled = computed(() => {
|
||||
if (props.subscription === null) {
|
||||
return false
|
||||
|
|
|
@ -274,11 +274,10 @@ export default {
|
|||
return tasks
|
||||
.map(task => {
|
||||
// by doing this here once we can save a lot of duplicate calls in the template
|
||||
const listAndNamespace = this.$store.getters['namespaces/getListAndNamespaceById'](task.listId, true)
|
||||
const {
|
||||
list,
|
||||
namespace,
|
||||
} = listAndNamespace === null ? {list: null, namespace: null} : listAndNamespace
|
||||
} = this.$store.getters['namespaces/getListAndNamespaceById'](task.listId, true)
|
||||
|
||||
return {
|
||||
...task,
|
||||
|
|
6
src/helpers/isEmail.ts
Normal file
6
src/helpers/isEmail.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
export function isEmail(email: string): Boolean {
|
||||
const format = /^.+@.+$/
|
||||
const match = email.match(format)
|
||||
|
||||
return match === null ? false : match.length > 0
|
||||
}
|
|
@ -288,7 +288,7 @@ const getDateFromWeekday = (text: string): dateFoundResult => {
|
|||
}
|
||||
|
||||
const getDayFromText = (text: string) => {
|
||||
const matcher = /($| )(([1-2][0-9])|(3[01])|(0?[1-9]))(st|nd|rd|th|\.)($| )/ig
|
||||
const matcher = /(([1-2][0-9])|(3[01])|(0?[1-9]))(st|nd|rd|th|\.)/ig
|
||||
const results = matcher.exec(text)
|
||||
if (results === null) {
|
||||
return {
|
||||
|
@ -302,18 +302,18 @@ const getDayFromText = (text: string) => {
|
|||
const day = parseInt(results[0])
|
||||
date.setDate(day)
|
||||
|
||||
// If the parsed day is the 31st (or 29+ and the next month is february) but the next month only has 30 days,
|
||||
// setting the day to 31 will "overflow" the date to the next month, but the first.
|
||||
// If the parsed day is the 31st but the next month only has 30 days, setting the day to 31 will "overflow" the
|
||||
// date to the next month, but the first.
|
||||
// This would look like a very weired bug. Now, to prevent that, we check if the day is the same as parsed after
|
||||
// setting it for the first time and set it again if it isn't - that would mean the month overflowed.
|
||||
while (date < now) {
|
||||
date.setMonth(date.getMonth() + 1)
|
||||
}
|
||||
|
||||
if (date.getDate() !== day) {
|
||||
if (day === 31 && date.getDate() !== day) {
|
||||
date.setDate(day)
|
||||
}
|
||||
|
||||
if (date < now) {
|
||||
date.setMonth(date.getMonth() + 1)
|
||||
}
|
||||
|
||||
return {
|
||||
foundText: results[0],
|
||||
date: date,
|
||||
|
|
|
@ -31,10 +31,9 @@
|
|||
"username": "Username",
|
||||
"usernameEmail": "Username Or Email Address",
|
||||
"usernamePlaceholder": "e.g. frederick",
|
||||
"email": "E-mail address",
|
||||
"email": "Email address",
|
||||
"emailPlaceholder": "e.g. frederic{'@'}vikunja.io",
|
||||
"password": "Password",
|
||||
"passwordRepeat": "Retype your password",
|
||||
"passwordPlaceholder": "e.g. •••••••••••",
|
||||
"forgotPassword": "Forgot your password?",
|
||||
"resetPassword": "Reset your password",
|
||||
|
@ -45,12 +44,19 @@
|
|||
"totpTitle": "Two Factor Authentication Code",
|
||||
"totpPlaceholder": "e.g. 123456",
|
||||
"login": "Login",
|
||||
"register": "Register",
|
||||
"createAccount": "Create account",
|
||||
"loginWith": "Log in with {provider}",
|
||||
"authenticating": "Authenticating…",
|
||||
"openIdStateError": "State does not match, refusing to continue!",
|
||||
"openIdGeneralError": "An error occured while authenticating against the third party.",
|
||||
"logout": "Logout"
|
||||
"logout": "Logout",
|
||||
"emailInvalid": "Please enter a valid email address.",
|
||||
"usernameRequired": "Please provide a username.",
|
||||
"passwordRequired": "Please provide a password.",
|
||||
"showPassword": "Show the password",
|
||||
"hidePassword": "Hide the password",
|
||||
"noAccountYet": "Don't have an account yet?",
|
||||
"alreadyHaveAnAccount": "Already have an account?"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
|
@ -61,7 +67,7 @@
|
|||
"currentPasswordPlaceholder": "Your current password",
|
||||
"passwordsDontMatch": "The new password and its confirmation don't match.",
|
||||
"passwordUpdateSuccess": "The password was successfully updated.",
|
||||
"updateEmailTitle": "Update Your E-Mail Address",
|
||||
"updateEmailTitle": "Update Your Email Address",
|
||||
"updateEmailNew": "New Email Address",
|
||||
"updateEmailSuccess": "Your email address was successfully updated. We've sent you a link to confirm it.",
|
||||
"general": {
|
||||
|
|
|
@ -16,6 +16,8 @@ import {
|
|||
faCocktail,
|
||||
faCoffee,
|
||||
faCog,
|
||||
faEye,
|
||||
faEyeSlash,
|
||||
faEllipsisH,
|
||||
faEllipsisV,
|
||||
faExclamation,
|
||||
|
@ -87,6 +89,8 @@ library.add(faCocktail)
|
|||
library.add(faCoffee)
|
||||
library.add(faCog)
|
||||
library.add(faComments)
|
||||
library.add(faEye)
|
||||
library.add(faEyeSlash)
|
||||
library.add(faEllipsisH)
|
||||
library.add(faEllipsisV)
|
||||
library.add(faExclamation)
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {beforeEach, afterEach, describe, it, expect, vi} from 'vitest'
|
||||
import {describe, it, expect} from 'vitest'
|
||||
|
||||
import {parseTaskText} from './parseTaskText'
|
||||
import {getDateFromText, getDateFromTextIn} from '../helpers/time/parseDate'
|
||||
|
@ -6,14 +6,6 @@ import {calculateDayInterval} from '../helpers/time/calculateDayInterval'
|
|||
import priorities from '../models/constants/priorities.json'
|
||||
|
||||
describe('Parse Task Text', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should return text with no intents as is', () => {
|
||||
expect(parseTaskText('Lorem Ipsum').text).toBe('Lorem Ipsum')
|
||||
})
|
||||
|
@ -40,7 +32,7 @@ describe('Parse Task Text', () => {
|
|||
expect(result.assignees).toHaveLength(1)
|
||||
expect(result.assignees[0]).toBe('user')
|
||||
})
|
||||
|
||||
|
||||
it('should ignore email addresses', () => {
|
||||
const text = 'Lorem Ipsum email@example.com'
|
||||
const result = parseTaskText(text)
|
||||
|
@ -219,36 +211,17 @@ describe('Parse Task Text', () => {
|
|||
expect(`${result.date.getHours()}:${result.date.getMinutes()}`).toBe('14:0')
|
||||
})
|
||||
it('should recognize dates of the month in the past but next month', () => {
|
||||
const time = new Date(2022, 0, 15)
|
||||
vi.setSystemTime(time)
|
||||
|
||||
const result = parseTaskText(`Lorem Ipsum ${time.getDate() - 1}th`)
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() - 1)
|
||||
const result = parseTaskText(`Lorem Ipsum ${date.getDate()}nd`)
|
||||
|
||||
expect(result.text).toBe('Lorem Ipsum')
|
||||
expect(result.date.getDate()).toBe(time.getDate() - 1)
|
||||
expect(result.date.getMonth()).toBe(time.getMonth() + 1)
|
||||
})
|
||||
it('should recognize dates of the month in the past but next month when february is the next month', () => {
|
||||
const jan = new Date(2022, 0, 30)
|
||||
vi.setSystemTime(jan)
|
||||
expect(result.date.getDate()).toBe(date.getDate())
|
||||
|
||||
const result = parseTaskText(`Lorem Ipsum ${jan.getDate() - 1}th`)
|
||||
|
||||
const expectedDate = new Date(2022, 2, jan.getDate() - 1)
|
||||
expect(result.text).toBe('Lorem Ipsum')
|
||||
expect(result.date.getDate()).toBe(expectedDate.getDate())
|
||||
expect(result.date.getMonth()).toBe(expectedDate.getMonth())
|
||||
})
|
||||
it('should recognize dates of the month in the past but next month when the next month has less days than this one', () => {
|
||||
const mar = new Date(2022, 2, 32)
|
||||
vi.setSystemTime(mar)
|
||||
|
||||
const result = parseTaskText(`Lorem Ipsum 31st`)
|
||||
|
||||
const expectedDate = new Date(2022, 4, 31)
|
||||
expect(result.text).toBe('Lorem Ipsum')
|
||||
expect(result.date.getDate()).toBe(expectedDate.getDate())
|
||||
expect(result.date.getMonth()).toBe(expectedDate.getMonth())
|
||||
const nextMonthWithDate = result.date.getDate() === 31
|
||||
? (date.getMonth() + 2) % 12
|
||||
: (date.getMonth() + 1) % 12
|
||||
expect(result.date.getMonth()).toBe(nextMonthWithDate)
|
||||
})
|
||||
it('should recognize dates of the month in the future', () => {
|
||||
const nextDay = new Date(+new Date() + 60 * 60 * 24 * 1000)
|
||||
|
@ -269,12 +242,6 @@ describe('Parse Task Text', () => {
|
|||
expect(result.text).toBe('Lorem Ipsum github')
|
||||
expect(result.date).toBeNull()
|
||||
})
|
||||
it('should not recognize date number with no spacing around them', () => {
|
||||
const result = parseTaskText('Lorem Ispum v1.1.1')
|
||||
|
||||
expect(result.text).toBe('Lorem Ispum v1.1.1')
|
||||
expect(result.date).toBeNull()
|
||||
})
|
||||
|
||||
describe('Parse weekdays', () => {
|
||||
|
||||
|
|
|
@ -132,7 +132,7 @@ const router = createRouter({
|
|||
name: 'user.register',
|
||||
component: RegisterComponent,
|
||||
meta: {
|
||||
title: 'user.auth.register',
|
||||
title: 'user.auth.createAccount',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
@ -23,6 +23,8 @@ export default {
|
|||
return
|
||||
}
|
||||
|
||||
// FIXME: direct manipulation of the prop
|
||||
// might not be a problem since this is happening in the mutation
|
||||
if (!namespace.lists || namespace.lists.length === 0) {
|
||||
namespace.lists = state.namespaces[namespaceIndex].lists
|
||||
}
|
||||
|
@ -134,8 +136,8 @@ export default {
|
|||
},
|
||||
|
||||
loadNamespacesIfFavoritesDontExist(ctx) {
|
||||
// The first or second namespace should be the one holding all favorites
|
||||
if (ctx.state.namespaces[0].id !== -2 && ctx.state.namespaces[1]?.id !== -2) {
|
||||
// The first namespace should be the one holding all favorites
|
||||
if (ctx.state.namespaces[0].id !== -2) {
|
||||
return ctx.dispatch('loadNamespaces')
|
||||
}
|
||||
},
|
||||
|
|
|
@ -2,4 +2,4 @@
|
|||
@import "labels";
|
||||
@import "list";
|
||||
@import "task";
|
||||
@import "tasks";
|
||||
@import "tasks";
|
||||
|
|
|
@ -4,11 +4,9 @@
|
|||
@submit="archiveNamespace()"
|
||||
>
|
||||
<template #header><span>{{ title }}</span></template>
|
||||
|
||||
|
||||
<template #text>
|
||||
<p>
|
||||
{{ namespace.isArchived ? $t('namespace.archive.unarchiveText') : $t('namespace.archive.archiveText')}}
|
||||
</p>
|
||||
<p>{{ list.isArchived ? $t('namespace.archive.unarchiveText') : $t('namespace.archive.archiveText') }}</p>
|
||||
</template>
|
||||
</modal>
|
||||
</template>
|
||||
|
@ -29,18 +27,17 @@ export default {
|
|||
created() {
|
||||
this.namespace = this.$store.getters['namespaces/getNamespaceById'](this.$route.params.id)
|
||||
this.title = this.namespace.isArchived ?
|
||||
this.$t('namespace.archive.titleUnarchive', {namespace: this.namespace.title}) :
|
||||
this.$t('namespace.archive.titleArchive', {namespace: this.namespace.title})
|
||||
this.$t('namespace.archive.titleUnarchive', { namespace: this.namespace.title }) :
|
||||
this.$t('namespace.archive.titleArchive', { namespace: this.namespace.title })
|
||||
this.setTitle(this.title)
|
||||
},
|
||||
|
||||
methods: {
|
||||
async archiveNamespace() {
|
||||
this.namespace.isArchived = !this.namespace.isArchived
|
||||
|
||||
try {
|
||||
const namespace = await this.namespaceService.update({
|
||||
...this.namespace,
|
||||
isArchived: !this.namespace.isArchived,
|
||||
})
|
||||
const namespace = await this.namespaceService.update(this.namespace)
|
||||
this.$store.commit('namespaces/setNamespaceById', namespace)
|
||||
this.$message.success({message: this.$t('namespace.archive.success')})
|
||||
} finally {
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
<template>
|
||||
<div>
|
||||
<message variant="success" class="has-text-centered" v-if="confirmedEmailSuccess">
|
||||
<message variant="success" text-align="center" class="mb-4" v-if="confirmedEmailSuccess">
|
||||
{{ $t('user.auth.confirmEmailSuccess') }}
|
||||
</message>
|
||||
<message variant="danger" v-if="errorMessage">
|
||||
<message variant="danger" v-if="errorMessage" class="mb-4">
|
||||
{{ errorMessage }}
|
||||
</message>
|
||||
<form @submit.prevent="submit" id="loginform" v-if="localAuthEnabled">
|
||||
|
@ -20,24 +20,26 @@
|
|||
autocomplete="username"
|
||||
v-focus
|
||||
@keyup.enter="submit"
|
||||
tabindex="1"
|
||||
@focusout="validateField('username')"
|
||||
/>
|
||||
</div>
|
||||
<p class="help is-danger" v-if="!usernameValid">
|
||||
{{ $t('user.auth.usernameRequired') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="password">{{ $t('user.auth.password') }}</label>
|
||||
<div class="control">
|
||||
<input
|
||||
class="input"
|
||||
id="password"
|
||||
name="password"
|
||||
:placeholder="$t('user.auth.passwordPlaceholder')"
|
||||
ref="password"
|
||||
required
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
@keyup.enter="submit"
|
||||
/>
|
||||
<div class="label-with-link">
|
||||
<label class="label" for="password">{{ $t('user.auth.password') }}</label>
|
||||
<router-link
|
||||
:to="{ name: 'user.password-reset.request' }"
|
||||
class="reset-password-link"
|
||||
tabindex="6"
|
||||
>
|
||||
{{ $t('user.auth.forgotPassword') }}
|
||||
</router-link>
|
||||
</div>
|
||||
<password tabindex="2" @submit="submit" v-model="password" :validate-initially="validatePasswordInitially"/>
|
||||
</div>
|
||||
<div class="field" v-if="needsTotpPasscode">
|
||||
<label class="label" for="totpPasscode">{{ $t('user.auth.totpTitle') }}</label>
|
||||
|
@ -52,32 +54,28 @@
|
|||
type="text"
|
||||
v-focus
|
||||
@keyup.enter="submit"
|
||||
tabindex="3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field is-grouped login-buttons">
|
||||
<div class="control is-expanded">
|
||||
<x-button
|
||||
@click="submit"
|
||||
:loading="loading"
|
||||
>
|
||||
{{ $t('user.auth.login') }}
|
||||
</x-button>
|
||||
<x-button
|
||||
:to="{ name: 'user.register' }"
|
||||
v-if="registrationEnabled"
|
||||
variant="secondary"
|
||||
>
|
||||
{{ $t('user.auth.register') }}
|
||||
</x-button>
|
||||
</div>
|
||||
<div class="control">
|
||||
<router-link :to="{ name: 'user.password-reset.request' }" class="reset-password-link">
|
||||
{{ $t('user.auth.forgotPassword') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<x-button
|
||||
@click="submit"
|
||||
:loading="loading"
|
||||
tabindex="4"
|
||||
>
|
||||
{{ $t('user.auth.login') }}
|
||||
</x-button>
|
||||
<p class="mt-2" v-if="registrationEnabled">
|
||||
{{ $t('user.auth.noAccountYet') }}
|
||||
<router-link
|
||||
:to="{ name: 'user.register' }"
|
||||
type="secondary"
|
||||
tabindex="5"
|
||||
>
|
||||
{{ $t('user.auth.createAccount') }}
|
||||
</router-link>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<div
|
||||
|
@ -97,6 +95,7 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import {useDebounceFn} from '@vueuse/core'
|
||||
import {mapState} from 'vuex'
|
||||
|
||||
import {HTTPFactory} from '@/http-common'
|
||||
|
@ -105,15 +104,20 @@ import {getErrorText} from '@/message'
|
|||
import Message from '@/components/misc/message'
|
||||
import {redirectToProvider} from '../../helpers/redirectToProvider'
|
||||
import {getLastVisited, clearLastVisited} from '../../helpers/saveLastVisited'
|
||||
import Password from '@/components/input/password'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Password,
|
||||
Message,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
confirmedEmailSuccess: false,
|
||||
errorMessage: '',
|
||||
usernameValid: true,
|
||||
password: '',
|
||||
validatePasswordInitially: false,
|
||||
}
|
||||
},
|
||||
beforeMount() {
|
||||
|
@ -166,6 +170,13 @@ export default {
|
|||
localAuthEnabled: state => state.config.auth.local.enabled,
|
||||
openidConnect: state => state.config.auth.openidConnect,
|
||||
}),
|
||||
|
||||
validateField() {
|
||||
// using computed so that debounced function definition stays
|
||||
return useDebounceFn((field) => {
|
||||
this[`${field}Valid`] = this.$refs[field].value !== ''
|
||||
}, 100)
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
setLoading() {
|
||||
|
@ -185,7 +196,14 @@ export default {
|
|||
// For more info, see https://kolaente.dev/vikunja/frontend/issues/78
|
||||
const credentials = {
|
||||
username: this.$refs.username.value,
|
||||
password: this.$refs.password.value,
|
||||
password: this.password,
|
||||
}
|
||||
|
||||
if (credentials.username === '' || credentials.password === '') {
|
||||
// Trigger the validation error messages
|
||||
this.validateField('username')
|
||||
this.validatePasswordInitially = true
|
||||
return
|
||||
}
|
||||
|
||||
if (this.needsTotpPasscode) {
|
||||
|
@ -196,7 +214,7 @@ export default {
|
|||
await this.$store.dispatch('auth/login', credentials)
|
||||
this.$store.commit('auth/needsTotpPasscode', false)
|
||||
} catch (e) {
|
||||
if (e.response && e.response.data.code === 1017 && !credentials.totpPasscode) {
|
||||
if (e.response?.data.code === 1017 && !this.credentials.totpPasscode) {
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -211,22 +229,21 @@ export default {
|
|||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.login-buttons {
|
||||
@media screen and (max-width: 450px) {
|
||||
flex-direction: column;
|
||||
|
||||
.control:first-child {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.button {
|
||||
margin: 0 0.4rem 0 0;
|
||||
}
|
||||
|
||||
.reset-password-link {
|
||||
display: inline-block;
|
||||
padding-top: 5px;
|
||||
}
|
||||
|
||||
.label-with-link {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: .5rem;
|
||||
|
||||
.label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
<template>
|
||||
<div>
|
||||
<message v-if="errorMsg">
|
||||
<message v-if="errorMsg" class="mb-4">
|
||||
{{ errorMsg }}
|
||||
</message>
|
||||
<div class="has-text-centered" v-if="successMessage">
|
||||
<div class="has-text-centered mb-4" v-if="successMessage">
|
||||
<message variant="success">
|
||||
{{ successMessage }}
|
||||
</message>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div>
|
||||
<message variant="danger" v-if="errorMessage !== ''">
|
||||
<message variant="danger" v-if="errorMessage !== ''" class="mb-4">
|
||||
{{ errorMessage }}
|
||||
</message>
|
||||
<form @submit.prevent="submit" id="registerform">
|
||||
|
@ -18,8 +18,12 @@
|
|||
v-focus
|
||||
v-model="credentials.username"
|
||||
@keyup.enter="submit"
|
||||
@focusout="validateUsername"
|
||||
/>
|
||||
</div>
|
||||
<p class="help is-danger" v-if="!usernameValid">
|
||||
{{ $t('user.auth.usernameRequired') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="email">{{ $t('user.auth.email') }}</label>
|
||||
|
@ -33,68 +37,46 @@
|
|||
type="email"
|
||||
v-model="credentials.email"
|
||||
@keyup.enter="submit"
|
||||
@focusout="validateEmail"
|
||||
/>
|
||||
</div>
|
||||
<p class="help is-danger" v-if="!emailValid">
|
||||
{{ $t('user.auth.emailInvalid') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="password">{{ $t('user.auth.password') }}</label>
|
||||
<div class="control">
|
||||
<input
|
||||
class="input"
|
||||
id="password"
|
||||
name="password"
|
||||
:placeholder="$t('user.auth.passwordPlaceholder')"
|
||||
required
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
v-model="credentials.password"
|
||||
@keyup.enter="submit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="passwordValidation">{{ $t('user.auth.passwordRepeat') }}</label>
|
||||
<div class="control">
|
||||
<input
|
||||
class="input"
|
||||
id="passwordValidation"
|
||||
name="passwordValidation"
|
||||
:placeholder="$t('user.auth.passwordPlaceholder')"
|
||||
required
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
v-model="passwordValidation"
|
||||
@keyup.enter="submit"
|
||||
/>
|
||||
</div>
|
||||
<password @submit="submit" @update:modelValue="v => credentials.password = v" :validate-initially="validatePasswordInitially"/>
|
||||
</div>
|
||||
|
||||
<div class="field is-grouped">
|
||||
<div class="control">
|
||||
<x-button
|
||||
:loading="loading"
|
||||
id="register-submit"
|
||||
@click="submit"
|
||||
class="mr-2"
|
||||
>
|
||||
{{ $t('user.auth.register') }}
|
||||
</x-button>
|
||||
<x-button :to="{ name: 'user.login' }" variant="secondary">
|
||||
{{ $t('user.auth.login') }}
|
||||
</x-button>
|
||||
</div>
|
||||
</div>
|
||||
<x-button
|
||||
:loading="loading"
|
||||
id="register-submit"
|
||||
@click="submit"
|
||||
class="mr-2"
|
||||
:disabled="!everythingValid"
|
||||
>
|
||||
{{ $t('user.auth.createAccount') }}
|
||||
</x-button>
|
||||
<p class="mt-2">
|
||||
{{ $t('user.auth.alreadyHaveAnAccount') }}
|
||||
<router-link :to="{ name: 'user.login' }">
|
||||
{{ $t('user.auth.login') }}
|
||||
</router-link>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {useDebounceFn} from '@vueuse/core'
|
||||
import {ref, reactive, toRaw, computed, onBeforeMount} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import router from '@/router'
|
||||
import {store} from '@/store'
|
||||
import Message from '@/components/misc/message'
|
||||
import {isEmail} from '@/helpers/isEmail'
|
||||
import Password from '@/components/input/password'
|
||||
|
||||
// FIXME: use the `beforeEnter` hook of vue-router
|
||||
// Check if the user is already logged in, if so, redirect them to the homepage
|
||||
|
@ -104,27 +86,45 @@ onBeforeMount(() => {
|
|||
}
|
||||
})
|
||||
|
||||
const {t} = useI18n()
|
||||
|
||||
const credentials = reactive({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
})
|
||||
const passwordValidation = ref('')
|
||||
|
||||
const loading = computed(() => store.state.loading)
|
||||
const errorMessage = ref('')
|
||||
const validatePasswordInitially = ref(false)
|
||||
|
||||
const DEBOUNCE_TIME = 100
|
||||
|
||||
// debouncing to prevent error messages when clicking on the log in button
|
||||
const emailValid = ref(true)
|
||||
const validateEmail = useDebounceFn(() => {
|
||||
emailValid.value = isEmail(credentials.email)
|
||||
}, DEBOUNCE_TIME)
|
||||
|
||||
const usernameValid = ref(true)
|
||||
const validateUsername = useDebounceFn(() => {
|
||||
usernameValid.value = credentials.username !== ''
|
||||
}, DEBOUNCE_TIME)
|
||||
|
||||
const everythingValid = computed(() => {
|
||||
return credentials.username !== '' &&
|
||||
credentials.email !== '' &&
|
||||
credentials.password !== '' &&
|
||||
emailValid.value &&
|
||||
usernameValid.value
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
errorMessage.value = ''
|
||||
validatePasswordInitially.value = true
|
||||
|
||||
if (credentials.password !== passwordValidation.value) {
|
||||
errorMessage.value = t('user.auth.passwordsDontMatch')
|
||||
if (!everythingValid.value) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
await store.dispatch('auth/register', toRaw(credentials))
|
||||
} catch (e) {
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
<template>
|
||||
<div>
|
||||
<message variant="danger" v-if="errorMsg">
|
||||
<message variant="danger" v-if="errorMsg" class="mb-4">
|
||||
{{ errorMsg }}
|
||||
</message>
|
||||
<div class="has-text-centered" v-if="isSuccess">
|
||||
<div class="has-text-centered mb-4" v-if="isSuccess">
|
||||
<message variant="success">
|
||||
{{ $t('user.auth.resetPasswordSuccess') }}
|
||||
</message>
|
||||
|
|
Loading…
Reference in New Issue
Block a user