move files

This commit is contained in:
MaysWind
2023-08-05 16:51:34 +08:00
parent 7e24492ce8
commit 395bd31898
22 changed files with 48 additions and 41 deletions
@@ -0,0 +1,480 @@
<template>
<v-row>
<v-col cols="12">
<v-card :class="{ 'disabled': loading || saving }">
<template #title>
<span>{{ $t('Basic Settings') }}</span>
<v-progress-circular indeterminate size="24" class="ml-2" v-if="loading"></v-progress-circular>
</template>
<v-card-text class="d-flex">
<v-avatar rounded="lg" color="primary" variant="tonal" size="100" class="me-4">
<v-img :src="oldProfile.avatar" v-if="oldProfile.avatar">
<template #placeholder>
<div class="d-flex align-center justify-center fill-height">
<v-icon size="48" :icon="icons.user"/>
</div>
</template>
</v-img>
<v-icon size="48" :icon="icons.user" v-else-if="!oldProfile.avatar"/>
</v-avatar>
<div class="d-flex flex-column justify-center gap-5">
<p class="text-body-1 mb-0">
<span class="me-1">{{ $t('Username:') }}</span>
<span>{{ oldProfile.username }}</span>
</p>
<p class="text-body-1 mb-0">
<span class="me-1">{{ $t('Avatar Provider:') }}</span>
<span>{{ currentUserAvatarProvider }}</span>
</p>
</div>
</v-card-text>
<v-divider />
<v-form class="mt-6">
<v-card-text>
<v-row>
<v-col cols="12" md="6">
<v-text-field
type="text"
autocomplete="nickname"
clearable
persistent-placeholder
:disabled="loading || saving"
:label="$t('Nickname')"
:placeholder="$t('Your nickname')"
v-model="newProfile.nickname"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
type="email"
autocomplete="email"
clearable
persistent-placeholder
:disabled="loading || saving"
:label="$t('E-mail')"
:placeholder="$t('Your email address')"
v-model="newProfile.email"
/>
</v-col>
<v-col cols="12" md="6">
<v-select
item-title="name"
item-value="id"
persistent-placeholder
:disabled="loading || saving || !allVisibleAccounts.length"
:label="$t('Default Account')"
:placeholder="$t('Default Account')"
:items="allVisibleAccounts"
:no-data-text="$t('No results')"
v-model="newProfile.defaultAccountId"
>
<template #selection="{ item }">
<v-label v-if="item && item.value !== 0 && item.value !== '0'">
<ItemIcon class="mr-2" icon-type="account" size="23px"
:icon-id="getNameByKeyValue(allAccounts, newProfile.defaultAccountId, 'id', 'icon')"
:color="getNameByKeyValue(allAccounts, newProfile.defaultAccountId, 'id', 'color')"
v-if="getNameByKeyValue(allAccounts, newProfile.defaultAccountId, 'id', 'icon')" />
<span>{{ getNameByKeyValue(allAccounts, newProfile.defaultAccountId, 'id', 'name', $t('Not Specified')) }}</span>
</v-label>
<v-label v-if="!item || item.value === 0 || item.value === '0'">{{ $t('Not Specified') }}</v-label>
</template>
<template #item="{ props, item }">
<v-list-item :value="item.value" v-bind="props">
<template #title>
<v-list-item-title>
<div class="d-flex align-center">
<ItemIcon icon-type="account"
:icon-id="item.raw.icon" :color="item.raw.color"
v-if="item.raw" />
<span class="ml-2">{{ item.title }}</span>
</div>
</v-list-item-title>
</template>
</v-list-item>
</template>
</v-select>
</v-col>
<v-col cols="12" md="6">
<v-select
item-title="displayName"
item-value="type"
persistent-placeholder
:disabled="loading || saving"
:label="$t('Editable Transaction Scope')"
:placeholder="$t('Editable Transaction Scope')"
:items="allTransactionEditScopeTypes"
v-model="newProfile.transactionEditScope"
/>
</v-col>
</v-row>
</v-card-text>
<v-divider />
<v-card-text>
<v-row>
<v-col cols="12" md="6">
<v-select
item-title="displayName"
item-value="code"
persistent-placeholder
:disabled="loading || saving"
:label="$t('Language')"
:placeholder="$t('Language')"
:items="allLanguages"
v-model="newProfile.language"
/>
</v-col>
<v-col cols="12" md="6">
<v-autocomplete
item-title="displayName"
item-value="code"
persistent-placeholder
:disabled="loading || saving"
:label="$t('Default Currency')"
:placeholder="$t('Default Currency')"
:items="allCurrencies"
:no-data-text="$t('No results')"
v-model="newProfile.defaultCurrency"
/>
</v-col>
<v-col cols="12" md="6">
<v-select
item-title="displayName"
item-value="type"
persistent-placeholder
:disabled="loading || saving"
:label="$t('First Day of Week')"
:placeholder="$t('First Day of Week')"
:items="allWeekDays"
v-model="newProfile.firstDayOfWeek"
/>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-select
item-title="displayName"
item-value="type"
persistent-placeholder
:disabled="loading || saving"
:label="$t('Long Date Format')"
:placeholder="$t('Long Date Format')"
:items="allLongDateFormats"
v-model="newProfile.longDateFormat"
/>
</v-col>
<v-col cols="12" md="6">
<v-select
item-title="displayName"
item-value="type"
persistent-placeholder
:disabled="loading || saving"
:label="$t('Short Date Format')"
:placeholder="$t('Short Date Format')"
:items="allShortDateFormats"
v-model="newProfile.shortDateFormat"
/>
</v-col>
<v-col cols="12" md="6">
<v-select
item-title="displayName"
item-value="type"
persistent-placeholder
:disabled="loading || saving"
:label="$t('Long Time Format')"
:placeholder="$t('Long Time Format')"
:items="allLongTimeFormats"
v-model="newProfile.longTimeFormat"
/>
</v-col>
<v-col cols="12" md="6">
<v-select
item-title="displayName"
item-value="type"
persistent-placeholder
:disabled="loading || saving"
:label="$t('Short Time Format')"
:placeholder="$t('Short Time Format')"
:items="allShortTimeFormats"
v-model="newProfile.shortTimeFormat"
/>
</v-col>
</v-row>
</v-card-text>
<v-card-text class="d-flex flex-wrap gap-4">
<v-btn :disabled="inputIsNotChanged || inputIsInvalid || saving" @click="save">
{{ $t('Save changes') }}
<v-progress-circular indeterminate size="24" class="ml-2" v-if="saving"></v-progress-circular>
</v-btn>
<v-btn color="default" variant="tonal" @click="reset">
{{ $t('Reset') }}
</v-btn>
</v-card-text>
</v-form>
</v-card>
</v-col>
</v-row>
<confirm-dialog ref="confirmDialog"/>
<snack-bar ref="snackbar" />
</template>
<script>
import { mapStores } from 'pinia';
import { useRootStore } from '@/stores/index.js';
import { useSettingsStore } from '@/stores/setting.js';
import { useUserStore } from '@/stores/user.js';
import { useAccountsStore } from '@/stores/account.js';
import datetimeConstants from '@/consts/datetime.js';
import { getNameByKeyValue } from '@/lib/common.js';
import {
mdiAccount
} from '@mdi/js';
export default {
data() {
const self = this;
const defaultFirstDayOfWeekName = self.$locale.getDefaultFirstDayOfWeek();
const defaultFirstDayOfWeek = datetimeConstants.allWeekDays[defaultFirstDayOfWeekName] ? datetimeConstants.allWeekDays[defaultFirstDayOfWeekName].type : datetimeConstants.defaultFirstDayOfWeek;
return {
newProfile: {
email: '',
nickname: '',
defaultAccountId: 0,
transactionEditScope: 1,
language: '',
defaultCurrency: self.$locale.getDefaultCurrency(),
firstDayOfWeek: defaultFirstDayOfWeek,
longDateFormat: 0,
shortDateFormat: 0,
longTimeFormat: 0,
shortTimeFormat: 0
},
oldProfile: {
email: '',
nickname: '',
defaultAccountId: 0,
transactionEditScope: 1,
language: '',
defaultCurrency: self.$locale.getDefaultCurrency(),
firstDayOfWeek: defaultFirstDayOfWeek,
longDateFormat: 0,
shortDateFormat: 0,
longTimeFormat: 0,
shortTimeFormat: 0
},
loading: true,
saving: false,
icons: {
user: mdiAccount
}
};
},
computed: {
...mapStores(useRootStore, useSettingsStore, useUserStore, useAccountsStore),
allLanguages() {
return this.$locale.getAllLanguageInfoArray(true);
},
allCurrencies() {
return this.$locale.getAllCurrencies();
},
allAccounts() {
return this.accountsStore.allPlainAccounts;
},
allVisibleAccounts() {
return this.accountsStore.allVisiblePlainAccounts;
},
allWeekDays() {
return this.$locale.getAllWeekDays();
},
allLongDateFormats() {
return this.$locale.getAllLongDateFormats();
},
allShortDateFormats() {
return this.$locale.getAllShortDateFormats();
},
allLongTimeFormats() {
return this.$locale.getAllLongTimeFormats();
},
allShortTimeFormats() {
return this.$locale.getAllShortTimeFormats();
},
allTransactionEditScopeTypes() {
return this.$locale.getAllTransactionEditScopeTypes();
},
currentUserAvatarProvider() {
if (this.oldProfile.avatarProvider === 'gravatar') {
return 'Gravatar';
} else {
return this.$t('None');
}
},
inputIsNotChanged() {
return !!this.inputIsNotChangedProblemMessage;
},
inputIsInvalid() {
return !!this.inputInvalidProblemMessage;
},
extendInputIsInvalid() {
return !!this.extendInputInvalidProblemMessage;
},
langAndRegionInputIsInvalid() {
return !!this.langAndRegionInputInvalidProblemMessage;
},
inputIsNotChangedProblemMessage() {
if (!this.newProfile.email && !this.newProfile.nickname) {
return 'Nothing has been modified';
} else if (this.newProfile.email === this.oldProfile.email &&
this.newProfile.nickname === this.oldProfile.nickname &&
this.newProfile.defaultAccountId === this.oldProfile.defaultAccountId &&
this.newProfile.transactionEditScope === this.oldProfile.transactionEditScope &&
this.newProfile.language === this.oldProfile.language &&
this.newProfile.defaultCurrency === this.oldProfile.defaultCurrency &&
this.newProfile.firstDayOfWeek === this.oldProfile.firstDayOfWeek &&
this.newProfile.longDateFormat === this.oldProfile.longDateFormat &&
this.newProfile.shortDateFormat === this.oldProfile.shortDateFormat &&
this.newProfile.longTimeFormat === this.oldProfile.longTimeFormat &&
this.newProfile.shortTimeFormat === this.oldProfile.shortTimeFormat) {
return 'Nothing has been modified';
} else {
return null;
}
},
inputInvalidProblemMessage() {
if (!this.newProfile.email) {
return 'Email address cannot be empty';
} else if (!this.newProfile.nickname) {
return 'Nickname cannot be empty';
} else if (!this.newProfile.defaultCurrency) {
return 'Default currency cannot be empty';
} else {
return null;
}
},
extendInputInvalidProblemMessage() {
return null;
},
langAndRegionInputInvalidProblemMessage() {
if (!this.newProfile.defaultCurrency) {
return 'Default currency cannot be empty';
} else {
return null;
}
}
},
created() {
const self = this;
self.loading = true;
const promises = [
self.accountsStore.loadAllAccounts({ force: false }),
self.userStore.getCurrentUserProfile()
];
Promise.all(promises).then(responses => {
const profile = responses[1];
self.setCurrentUserProfile(profile);
self.loading = false;
}).catch(error => {
self.oldProfile.nickname = '';
self.oldProfile.email = '';
self.newProfile.nickname = '';
self.newProfile.email = '';
self.loading = false;
if (!error.processed) {
self.$refs.snackbar.showError(error);
}
});
},
methods: {
save() {
const self = this;
const problemMessage = self.inputIsNotChangedProblemMessage || self.inputInvalidProblemMessage || self.extendInputInvalidProblemMessage || self.langAndRegionInputInvalidProblemMessage;
if (problemMessage) {
self.$refs.snackbar.showMessage(problemMessage);
return;
}
self.saving = true;
self.rootStore.updateUserProfile({
profile: self.newProfile
}).then(response => {
self.saving = false;
if (response.user) {
self.setCurrentUserProfile(response.user);
const localeDefaultSettings = self.$locale.setLanguage(response.user.language);
self.settingsStore.updateLocalizedDefaultSettings(localeDefaultSettings);
}
self.$refs.snackbar.showMessage('Your profile has been successfully updated');
}).catch(error => {
self.saving = false;
if (!error.processed) {
self.$refs.snackbar.showError(error);
}
});
},
reset() {
this.setCurrentUserProfile(this.oldProfile);
},
getNameByKeyValue(src, value, keyField, nameField, defaultName) {
return getNameByKeyValue(src, value, keyField, nameField, defaultName);
},
setCurrentUserProfile(profile) {
this.oldProfile.username = profile.username;
this.oldProfile.email = profile.email;
this.oldProfile.nickname = profile.nickname;
this.oldProfile.avatar = profile.avatar;
this.oldProfile.avatarProvider = profile.avatarProvider;
this.oldProfile.defaultAccountId = profile.defaultAccountId;
this.oldProfile.transactionEditScope = profile.transactionEditScope;
this.oldProfile.language = profile.language;
this.oldProfile.defaultCurrency = profile.defaultCurrency;
this.oldProfile.firstDayOfWeek = profile.firstDayOfWeek;
this.oldProfile.longDateFormat = profile.longDateFormat;
this.oldProfile.shortDateFormat = profile.shortDateFormat;
this.oldProfile.longTimeFormat = profile.longTimeFormat;
this.oldProfile.shortTimeFormat = profile.shortTimeFormat;
this.newProfile.email = this.oldProfile.email
this.newProfile.nickname = this.oldProfile.nickname;
this.newProfile.defaultAccountId = this.oldProfile.defaultAccountId;
this.newProfile.transactionEditScope = this.oldProfile.transactionEditScope;
this.newProfile.language = this.oldProfile.language;
this.newProfile.defaultCurrency = this.oldProfile.defaultCurrency;
this.newProfile.firstDayOfWeek = this.oldProfile.firstDayOfWeek;
this.newProfile.longDateFormat = this.oldProfile.longDateFormat;
this.newProfile.shortDateFormat = this.oldProfile.shortDateFormat;
this.newProfile.longTimeFormat = this.oldProfile.longTimeFormat;
this.newProfile.shortTimeFormat = this.oldProfile.shortTimeFormat;
}
}
};
</script>
@@ -0,0 +1,303 @@
<template>
<v-row>
<v-col cols="12">
<v-card :class="{ 'disabled': loadingDataStatistics }">
<template #title>
<div class="d-flex align-center">
<span>{{ $t('Data Management') }}</span>
<v-btn density="compact" color="default" variant="text"
class="ml-2" :icon="true"
v-if="!loadingDataStatistics" @click="reloadUserDataStatistics(true)">
<v-icon :icon="icons.refresh" size="24" />
<v-tooltip activator="parent">{{ $t('Refresh') }}</v-tooltip>
</v-btn>
<v-progress-circular indeterminate size="24" class="ml-2" v-if="loadingDataStatistics"></v-progress-circular>
</div>
</template>
<v-card-text>
<v-row>
<v-col cols="6" sm="3">
<div class="d-flex align-center">
<div class="me-3">
<v-avatar rounded color="info" size="42" class="elevation-1">
<v-icon size="24" :icon="icons.transactions"/>
</v-avatar>
</div>
<div class="d-flex flex-column">
<span class="text-caption">{{ $t('Transaction') }}</span>
<span class="text-h6">{{ displayDataStatistics ? displayDataStatistics.totalTransactionCount : '-' }}</span>
</div>
</div>
</v-col>
<v-col cols="6" sm="3">
<div class="d-flex align-center">
<div class="me-3">
<v-avatar rounded color="primary" size="42" class="elevation-1">
<v-icon size="24" :icon="icons.accounts"/>
</v-avatar>
</div>
<div class="d-flex flex-column">
<span class="text-caption">{{ $t('Accounts') }}</span>
<span class="text-h6">{{ displayDataStatistics ? displayDataStatistics.totalAccountCount : '-' }}</span>
</div>
</div>
</v-col>
<v-col cols="6" sm="3">
<div class="d-flex align-center">
<div class="me-3">
<v-avatar rounded color="success" size="42" class="elevation-1">
<v-icon size="24" :icon="icons.categories"/>
</v-avatar>
</div>
<div class="d-flex flex-column">
<span class="text-caption">{{ $t('Transaction Categories') }}</span>
<span class="text-h6">{{ displayDataStatistics ? displayDataStatistics.totalTransactionCategoryCount : '-' }}</span>
</div>
</div>
</v-col>
<v-col cols="6" sm="3">
<div class="d-flex align-center">
<div class="me-3">
<v-avatar rounded color="secondary" size="42" class="elevation-1">
<v-icon size="24" :icon="icons.tags"/>
</v-avatar>
</div>
<div class="d-flex flex-column">
<span class="text-caption">{{ $t('Transaction Tags') }}</span>
<span class="text-h6">{{ displayDataStatistics ? displayDataStatistics.totalTransactionTagCount : '-' }}</span>
</div>
</div>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" v-if="isDataExportingEnabled">
<v-card :class="{ 'disabled': exportingData }" :title="$t('Export Data')">
<v-card-text>
<span class="text-body-1">{{ $t('Export all data to csv file.') }}&nbsp;{{ $t('It may take a long time, please wait for a few minutes.') }}</span>
</v-card-text>
<v-card-text class="d-flex flex-wrap gap-4">
<v-btn :disabled="exportingData || !dataStatistics || !dataStatistics.totalTransactionCount || dataStatistics.totalTransactionCount === '0'" @click="exportData">
{{ $t('Export Data') }}
<v-progress-circular indeterminate size="24" class="ml-2" v-if="exportingData"></v-progress-circular>
</v-btn>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12">
<v-card :class="{ 'disabled': clearingData }">
<template #title>
<span class="text-error">{{ $t('Danger Zone') }}</span>
</template>
<v-form>
<v-card-text class="py-0">
<span class="text-body-1 text-error">
<v-icon :icon="icons.alert"/>
{{ $t('You CANNOT undo this action. This will clear your accounts, categories, tags and transactions data. Please input your current password to confirm.') }}
</span>
</v-card-text>
<v-card-text class="pb-0">
<v-row class="mb-3">
<v-col cols="12" md="6">
<v-text-field
ref="currentPasswordInput"
autocomplete="current-password"
clearable variant="underlined"
color="error"
:disabled="clearingData"
:placeholder="$t('Current Password')"
:type="isCurrentPasswordVisible ? 'text' : 'password'"
:append-inner-icon="isCurrentPasswordVisible ? icons.eyeSlash : icons.eye"
v-model="currentPasswordForClearData"
@click:append-inner="isCurrentPasswordVisible = !isCurrentPasswordVisible"
@keyup.enter="clearData"
/>
</v-col>
</v-row>
</v-card-text>
<v-card-text class="d-flex flex-wrap gap-4">
<v-btn color="error" :disabled="!currentPasswordForClearData || clearingData" @click="clearData">
{{ $t('Clear User Data') }}
<v-progress-circular indeterminate size="24" class="ml-2" v-if="clearingData"></v-progress-circular>
</v-btn>
</v-card-text>
</v-form>
</v-card>
</v-col>
</v-row>
<confirm-dialog ref="confirmDialog"/>
<snack-bar ref="snackbar" />
</template>
<script>
import { mapStores } from 'pinia';
import { useRootStore } from '@/stores/index.js';
import { useSettingsStore } from '@/stores/setting.js';
import { useUserStore } from '@/stores/user.js';
import {appendThousandsSeparator, isEquals} from '@/lib/common.js';
import { isDataExportingEnabled } from '@/lib/server_settings.js';
import { startDownloadFile } from '@/lib/ui.js';
import {
mdiRefresh,
mdiListBoxOutline,
mdiCreditCardOutline,
mdiViewDashboardOutline,
mdiTagOutline,
mdiAlert,
mdiEyeOutline,
mdiEyeOffOutline
} from '@mdi/js';
export default {
data() {
return {
loadingDataStatistics: true,
dataStatistics: null,
exportingData: false,
currentPasswordForClearData: '',
isCurrentPasswordVisible: false,
clearingData: false,
icons: {
refresh: mdiRefresh,
transactions: mdiListBoxOutline,
accounts: mdiCreditCardOutline,
categories: mdiViewDashboardOutline,
tags: mdiTagOutline,
alert: mdiAlert,
eye: mdiEyeOutline,
eyeSlash: mdiEyeOffOutline
}
}
},
computed: {
...mapStores(useRootStore, useSettingsStore, useUserStore),
isEnableThousandsSeparator() {
return this.settingsStore.appSettings.thousandsSeparator;
},
displayDataStatistics() {
const self = this;
if (!self.dataStatistics) {
return null;
}
return {
totalAccountCount: appendThousandsSeparator(self.dataStatistics.totalAccountCount, self.isEnableThousandsSeparator),
totalTransactionCategoryCount: appendThousandsSeparator(self.dataStatistics.totalTransactionCategoryCount, self.isEnableThousandsSeparator),
totalTransactionTagCount: appendThousandsSeparator(self.dataStatistics.totalTransactionTagCount, self.isEnableThousandsSeparator),
totalTransactionCount: appendThousandsSeparator(self.dataStatistics.totalTransactionCount, self.isEnableThousandsSeparator)
};
},
isDataExportingEnabled() {
return isDataExportingEnabled();
},
exportFileName() {
const nickname = this.userStore.currentUserNickname;
if (nickname) {
return this.$t('dataExport.exportFilename', {
nickname: nickname
}) + '.csv';
}
return this.$t('dataExport.defaultExportFilename') + '.csv';
}
},
created() {
this.reloadUserDataStatistics(false);
},
methods: {
reloadUserDataStatistics(force) {
const self = this;
self.loadingDataStatistics = true;
self.userStore.getUserDataStatistics().then(dataStatistics => {
if (force) {
if (isEquals(self.dataStatistics, dataStatistics)) {
self.$refs.snackbar.showMessage('Data is up to date');
} else {
self.$refs.snackbar.showMessage('Data has been updated');
}
}
self.dataStatistics = dataStatistics;
self.loadingDataStatistics = false;
}).catch(error => {
self.loadingDataStatistics = false;
if (!error.processed) {
self.$refs.snackbar.showError(error);
}
});
},
exportData() {
const self = this;
if (self.exportingData) {
return;
}
self.exportingData = true;
self.userStore.getExportedUserData().then(data => {
startDownloadFile(self.exportFileName, data);
self.exportingData = false;
}).catch(error => {
self.exportingData = false;
if (!error.processed) {
self.$refs.snackbar.showError(error);
}
});
},
clearData() {
const self = this;
if (!self.currentPasswordForClearData) {
self.$refs.snackbar.showMessage('Current password cannot be empty');
return;
}
if (self.clearingData) {
return;
}
self.$refs.confirmDialog.open('Are you sure you want to clear all data?', { color: 'error' }).then(() => {
self.clearingData = true;
self.isCurrentPasswordVisible = false;
self.rootStore.clearUserData({
password: self.currentPasswordForClearData
}).then(() => {
self.clearingData = false;
self.$refs.snackbar.showMessage('All user data has been cleared');
self.reloadUserDataStatistics();
}).catch(error => {
self.clearingData = false;
if (!error.processed) {
self.$refs.snackbar.showError(error);
}
});
});
}
}
}
</script>
@@ -0,0 +1,377 @@
<template>
<v-row>
<v-col cols="12">
<v-card :class="{ 'disabled': updatingPassword }" :title="$t('Modify Password')">
<v-form>
<v-card-text class="pt-0">
<span class="text-body-1">{{ $t('After the password is changed, other devices will be logged out, please log in again on other devices by using the new password.') }}</span>
</v-card-text>
<v-card-text>
<v-row>
<v-col cols="12" md="6">
<v-text-field
ref="currentPasswordInput"
autocomplete="current-password"
clearable
:disabled="updatingPassword"
:label="$t('Current Password')"
:placeholder="$t('Current Password')"
:type="isCurrentPasswordVisible ? 'text' : 'password'"
:append-inner-icon="isCurrentPasswordVisible ? icons.eyeSlash : icons.eye"
v-model="currentPassword"
@click:append-inner="isCurrentPasswordVisible = !isCurrentPasswordVisible"
@keyup.enter="$refs.newPasswordInput.focus()"
/>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-text-field
ref="newPasswordInput"
autocomplete="new-password"
clearable
:disabled="updatingPassword"
:label="$t('New Password')"
:placeholder="$t('New Password')"
:type="isNewPasswordVisible ? 'text' : 'password'"
:append-inner-icon="isNewPasswordVisible ? icons.eyeSlash : icons.eye"
v-model="newPassword"
@click:append-inner="isNewPasswordVisible = !isNewPasswordVisible"
@keyup.enter="$refs.confirmPasswordInput.focus()"
/>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-text-field
ref="confirmPasswordInput"
clearable
:disabled="updatingPassword"
:type="isConfirmPasswordVisible ? 'text' : 'password'"
:label="$t('Confirmation Password')"
:placeholder="$t('Re-enter the password')"
:append-inner-icon="isConfirmPasswordVisible ? icons.eyeSlash : icons.eye"
v-model="confirmPassword"
@click:append-inner="isConfirmPasswordVisible = !isConfirmPasswordVisible"
@keyup.enter="updatePassword"
/>
</v-col>
</v-row>
</v-card-text>
<v-card-text class="d-flex flex-wrap gap-4">
<v-btn :disabled="!currentPassword || !newPassword || !confirmPassword || updatingPassword" @click="updatePassword">
{{ $t('Save changes') }}
<v-progress-circular indeterminate size="24" class="ml-2" v-if="updatingPassword"></v-progress-circular>
</v-btn>
</v-card-text>
</v-form>
</v-card>
</v-col>
<v-col cols="12">
<v-card :class="{ 'disabled': loadingSession }">
<template #title>
<div class="d-flex align-center">
<span>{{ $t('Device & Sessions') }}</span>
<v-btn density="compact" color="default" variant="text"
class="ml-2" :icon="true"
v-if="!loadingSession" @click="reloadSessions">
<v-icon :icon="icons.refresh" size="24" />
<v-tooltip activator="parent">{{ $t('Refresh') }}</v-tooltip>
</v-btn>
<v-progress-circular indeterminate size="24" class="ml-2" v-if="loadingSession"></v-progress-circular>
</div>
</template>
<v-table class="table-striped text-no-wrap" :hover="!loadingSession">
<thead>
<tr>
<th class="text-uppercase">{{ $t('Type') }}</th>
<th class="text-uppercase">{{ $t('Device Info') }}</th>
<th class="text-uppercase">{{ $t('Last Activity Time') }}</th>
<th class="text-uppercase text-right">
<v-btn density="comfortable" color="error" variant="tonal"
:disabled="sessions.length < 2 || loadingSession"
@click="revokeAllSessions">
{{ $t('Logout All') }}
</v-btn>
</th>
</tr>
</thead>
<tbody>
<tr :key="itemIdx"
v-for="itemIdx in (loadingSession && (!sessions || sessions.length < 1) ? [ 1, 2, 3 ] : [])">
<td class="px-0" colspan="4">
<v-skeleton-loader type="text" :loading="true"></v-skeleton-loader>
</td>
</tr>
<tr :key="session.tokenId"
v-for="session in sessions">
<td class="text-sm">
<v-icon start :icon="session.icon"/>
{{ session.deviceType }}
</td>
<td class="text-sm">{{ session.deviceInfo }}</td>
<td class="text-sm">{{ session.createdAt }}</td>
<td class="text-sm text-right">
<v-btn density="comfortable" color="error" variant="tonal"
:disabled="session.isCurrent || loadingSession"
@click="revokeSession(session)">
{{ $t('Log Out') }}
</v-btn>
</td>
</tr>
</tbody>
</v-table>
</v-card>
</v-col>
</v-row>
<confirm-dialog ref="confirmDialog"/>
<snack-bar ref="snackbar" />
</template>
<script>
import { mapStores } from 'pinia';
import { useRootStore } from '@/stores/index.js';
import { useSettingsStore } from '@/stores/setting.js';
import { useUserStore } from '@/stores/user.js';
import { useTokensStore } from '@/stores/token.js';
import { isEquals } from '@/lib/common.js';
import { parseDeviceInfo, parseUserAgent } from '@/lib/misc.js';
import {
mdiRefresh,
mdiEyeOffOutline,
mdiEyeOutline,
mdiCellphone,
mdiTablet,
mdiWatch,
mdiTelevision,
mdiDevices
} from '@mdi/js';
export default {
data() {
return {
tokens: [],
currentPassword: '',
newPassword: '',
confirmPassword: '',
isCurrentPasswordVisible: false,
isNewPasswordVisible: false,
isConfirmPasswordVisible: false,
updatingPassword: false,
loadingSession: true,
icons: {
refresh: mdiRefresh,
eye: mdiEyeOutline,
eyeSlash: mdiEyeOffOutline
}
};
},
computed: {
...mapStores(useRootStore, useSettingsStore, useUserStore, useTokensStore),
inputProblemMessage() {
if (!this.currentPassword) {
return 'Current password cannot be empty';
} else if (!this.newPassword && !this.confirmPassword) {
return 'Nothing has been modified';
} else if (!this.newPassword && this.confirmPassword) {
return 'New password cannot be empty';
} else if (this.newPassword && !this.confirmPassword) {
return 'Confirmation password cannot be empty';
} else if (this.newPassword && this.confirmPassword && this.newPassword !== this.confirmPassword) {
return 'Password and confirmation password do not match';
} else {
return null;
}
},
sessions() {
if (!this.tokens) {
return this.tokens;
}
const sessions = [];
for (let i = 0; i < this.tokens.length; i++) {
const token = this.tokens[i];
sessions.push({
tokenId: token.tokenId,
isCurrent: token.isCurrent,
deviceType: this.$t(token.isCurrent ? 'Current' : 'Other Device'),
deviceInfo: parseDeviceInfo(token.userAgent),
icon: this.getTokenIcon(token),
createdAt: this.$locale.formatUnixTimeToLongDateTime(this.userStore, token.createdAt)
});
}
return sessions;
}
},
created() {
const self = this;
self.loadingSession = true;
self.tokensStore.getAllTokens().then(tokens => {
self.tokens = tokens;
self.loadingSession = false;
}).catch(error => {
self.loadingSession = false;
if (!error.processed) {
self.$refs.snackbar.showError(error);
}
});
},
methods: {
updatePassword() {
const self = this;
const problemMessage = self.inputProblemMessage;
if (problemMessage) {
self.$refs.snackbar.showMessage(problemMessage);
return;
}
self.updatingPassword = true;
self.isCurrentPasswordVisible = false;
self.isNewPasswordVisible = false;
self.isConfirmPasswordVisible = false;
self.rootStore.updateUserProfile({
profile: {
password: self.newPassword,
confirmPassword: self.confirmPassword
},
currentPassword: self.currentPassword
}).then(response => {
self.updatingPassword = false;
self.currentPassword = '';
self.newPassword = '';
self.confirmPassword = '';
if (response.user) {
const localeDefaultSettings = self.$locale.setLanguage(response.user.language);
self.settingsStore.updateLocalizedDefaultSettings(localeDefaultSettings);
}
self.reloadSessions();
self.$refs.snackbar.showMessage('Your profile has been successfully updated');
}).catch(error => {
self.updatingPassword = false;
self.currentPassword = '';
if (!error.processed) {
self.$refs.snackbar.showError(error);
}
});
},
reloadSessions() {
const self = this;
self.loadingSession = true;
self.tokensStore.getAllTokens().then(tokens => {
if (isEquals(self.tokens, tokens)) {
self.$refs.snackbar.showMessage('Session list is up to date');
} else {
self.$refs.snackbar.showMessage('Session list has been updated');
}
self.tokens = tokens;
self.loadingSession = false;
}).catch(error => {
self.loadingSession = false;
if (!error.processed) {
self.$refs.snackbar.showError(error);
}
});
},
revokeSession(session) {
const self = this;
self.$refs.confirmDialog.open('Are you sure you want to logout from this session?').then(() => {
self.loadingSession = true;
self.tokensStore.revokeToken({
tokenId: session.tokenId
}).then(() => {
self.loadingSession = false;
for (let i = 0; i < self.tokens.length; i++) {
if (self.tokens[i].tokenId === session.tokenId) {
self.tokens.splice(i, 1);
}
}
}).catch(error => {
self.loadingSession = false;
if (!error.processed) {
self.$refs.snackbar.showError(error);
}
});
});
},
revokeAllSessions() {
const self = this;
if (self.tokens.length < 2) {
return;
}
self.$refs.confirmDialog.open('Are you sure you want to logout all other sessions?').then(() => {
self.loadingSession = true;
self.tokensStore.revokeAllTokens().then(() => {
self.loadingSession = false;
for (let i = self.tokens.length - 1; i >= 0; i--) {
if (!self.tokens[i].isCurrent) {
self.tokens.splice(i, 1);
}
}
self.$refs.snackbar.showMessage('You have logged out all other sessions');
}).catch(error => {
self.loadingSession = false
if (!error.processed) {
self.$refs.snackbar.showError(error);
}
});
});
},
getTokenIcon(token) {
const ua = parseUserAgent(token.userAgent);
if (!ua || !ua.device) {
return mdiDevices;
}
if (ua.device.type === 'mobile') {
return mdiCellphone;
} else if (ua.device.type === 'wearable') {
return mdiWatch;
} else if (ua.device.type === 'tablet') {
return mdiTablet;
} else if (ua.device.type === 'smarttv') {
return mdiTelevision;
} else {
return mdiDevices;
}
}
}
};
</script>
@@ -0,0 +1,351 @@
<template>
<v-row>
<v-col cols="12">
<v-card :class="{ 'disabled': loading }">
<template #title>
<span>{{ $t('Two-Factor Authentication') }}</span>
<v-progress-circular indeterminate size="24" class="ml-2" v-if="loading"></v-progress-circular>
</template>
<v-card-text class="pb-0">
<p class="text-body-1 font-weight-semibold" v-if="!new2FAQRCode">
{{ status === true ? $t('Two-factor authentication has been enabled.') : $t('Two-factor authentication is not enabled yet.') }}
</p>
<p class="text-body-1" v-if="new2FAQRCode">
{{ $t('Please use two factor authentication app scan the below qrcode and input current passcode') }}
</p>
<p class="text-body-1" v-if="status === true">
{{ $t('Please enter your current password when disable two factor authentication or regenerate two factor authentication backup codes. If you regenerate backup codes, the old codes will be invalidated.') }}
</p>
</v-card-text>
<v-card-text v-if="status === false && new2FAQRCode">
<v-img alt="qrcode" class="img-qrcode" :src="new2FAQRCode" />
<v-row class="mb-3">
<v-col cols="12" md="3">
<v-text-field
type="number"
autocomplete="one-time-code"
clearable variant="underlined"
:disabled="loading || enabling || enableConfirming || disabling"
:placeholder="$t('Passcode')"
v-model="currentPasscode"
@keyup.enter="enableConfirm"
/>
</v-col>
</v-row>
</v-card-text>
<v-card-text class="pb-0" v-if="status === true">
<v-row class="mb-3">
<v-col cols="12" md="6">
<v-text-field
autocomplete="current-password"
clearable variant="underlined"
:disabled="loading || enabling || enableConfirming || disabling"
:placeholder="$t('Current Password')"
:type="isCurrentPasswordVisible ? 'text' : 'password'"
:append-inner-icon="isCurrentPasswordVisible ? icons.eyeSlash : icons.eye"
v-model="currentPassword"
@click:append-inner="isCurrentPasswordVisible = !isCurrentPasswordVisible"
/>
</v-col>
</v-row>
</v-card-text>
<v-card-text>
<v-row>
<v-col cols="12" class="d-flex flex-wrap gap-4">
<v-btn :disabled="!currentPassword || loading || disabling " v-if="status === true" @click="disable">
{{ $t('Disable two-factor authentication') }}
<v-progress-circular indeterminate size="24" class="ml-2" v-if="disabling"></v-progress-circular>
</v-btn>
<v-btn :disabled="!currentPassword || loading || regenerating" v-if="status === true" @click="regenerateBackupCode()">
{{ $t('Regenerate Backup Codes') }}
<v-progress-circular indeterminate size="24" class="ml-2" v-if="regenerating"></v-progress-circular>
</v-btn>
<v-btn :disabled="loading || enabling" v-if="status === false && !new2FAQRCode" @click="enable">
{{ $t('Enable two-factor authentication') }}
<v-progress-circular indeterminate size="24" class="ml-2" v-if="enabling"></v-progress-circular>
</v-btn>
<v-btn :disabled="!currentPasscode || loading || enableConfirming" v-if="status === false && new2FAQRCode" @click="enableConfirm">
{{ $t('Continue') }}
<v-progress-circular indeterminate size="24" class="ml-2" v-if="enableConfirming"></v-progress-circular>
</v-btn>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12">
<v-card v-if="currentBackupCode">
<template #title>
<span>{{ $t('Backup Code') }}</span>
<v-btn id="copy-to-clipboard-icon" ref="copyToClipboardIcon"
density="compact" color="default" variant="text"
class="ml-2" :icon="true">
<v-icon :icon="icons.copy" size="20" />
<v-tooltip activator="parent">{{ $t('Copy') }}</v-tooltip>
</v-btn>
</template>
<v-card-text>
<p class="text-body-1" v-if="status === true">
{{ $t('Please copy these backup codes to safe place, the below codes can only be shown once. If these codes were lost, you can regenerate backup codes at any time.') }}
</p>
<v-textarea class="backup-code" readonly="readonly" :rows="10" :value="currentBackupCode"/>
</v-card-text>
</v-card>
</v-col>
</v-row>
<snack-bar ref="snackbar" />
</template>
<script>
import { mapStores } from 'pinia';
import { useTwoFactorAuthStore } from '@/stores/twoFactorAuth.js';
import { makeButtonCopyToClipboard, changeClipboardObjectText } from '@/lib/misc.js';
import {
mdiEyeOutline,
mdiEyeOffOutline,
mdiContentCopy
} from '@mdi/js';
export default {
expose: [
'reset'
],
data() {
return {
status: null,
loading: true,
new2FASecret: '',
new2FAQRCode: '',
currentPassword: '',
isCurrentPasswordVisible: false,
currentPasscode: '',
currentBackupCode: '',
enabling: false,
enableConfirming: false,
disabling: false,
regenerating: false,
clipboardHolder: null,
icons: {
eye: mdiEyeOutline,
eyeSlash: mdiEyeOffOutline,
copy: mdiContentCopy
}
};
},
computed: {
...mapStores(useTwoFactorAuthStore),
},
created() {
const self = this;
self.loading = true;
self.twoFactorAuthStore.get2FAStatus().then(response => {
self.status = response.enable;
self.loading = false;
}).catch(error => {
self.loading = false;
if (!error.processed) {
self.$refs.snackbar.showError(error);
}
});
},
watch: {
'currentBackupCode': function (newValue) {
if (this.clipboardHolder) {
changeClipboardObjectText(this.clipboardHolder, newValue);
}
}
},
methods: {
reset() {
this.new2FASecret = '';
this.new2FAQRCode = '';
this.currentPassword = '';
this.isCurrentPasswordVisible = false;
this.currentPasscode = '';
this.currentBackupCode = '';
this.enabling = false;
this.enableConfirming = false;
this.disabling = false;
this.regenerating = false;
},
enable() {
const self = this;
self.new2FAQRCode = '';
self.new2FASecret = '';
self.currentBackupCode = '';
self.enabling = true;
self.twoFactorAuthStore.enable2FA().then(response => {
self.enabling = false;
self.new2FAQRCode = response.qrcode;
self.new2FASecret = response.secret;
}).catch(error => {
self.enabling = false;
if (!error.processed) {
self.$refs.snackbar.showError(error);
}
});
},
enableConfirm() {
const self = this;
if (!self.currentPasscode) {
self.$refs.snackbar.showMessage('Passcode cannot be empty');
return;
}
if (self.enableConfirming) {
return;
}
const password = self.currentPasscode;
self.currentBackupCode = '';
self.currentPasscode = '';
self.enableConfirming = true;
self.twoFactorAuthStore.confirmEnable2FA({
secret: self.new2FASecret,
passcode: password
}).then(response => {
self.enableConfirming = false;
self.new2FAQRCode = '';
self.new2FASecret = '';
self.status = true;
if (response.recoveryCodes && response.recoveryCodes.length) {
self.currentBackupCode = response.recoveryCodes.join('\n');
}
self.$nextTick(() => {
self.makeCopyToClipboardClickable();
});
}).catch(error => {
self.enableConfirming = false;
if (!error.processed) {
self.$refs.snackbar.showError(error);
}
});
},
disable() {
const self = this;
if (!self.currentPassword) {
self.$refs.snackbar.showMessage('Current password cannot be empty');
return;
}
if (self.disabling) {
return;
}
const password = self.currentPassword;
self.currentBackupCode = '';
self.currentPassword = '';
self.disabling = true;
self.twoFactorAuthStore.disable2FA({
password: password
}).then(() => {
self.disabling = false;
self.status = false;
self.$refs.snackbar.showMessage('Two factor authentication has been disabled');
}).catch(error => {
self.disabling = false;
if (!error.processed) {
self.$refs.snackbar.showError(error);
}
});
},
regenerateBackupCode() {
const self = this;
if (!self.currentPassword) {
self.$refs.snackbar.showMessage('Current password cannot be empty');
return;
}
if (self.regenerating) {
return;
}
const password = self.currentPassword;
self.currentBackupCode = '';
self.currentPassword = '';
self.regenerating = true;
self.twoFactorAuthStore.regenerate2FARecoveryCode({
password: password
}).then(response => {
self.regenerating = false;
self.currentBackupCode = response.recoveryCodes.join('\n');
self.$nextTick(() => {
self.makeCopyToClipboardClickable();
});
}).catch(error => {
self.regenerating = false;
if (!error.processed) {
self.$refs.snackbar.showError(error);
}
});
},
makeCopyToClipboardClickable() {
const self = this;
if (self.clipboardHolder) {
return;
}
if (self.$refs.copyToClipboardIcon) {
self.clipboardHolder = makeButtonCopyToClipboard({
el: '#copy-to-clipboard-icon',
text: self.currentBackupCode,
successCallback: function () {
self.$refs.snackbar.showMessage('Backup codes copied');
}
});
}
}
}
};
</script>
<style>
.img-qrcode {
width: 240px;
height: 240px
}
.backup-code {
font-family: monospace;
}
</style>