add user settings page

This commit is contained in:
MaysWind
2023-06-24 01:18:41 +08:00
parent 69f5aca853
commit 178810f908
11 changed files with 1716 additions and 8 deletions
+3 -3
View File
@@ -121,7 +121,7 @@
<template #prepend>
<v-icon class="me-2" :icon="icons.profile" size="22"/>
</template>
<v-list-item-title>{{ $t('User Profile') }}</v-list-item-title>
<v-list-item-title>{{ $t('User Settings') }}</v-list-item-title>
</v-list-item>
<v-list-item to="/app/settings">
<template #prepend>
@@ -191,7 +191,7 @@ import {
mdiWeatherSunny,
mdiWeatherNight,
mdiAccount,
mdiAccountOutline,
mdiAccountCogOutline,
mdiLogout
} from '@mdi/js';
@@ -222,7 +222,7 @@ export default {
themeLight: mdiWeatherSunny,
themeDark: mdiWeatherNight,
user: mdiAccount,
profile: mdiAccountOutline,
profile: mdiAccountCogOutline,
logout: mdiLogout
}
}
+66 -5
View File
@@ -1,13 +1,74 @@
<template>
<v-row class="match-height">
user settings
</v-row>
<div>
<v-tabs show-arrows class="text-uppercase" v-model="activeTab">
<v-tab value="basicSetting">
<v-icon size="20" start :icon="icons.basicSetting"/>
{{ $t('Basic') }}
</v-tab>
<v-tab value="securitySetting">
<v-icon size="20" start :icon="icons.securitySetting"/>
{{ $t('Security') }}
</v-tab>
<v-tab value="twoFactorSetting">
<v-icon size="20" start :icon="icons.twoFactorSetting"/>
{{ $t('Two-Factor Authentication') }}
</v-tab>
<v-tab value="dataManagementSetting">
<v-icon size="20" start :icon="icons.dataManagementSetting"/>
{{ $t('Data Management') }}
</v-tab>
</v-tabs>
<v-divider />
<v-window class="mt-5 disable-tab-transition" v-model="activeTab">
<v-window-item value="basicSetting">
<user-basic-setting-tab/>
</v-window-item>
<v-window-item value="securitySetting">
<user-security-setting-tab/>
</v-window-item>
<v-window-item value="twoFactorSetting">
<user-two-factor-auth-setting-tab/>
</v-window-item>
<v-window-item value="dataManagementSetting">
<user-data-management-setting-tab/>
</v-window-item>
</v-window>
</div>
</template>
<script>
export default {
created() {
import UserBasicSettingTab from "./settings/UserBasicSettingTab.vue";
import UserSecuritySettingTab from "./settings/UserSecuritySettingTab.vue";
import UserTwoFactorAuthSettingTab from "./settings/UserTwoFactorAuthSettingTab.vue";
import UserDataManagementSettingTab from "./settings/UserDataManagementSettingTab.vue";
import {
mdiAccountOutline,
mdiLockOpenOutline,
mdiOnepassword,
mdiDatabaseCogOutline
} from '@mdi/js';
export default {
components: {
UserBasicSettingTab,
UserSecuritySettingTab,
UserTwoFactorAuthSettingTab,
UserDataManagementSettingTab
},
data() {
return {
activeTab: 'basicSetting',
icons: {
basicSetting: mdiAccountOutline,
securitySetting: mdiLockOpenOutline,
twoFactorSetting: mdiOnepassword,
dataManagementSetting: mdiDatabaseCogOutline
}
};
}
}
</script>
@@ -0,0 +1,462 @@
<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-form>
<v-card-text>
<v-row>
<v-col cols="12" md="6">
<v-text-field
type="text"
autocomplete="nickname"
clearable
: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
: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"
:disabled="loading || saving"
:label="$t('Default Account')"
:placeholder="$t('Default Account')"
:items="allVisibleAccounts"
v-model="newProfile.defaultAccountId"
>
<template v-slot:selection="{ item }">
<v-label>{{ !item || item.value === 0 || item.value === '0' ? $t('Not Specified') : item.title }}</v-label>
</template>
<template v-slot:no-data>
<div class="px-4">{{ $t('No results') }}</div>
</template>
</v-select>
</v-col>
<v-col cols="12" md="6">
<v-select
item-title="name"
item-value="value"
: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"
: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"
:disabled="loading || saving"
:label="$t('Default Currency')"
:placeholder="$t('Default Currency')"
:items="allCurrencies"
v-model="newProfile.defaultCurrency"
>
<template v-slot:no-data>
<div class="px-4">{{ $t('No results') }}</div>
</template>
</v-autocomplete>
</v-col>
<v-col cols="12" md="6">
<v-select
item-title="displayName"
item-value="type"
: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"
: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"
: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"
: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"
: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="saving" @click="save">
{{ $t('Save changes') }}
<v-progress-circular indeterminate size="24" class="ml-2" v-if="saving"></v-progress-circular>
</v-btn>
</v-card-text>
</v-form>
</v-card>
</v-col>
</v-row>
<confirm-dialog ref="confirmDialog"/>
<v-snackbar v-model="showSnackbar">
{{ snackbarMessage }}
<template #actions>
<v-btn color="primary" variant="text" @click="showSnackbar = false">{{ $t('Close') }}</v-btn>
</template>
</v-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';
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: self.$t('Your nickname'),
defaultAccountId: 0,
transactionEditScope: 1,
language: '',
defaultCurrency: self.$locale.getDefaultCurrency(),
firstDayOfWeek: defaultFirstDayOfWeek,
longDateFormat: 0,
shortDateFormat: 0,
longTimeFormat: 0,
shortTimeFormat: 0
},
oldProfile: {
email: '',
nickname: self.$t('Your nickname'),
defaultAccountId: 0,
transactionEditScope: 1,
language: '',
defaultCurrency: self.$locale.getDefaultCurrency(),
firstDayOfWeek: defaultFirstDayOfWeek,
longDateFormat: 0,
shortDateFormat: 0,
longTimeFormat: 0,
shortTimeFormat: 0
},
loading: true,
saving: false,
showSnackbar: false,
snackbarMessage: ''
};
},
computed: {
...mapStores(useRootStore, useSettingsStore, useUserStore, useAccountsStore),
allLanguages() {
const ret = [];
const allLanguageInfo = this.$locale.getAllLanguageInfos();
ret.push({
code: '',
displayName: this.$t('System Default')
});
for (let code in allLanguageInfo) {
if (!Object.prototype.hasOwnProperty.call(allLanguageInfo, code)) {
continue;
}
const languageInfo = allLanguageInfo[code];
ret.push({
code: code,
displayName: languageInfo.displayName
});
}
return ret;
},
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() {
const self = this;
return [{
value: 0,
name: self.$t('None')
}, {
value: 1,
name: self.$t('All')
}, {
value: 2,
name: self.$t('Today or later')
}, {
value: 3,
name: self.$t('Recent 24 hours or later')
}, {
value: 4,
name: self.$t('This week or later')
}, {
value: 5,
name: self.$t('This month or later')
}, {
value: 6,
name: self.$t('This year or later')
}];
},
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.showSnackbarMessage(self.$tError(error.message || error));
}
});
},
methods: {
save() {
const self = this;
const problemMessage = self.inputIsNotChangedProblemMessage || self.inputInvalidProblemMessage || self.extendInputInvalidProblemMessage || self.langAndRegionInputInvalidProblemMessage;
if (problemMessage) {
self.showSnackbarMessage(self.$t(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.showSnackbarMessage(self.$t('Your profile has been successfully updated'));
}).catch(error => {
self.saving = false;
if (!error.processed) {
self.showSnackbarMessage(self.$tError(error.message || error));
}
});
},
getNameByKeyValue(src, value, keyField, nameField, defaultName) {
return getNameByKeyValue(src, value, keyField, nameField, defaultName);
},
setCurrentUserProfile(profile) {
this.oldProfile.email = profile.email;
this.oldProfile.nickname = profile.nickname;
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;
},
showSnackbarMessage(message) {
this.showSnackbar = true;
this.snackbarMessage = message;
}
}
};
</script>
@@ -0,0 +1,301 @@
<template>
<v-row>
<v-col cols="12">
<v-card :class="{ 'disabled': loadingDataStatistics }">
<template #title>
<span>{{ $t('Data Management') }}</span>
<v-btn density="compact" color="default" variant="text"
class="ml-2" :icon="true"
v-if="!loadingDataStatistics" @click="reloadUserDataStatistics">
<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>
</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-card :class="{ 'disabled': exportingData }" :title="$t('Export Data')">
<v-card-text>
<span class="text-subtitle-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" @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-subtitle-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="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"/>
<v-snackbar v-model="showSnackbar">
{{ snackbarMessage }}
<template #actions>
<v-btn color="primary" variant="text" @click="showSnackbar = false">{{ $t('Close') }}</v-btn>
</template>
</v-snackbar>
</template>
<script>
import { mapStores } from 'pinia';
import { useRootStore } from '@/stores/index.js';
import { useUserStore } from '@/stores/user.js';
import { appendThousandsSeparator } from '@/lib/common.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,
showSnackbar: false,
snackbarMessage: '',
icons: {
refresh: mdiRefresh,
transactions: mdiListBoxOutline,
accounts: mdiCreditCardOutline,
categories: mdiViewDashboardOutline,
tags: mdiTagOutline,
alert: mdiAlert,
eye: mdiEyeOutline,
eyeSlash: mdiEyeOffOutline
}
}
},
computed: {
...mapStores(useRootStore, useUserStore),
displayDataStatistics() {
const self = this;
if (!self.dataStatistics) {
return null;
}
return {
totalAccountCount: appendThousandsSeparator(self.dataStatistics.totalAccountCount),
totalTransactionCategoryCount: appendThousandsSeparator(self.dataStatistics.totalTransactionCategoryCount),
totalTransactionTagCount: appendThousandsSeparator(self.dataStatistics.totalTransactionTagCount),
totalTransactionCount: appendThousandsSeparator(self.dataStatistics.totalTransactionCount)
};
},
isDataExportingEnabled() {
return this.$settings.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();
},
methods: {
reloadUserDataStatistics() {
const self = this;
self.loadingDataStatistics = true;
self.userStore.getUserDataStatistics().then(dataStatistics => {
self.dataStatistics = dataStatistics;
self.loadingDataStatistics = false;
}).catch(error => {
self.loadingDataStatistics = false;
if (!error.processed) {
self.showSnackbarMessage(self.$tError(error.message || 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.showSnackbarMessage(self.$tError(error.message || error));
}
});
},
clearData() {
const self = this;
if (!self.currentPasswordForClearData) {
self.showSnackbarMessage(self.$t('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.showSnackbarMessage(self.$t('All user data has been cleared'));
self.reloadUserDataStatistics();
}).catch(error => {
self.clearingData = false;
if (!error.processed) {
self.showSnackbarMessage(self.$tError(error.message || error));
}
});
});
},
showSnackbarMessage(message) {
this.showSnackbar = true;
this.snackbarMessage = message;
}
}
}
</script>
@@ -0,0 +1,374 @@
<template>
<v-row>
<v-col cols="12">
<v-card :class="{ 'disabled': updatingPassword }" :title="$t('Modify Password')">
<v-form>
<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="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>
<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>
</template>
<v-table class="text-no-wrap">
<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-center">
<v-btn density="comfortable"
:disabled="sessions.length < 2 || loadingSession"
@click="revokeAllSessions">
{{ $t('Logout All') }}
</v-btn>
</th>
</tr>
</thead>
<tbody>
<tr :key="session.tokenId" v-for="session in sessions">
<td>
<v-icon start :icon="session.icon"/>
{{ session.deviceType }}
</td>
<td>{{ session.deviceInfo }}</td>
<td>{{ session.createdAt }}</td>
<td class="text-center">
<v-btn density="comfortable"
: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"/>
<v-snackbar v-model="showSnackbar">
{{ snackbarMessage }}
<template #actions>
<v-btn color="primary" variant="text" @click="showSnackbar = false">{{ $t('Close') }}</v-btn>
</template>
</v-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,
showSnackbar: false,
snackbarMessage: '',
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.showSnackbarMessage(self.$tError(error.message || error));
}
});
},
methods: {
updatePassword() {
const self = this;
const problemMessage = self.inputProblemMessage;
if (problemMessage) {
self.showSnackbarMessage(self.$t(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.showSnackbarMessage(self.$t('Your profile has been successfully updated'));
}).catch(error => {
self.updatingPassword = false;
self.currentPassword = '';
if (!error.processed) {
self.showSnackbarMessage(self.$tError(error.message || error));
}
});
},
reloadSessions() {
const self = this;
self.loadingSession = true;
self.tokensStore.getAllTokens().then(tokens => {
if (isEquals(self.tokens, tokens)) {
self.showSnackbarMessage(this.$t('Session list is up to date'));
} else {
self.showSnackbarMessage(this.$t('Session list has been updated'));
}
self.tokens = tokens;
self.loadingSession = false;
}).catch(error => {
self.loadingSession = false;
if (!error.processed) {
self.showSnackbarMessage(self.$tError(error.message || 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.showSnackbarMessage(self.$tError(error.message || 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.showSnackbarMessage(this.$t('You have logged out all other sessions'));
}).catch(error => {
self.loadingSession = false
if (!error.processed) {
self.showSnackbarMessage(self.$tError(error.message || 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;
}
},
showSnackbarMessage(message) {
this.showSnackbar = true;
this.snackbarMessage = message;
}
}
};
</script>
@@ -0,0 +1,348 @@
<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-subtitle-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-subtitle-1" v-if="new2FAQRCode">
{{ $t('Please use two factor authentication app scan the below qrcode and input current passcode') }}
</p>
<p class="text-subtitle-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="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="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="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-subtitle-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>
<v-snackbar v-model="showSnackbar">
{{ snackbarMessage }}
<template #actions>
<v-btn color="primary" variant="text" @click="showSnackbar = false">{{ $t('Close') }}</v-btn>
</template>
</v-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 {
data() {
return {
status: null,
loading: true,
new2FASecret: '',
new2FAQRCode: '',
currentPassword: '',
isCurrentPasswordVisible: false,
currentPasscode: '',
currentBackupCode: '',
enabling: false,
enableConfirming: false,
disabling: false,
regenerating: false,
clipboardHolder: null,
showSnackbar: false,
snackbarMessage: '',
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.showSnackbarMessage(self.$tError(error.message || error));
}
});
},
watch: {
'currentBackupCode': function (newValue) {
if (this.clipboardHolder) {
changeClipboardObjectText(this.clipboardHolder, newValue);
}
}
},
methods: {
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.showSnackbarMessage(self.$tError(error.message || error));
}
});
},
enableConfirm() {
const self = this;
if (!self.currentPasscode) {
self.showSnackbarMessage(self.$t('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.showSnackbarMessage(self.$tError(error.message || error));
}
});
},
disable() {
const self = this;
if (!self.currentPassword) {
self.showSnackbarMessage(self.$t('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.showSnackbarMessage(self.$t('Two factor authentication has been disabled'));
}).catch(error => {
self.disabling = false;
if (!error.processed) {
self.showSnackbarMessage(self.$tError(error.message || error));
}
});
},
regenerateBackupCode() {
const self = this;
if (!self.currentPassword) {
self.showSnackbarMessage(self.$t('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.showSnackbarMessage(self.$tError(error.message || 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.showSnackbarMessage(self.$t('Backup codes copied'));
}
});
}
},
showSnackbarMessage(message) {
this.showSnackbar = true;
this.snackbarMessage = message;
}
}
};
</script>
<style>
.img-qrcode {
width: 240px;
height: 240px
}
.backup-code {
font-family: monospace;
}
</style>