diff options
Diffstat (limited to 'src/components/user_settings')
| -rw-r--r-- | src/components/user_settings/confirm.js | 9 | ||||
| -rw-r--r-- | src/components/user_settings/confirm.vue | 22 | ||||
| -rw-r--r-- | src/components/user_settings/mfa.js | 155 | ||||
| -rw-r--r-- | src/components/user_settings/mfa.vue | 173 | ||||
| -rw-r--r-- | src/components/user_settings/mfa_backup_codes.js | 17 | ||||
| -rw-r--r-- | src/components/user_settings/mfa_backup_codes.vue | 33 | ||||
| -rw-r--r-- | src/components/user_settings/mfa_totp.js | 49 | ||||
| -rw-r--r-- | src/components/user_settings/mfa_totp.vue | 43 | ||||
| -rw-r--r-- | src/components/user_settings/user_settings.js | 295 | ||||
| -rw-r--r-- | src/components/user_settings/user_settings.vue | 581 |
10 files changed, 1119 insertions, 258 deletions
diff --git a/src/components/user_settings/confirm.js b/src/components/user_settings/confirm.js new file mode 100644 index 00000000..0f4ddfc9 --- /dev/null +++ b/src/components/user_settings/confirm.js @@ -0,0 +1,9 @@ +const Confirm = { + props: ['disabled'], + data: () => ({}), + methods: { + confirm () { this.$emit('confirm') }, + cancel () { this.$emit('cancel') } + } +} +export default Confirm diff --git a/src/components/user_settings/confirm.vue b/src/components/user_settings/confirm.vue new file mode 100644 index 00000000..69b3811b --- /dev/null +++ b/src/components/user_settings/confirm.vue @@ -0,0 +1,22 @@ +<template> + <div> + <slot /> + <button + class="btn btn-default" + :disabled="disabled" + @click="confirm" + > + {{ $t('general.confirm') }} + </button> + <button + class="btn btn-default" + :disabled="disabled" + @click="cancel" + > + {{ $t('general.cancel') }} + </button> + </div> +</template> + +<script src="./confirm.js"> +</script> diff --git a/src/components/user_settings/mfa.js b/src/components/user_settings/mfa.js new file mode 100644 index 00000000..3090138a --- /dev/null +++ b/src/components/user_settings/mfa.js @@ -0,0 +1,155 @@ +import RecoveryCodes from './mfa_backup_codes.vue' +import TOTP from './mfa_totp.vue' +import Confirm from './confirm.vue' +import VueQrcode from '@chenfengyuan/vue-qrcode' +import { mapState } from 'vuex' + +const Mfa = { + data: () => ({ + settings: { // current settings of MFA + available: false, + enabled: false, + totp: false + }, + setupState: { // setup mfa + state: '', // state of setup. '' -> 'getBackupCodes' -> 'setupOTP' -> 'complete' + setupOTPState: '' // state of setup otp. '' -> 'prepare' -> 'confirm' -> 'complete' + }, + backupCodes: { + getNewCodes: false, + inProgress: false, // progress of fetch codes + codes: [] + }, + otpSettings: { // pre-setup setting of OTP. secret key, qrcode url. + provisioning_uri: '', + key: '' + }, + currentPassword: null, + otpConfirmToken: null, + error: null, + readyInit: false + }), + components: { + 'recovery-codes': RecoveryCodes, + 'totp-item': TOTP, + 'qrcode': VueQrcode, + 'confirm': Confirm + }, + computed: { + canSetupOTP () { + return ( + (this.setupInProgress && this.backupCodesPrepared) || + this.settings.enabled + ) && !this.settings.totp && !this.setupOTPInProgress + }, + setupInProgress () { + return this.setupState.state !== '' && this.setupState.state !== 'complete' + }, + setupOTPInProgress () { + return this.setupState.state === 'setupOTP' && !this.completedOTP + }, + prepareOTP () { + return this.setupState.setupOTPState === 'prepare' + }, + confirmOTP () { + return this.setupState.setupOTPState === 'confirm' + }, + completedOTP () { + return this.setupState.setupOTPState === 'completed' + }, + backupCodesPrepared () { + return !this.backupCodes.inProgress && this.backupCodes.codes.length > 0 + }, + confirmNewBackupCodes () { + return this.backupCodes.getNewCodes + }, + ...mapState({ + backendInteractor: (state) => state.api.backendInteractor + }) + }, + + methods: { + activateOTP () { + if (!this.settings.enabled) { + this.setupState.state = 'getBackupcodes' + this.fetchBackupCodes() + } + }, + fetchBackupCodes () { + this.backupCodes.inProgress = true + this.backupCodes.codes = [] + + return this.backendInteractor.generateMfaBackupCodes() + .then((res) => { + this.backupCodes.codes = res.codes + this.backupCodes.inProgress = false + }) + }, + getBackupCodes () { // get a new backup codes + this.backupCodes.getNewCodes = true + }, + confirmBackupCodes () { // confirm getting new backup codes + this.fetchBackupCodes().then((res) => { + this.backupCodes.getNewCodes = false + }) + }, + cancelBackupCodes () { // cancel confirm form of new backup codes + this.backupCodes.getNewCodes = false + }, + + // Setup OTP + setupOTP () { // prepare setup OTP + this.setupState.state = 'setupOTP' + this.setupState.setupOTPState = 'prepare' + this.backendInteractor.mfaSetupOTP() + .then((res) => { + this.otpSettings = res + this.setupState.setupOTPState = 'confirm' + }) + }, + doConfirmOTP () { // handler confirm enable OTP + this.error = null + this.backendInteractor.mfaConfirmOTP({ + token: this.otpConfirmToken, + password: this.currentPassword + }) + .then((res) => { + if (res.error) { + this.error = res.error + return + } + this.completeSetup() + }) + }, + + completeSetup () { + this.setupState.setupOTPState = 'complete' + this.setupState.state = 'complete' + this.currentPassword = null + this.error = null + this.fetchSettings() + }, + cancelSetup () { // cancel setup + this.setupState.setupOTPState = '' + this.setupState.state = '' + this.currentPassword = null + this.error = null + }, + // end Setup OTP + + // fetch settings from server + async fetchSettings () { + let result = await this.backendInteractor.fetchSettingsMFA() + if (result.error) return + this.settings = result.settings + this.settings.available = true + return result + } + }, + mounted () { + this.fetchSettings().then(() => { + this.readyInit = true + }) + } +} +export default Mfa diff --git a/src/components/user_settings/mfa.vue b/src/components/user_settings/mfa.vue new file mode 100644 index 00000000..14ea10a1 --- /dev/null +++ b/src/components/user_settings/mfa.vue @@ -0,0 +1,173 @@ +<template> + <div + v-if="readyInit && settings.available" + class="setting-item mfa-settings" + > + <div class="mfa-heading"> + <h2>{{ $t('settings.mfa.title') }}</h2> + </div> + + <div> + <div + v-if="!setupInProgress" + class="setting-item" + > + <!-- Enabled methods --> + <h3>{{ $t('settings.mfa.authentication_methods') }}</h3> + <totp-item + :settings="settings" + @deactivate="fetchSettings" + @activate="activateOTP" + /> + <br> + + <div v-if="settings.enabled"> + <!-- backup codes block--> + <recovery-codes + v-if="!confirmNewBackupCodes" + :backup-codes="backupCodes" + /> + <button + v-if="!confirmNewBackupCodes" + class="btn btn-default" + @click="getBackupCodes" + > + {{ $t('settings.mfa.generate_new_recovery_codes') }} + </button> + + <div v-if="confirmNewBackupCodes"> + <confirm + :disabled="backupCodes.inProgress" + @confirm="confirmBackupCodes" + @cancel="cancelBackupCodes" + > + <p class="warning"> + {{ $t('settings.mfa.warning_of_generate_new_codes') }} + </p> + </confirm> + </div> + </div> + </div> + + <div v-if="setupInProgress"> + <!-- setup block--> + + <h3>{{ $t('settings.mfa.setup_otp') }}</h3> + + <recovery-codes + v-if="!setupOTPInProgress" + :backup-codes="backupCodes" + /> + + <button + v-if="canSetupOTP" + class="btn btn-default" + @click="cancelSetup" + > + {{ $t('general.cancel') }} + </button> + + <button + v-if="canSetupOTP" + class="btn btn-default" + @click="setupOTP" + > + {{ $t('settings.mfa.setup_otp') }} + </button> + + <template v-if="setupOTPInProgress"> + <i v-if="prepareOTP">{{ $t('settings.mfa.wait_pre_setup_otp') }}</i> + + <div v-if="confirmOTP"> + <div class="setup-otp"> + <div class="qr-code"> + <h4>{{ $t('settings.mfa.scan.title') }}</h4> + <p>{{ $t('settings.mfa.scan.desc') }}</p> + <qrcode + :value="otpSettings.provisioning_uri" + :options="{ width: 200 }" + /> + <p> + {{ $t('settings.mfa.scan.secret_code') }}: + {{ otpSettings.key }} + </p> + </div> + + <div class="verify"> + <h4>{{ $t('general.verify') }}</h4> + <p>{{ $t('settings.mfa.verify.desc') }}</p> + <input + v-model="otpConfirmToken" + type="text" + > + + <p>{{ $t('settings.enter_current_password_to_confirm') }}:</p> + <input + v-model="currentPassword" + type="password" + > + <div class="confirm-otp-actions"> + <button + class="btn btn-default" + @click="doConfirmOTP" + > + {{ $t('settings.mfa.confirm_and_enable') }} + </button> + <button + class="btn btn-default" + @click="cancelSetup" + > + {{ $t('general.cancel') }} + </button> + </div> + <div + v-if="error" + class="alert error" + > + {{ error }} + </div> + </div> + </div> + </div> + </template> + </div> + </div> + </div> +</template> + +<script src="./mfa.js"></script> +<style lang="scss"> +@import '../../_variables.scss'; +.warning { + color: $fallback--cOrange; + color: var(--cOrange, $fallback--cOrange); +} +.mfa-settings { + .mfa-heading, .method-item { + overflow: hidden; + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: baseline; + } + + .setup-otp { + display: flex; + justify-content: center; + flex-wrap: wrap; + .qr-code { + flex: 1; + padding-right: 10px; + } + .verify { flex: 1; } + .error { margin: 4px 0 0 0; } + .confirm-otp-actions { + button { + width: 15em; + margin-top: 5px; + } + + } + } +} +</style> diff --git a/src/components/user_settings/mfa_backup_codes.js b/src/components/user_settings/mfa_backup_codes.js new file mode 100644 index 00000000..f0a984ec --- /dev/null +++ b/src/components/user_settings/mfa_backup_codes.js @@ -0,0 +1,17 @@ +export default { + props: { + backupCodes: { + type: Object, + default: () => ({ + inProgress: false, + codes: [] + }) + } + }, + data: () => ({}), + computed: { + inProgress () { return this.backupCodes.inProgress }, + ready () { return this.backupCodes.codes.length > 0 }, + displayTitle () { return this.inProgress || this.ready } + } +} diff --git a/src/components/user_settings/mfa_backup_codes.vue b/src/components/user_settings/mfa_backup_codes.vue new file mode 100644 index 00000000..e6c8ede2 --- /dev/null +++ b/src/components/user_settings/mfa_backup_codes.vue @@ -0,0 +1,33 @@ +<template> + <div> + <h4 v-if="displayTitle"> + {{ $t('settings.mfa.recovery_codes') }} + </h4> + <i v-if="inProgress">{{ $t('settings.mfa.waiting_a_recovery_codes') }}</i> + <template v-if="ready"> + <p class="alert warning"> + {{ $t('settings.mfa.recovery_codes_warning') }} + </p> + <ul class="backup-codes"> + <li + v-for="code in backupCodes.codes" + :key="code" + > + {{ code }} + </li> + </ul> + </template> + </div> +</template> +<script src="./mfa_backup_codes.js"></script> +<style lang="scss"> +@import '../../_variables.scss'; + +.warning { + color: $fallback--cOrange; + color: var(--cOrange, $fallback--cOrange); +} +.backup-codes { + font-family: var(--postCodeFont, monospace); +} +</style> diff --git a/src/components/user_settings/mfa_totp.js b/src/components/user_settings/mfa_totp.js new file mode 100644 index 00000000..8408d8e9 --- /dev/null +++ b/src/components/user_settings/mfa_totp.js @@ -0,0 +1,49 @@ +import Confirm from './confirm.vue' +import { mapState } from 'vuex' + +export default { + props: ['settings'], + data: () => ({ + error: false, + currentPassword: '', + deactivate: false, + inProgress: false // progress peform request to disable otp method + }), + components: { + 'confirm': Confirm + }, + computed: { + isActivated () { + return this.settings.totp + }, + ...mapState({ + backendInteractor: (state) => state.api.backendInteractor + }) + }, + methods: { + doActivate () { + this.$emit('activate') + }, + cancelDeactivate () { this.deactivate = false }, + doDeactivate () { + this.error = null + this.deactivate = true + }, + confirmDeactivate () { // confirm deactivate TOTP method + this.error = null + this.inProgress = true + this.backendInteractor.mfaDisableOTP({ + password: this.currentPassword + }) + .then((res) => { + this.inProgress = false + if (res.error) { + this.error = res.error + return + } + this.deactivate = false + this.$emit('deactivate') + }) + } + } +} diff --git a/src/components/user_settings/mfa_totp.vue b/src/components/user_settings/mfa_totp.vue new file mode 100644 index 00000000..c6f2cc7b --- /dev/null +++ b/src/components/user_settings/mfa_totp.vue @@ -0,0 +1,43 @@ +<template> + <div> + <div class="method-item"> + <strong>{{ $t('settings.mfa.otp') }}</strong> + <button + v-if="!isActivated" + class="btn btn-default" + @click="doActivate" + > + {{ $t('general.enable') }} + </button> + + <button + v-if="isActivated" + class="btn btn-default" + :disabled="deactivate" + @click="doDeactivate" + > + {{ $t('general.disable') }} + </button> + </div> + + <confirm + v-if="deactivate" + :disabled="inProgress" + @confirm="confirmDeactivate" + @cancel="cancelDeactivate" + > + {{ $t('settings.enter_current_password_to_confirm') }}: + <input + v-model="currentPassword" + type="password" + > + </confirm> + <div + v-if="error" + class="alert error" + > + {{ error }} + </div> + </div> +</template> +<script src="./mfa_totp.js"></script> diff --git a/src/components/user_settings/user_settings.js b/src/components/user_settings/user_settings.js index b6a0479d..b5a7f0df 100644 --- a/src/components/user_settings/user_settings.js +++ b/src/components/user_settings/user_settings.js @@ -1,6 +1,7 @@ -import { compose } from 'vue-compose' import unescape from 'lodash/unescape' import get from 'lodash/get' +import map from 'lodash/map' +import reject from 'lodash/reject' import TabSwitcher from '../tab_switcher/tab_switcher.js' import ImageCropper from '../image_cropper/image_cropper.vue' import StyleSwitcher from '../style_switcher/style_switcher.vue' @@ -8,27 +9,27 @@ import ScopeSelector from '../scope_selector/scope_selector.vue' import fileSizeFormatService from '../../services/file_size_format/file_size_format.js' import BlockCard from '../block_card/block_card.vue' import MuteCard from '../mute_card/mute_card.vue' +import SelectableList from '../selectable_list/selectable_list.vue' +import ProgressButton from '../progress_button/progress_button.vue' import EmojiInput from '../emoji-input/emoji-input.vue' +import suggestor from '../emoji-input/suggestor.js' +import Autosuggest from '../autosuggest/autosuggest.vue' +import Importer from '../importer/importer.vue' +import Exporter from '../exporter/exporter.vue' import withSubscription from '../../hocs/with_subscription/with_subscription' -import withList from '../../hocs/with_list/with_list' +import Mfa from './mfa.vue' -const BlockList = compose( - withSubscription({ - fetch: (props, $store) => $store.dispatch('fetchBlocks'), - select: (props, $store) => get($store.state.users.currentUser, 'blockIds', []), - childPropName: 'entries' - }), - withList({ getEntryProps: userId => ({ userId }) }) -)(BlockCard) +const BlockList = withSubscription({ + fetch: (props, $store) => $store.dispatch('fetchBlocks'), + select: (props, $store) => get($store.state.users.currentUser, 'blockIds', []), + childPropName: 'items' +})(SelectableList) -const MuteList = compose( - withSubscription({ - fetch: (props, $store) => $store.dispatch('fetchMutes'), - select: (props, $store) => get($store.state.users.currentUser, 'muteIds', []), - childPropName: 'entries' - }), - withList({ getEntryProps: userId => ({ userId }) }) -)(MuteCard) +const MuteList = withSubscription({ + fetch: (props, $store) => $store.dispatch('fetchMutes'), + select: (props, $store) => get($store.state.users.currentUser, 'muteIds', []), + childPropName: 'items' +})(SelectableList) const UserSettings = { data () { @@ -42,15 +43,12 @@ const UserSettings = { hideFollowers: this.$store.state.users.currentUser.hide_followers, showRole: this.$store.state.users.currentUser.show_role, role: this.$store.state.users.currentUser.role, - followList: null, - followImportError: false, - followsImported: false, - enableFollowsExport: true, pickAvatarBtnVisible: true, bannerUploading: false, backgroundUploading: false, - followListUploading: false, + banner: null, bannerPreview: null, + background: null, backgroundPreview: null, bannerUploadError: null, backgroundUploadError: null, @@ -60,7 +58,8 @@ const UserSettings = { changePasswordInputs: [ '', '', '' ], changedPassword: false, changePasswordError: false, - activeTab: 'profile' + activeTab: 'profile', + notificationSettings: this.$store.state.users.currentUser.notification_settings } }, created () { @@ -73,12 +72,35 @@ const UserSettings = { ImageCropper, BlockList, MuteList, - EmojiInput + EmojiInput, + Autosuggest, + BlockCard, + MuteCard, + ProgressButton, + Importer, + Exporter, + Mfa }, computed: { user () { return this.$store.state.users.currentUser }, + emojiUserSuggestor () { + return suggestor({ + emoji: [ + ...this.$store.state.instance.emoji, + ...this.$store.state.instance.customEmoji + ], + users: this.$store.state.users.users, + updateUsersList: (input) => this.$store.dispatch('searchUsers', input) + }) + }, + emojiSuggestor () { + return suggestor({ emoji: [ + ...this.$store.state.instance.emoji, + ...this.$store.state.instance.customEmoji + ] }) + }, pleromaBackend () { return this.$store.state.instance.pleromaBackend }, @@ -108,38 +130,28 @@ const UserSettings = { }, methods: { updateProfile () { - const name = this.newName - const description = this.newBio - const locked = this.newLocked - // Backend notation. - /* eslint-disable camelcase */ - const default_scope = this.newDefaultScope - const no_rich_text = this.newNoRichText - const hide_follows = this.hideFollows - const hide_followers = this.hideFollowers - const show_role = this.showRole - - /* eslint-enable camelcase */ this.$store.state.api.backendInteractor .updateProfile({ params: { - name, - description, - locked, + note: this.newBio, + locked: this.newLocked, // Backend notation. /* eslint-disable camelcase */ - default_scope, - no_rich_text, - hide_follows, - hide_followers, - show_role + display_name: this.newName, + default_scope: this.newDefaultScope, + no_rich_text: this.newNoRichText, + hide_follows: this.hideFollows, + hide_followers: this.hideFollowers, + show_role: this.showRole /* eslint-enable camelcase */ - }}).then((user) => { - if (!user.error) { - this.$store.commit('addNewUsers', [user]) - this.$store.commit('setCurrentUser', user) - } - }) + } }).then((user) => { + this.$store.commit('addNewUsers', [user]) + this.$store.commit('setCurrentUser', user) + }) + }, + updateNotificationSettings () { + this.$store.state.api.backendInteractor + .updateNotificationSettings({ settings: this.notificationSettings }) }, changeVis (visibility) { this.newDefaultScope = visibility @@ -150,31 +162,37 @@ const UserSettings = { if (file.size > this.$store.state.instance[slot + 'limit']) { const filesize = fileSizeFormatService.fileSizeFormat(file.size) const allowedsize = fileSizeFormatService.fileSizeFormat(this.$store.state.instance[slot + 'limit']) - this[slot + 'UploadError'] = this.$t('upload.error.base') + ' ' + this.$t('upload.error.file_too_big', {filesize: filesize.num, filesizeunit: filesize.unit, allowedsize: allowedsize.num, allowedsizeunit: allowedsize.unit}) + this[slot + 'UploadError'] = this.$t('upload.error.base') + ' ' + this.$t('upload.error.file_too_big', { filesize: filesize.num, filesizeunit: filesize.unit, allowedsize: allowedsize.num, allowedsizeunit: allowedsize.unit }) return } // eslint-disable-next-line no-undef const reader = new FileReader() - reader.onload = ({target}) => { + reader.onload = ({ target }) => { const img = target.result this[slot + 'Preview'] = img + this[slot] = file } reader.readAsDataURL(file) }, submitAvatar (cropper, file) { - let img - if (cropper) { - img = cropper.getCroppedCanvas().toDataURL(file.type) - } else { - img = file - } + const that = this + return new Promise((resolve, reject) => { + function updateAvatar (avatar) { + that.$store.state.api.backendInteractor.updateAvatar({ avatar }) + .then((user) => { + that.$store.commit('addNewUsers', [user]) + that.$store.commit('setCurrentUser', user) + resolve() + }) + .catch((err) => { + reject(new Error(that.$t('upload.error.base') + ' ' + err.message)) + }) + } - return this.$store.state.api.backendInteractor.updateAvatar({ params: { img } }).then((user) => { - if (!user.error) { - this.$store.commit('addNewUsers', [user]) - this.$store.commit('setCurrentUser', user) + if (cropper) { + cropper.getCroppedCanvas().toBlob(updateAvatar, file.type) } else { - throw new Error(this.$t('upload.error.base') + user.error) + updateAvatar(file) } }) }, @@ -184,49 +202,26 @@ const UserSettings = { submitBanner () { if (!this.bannerPreview) { return } - let banner = this.bannerPreview - // eslint-disable-next-line no-undef - let imginfo = new Image() - /* eslint-disable camelcase */ - let offset_top, offset_left, width, height - imginfo.src = banner - width = imginfo.width - height = imginfo.height - offset_top = 0 - offset_left = 0 this.bannerUploading = true - this.$store.state.api.backendInteractor.updateBanner({params: {banner, offset_top, offset_left, width, height}}).then((data) => { - if (!data.error) { - let clone = JSON.parse(JSON.stringify(this.$store.state.users.currentUser)) - clone.cover_photo = data.url - this.$store.commit('addNewUsers', [clone]) - this.$store.commit('setCurrentUser', clone) + this.$store.state.api.backendInteractor.updateBanner({ banner: this.banner }) + .then((user) => { + this.$store.commit('addNewUsers', [user]) + this.$store.commit('setCurrentUser', user) this.bannerPreview = null - } else { - this.bannerUploadError = this.$t('upload.error.base') + data.error - } - this.bannerUploading = false - }) - /* eslint-enable camelcase */ + }) + .catch((err) => { + this.bannerUploadError = this.$t('upload.error.base') + ' ' + err.message + }) + .then(() => { this.bannerUploading = false }) }, submitBg () { if (!this.backgroundPreview) { return } - let img = this.backgroundPreview - // eslint-disable-next-line no-undef - let imginfo = new Image() - let cropX, cropY, cropW, cropH - imginfo.src = img - cropX = 0 - cropY = 0 - cropW = imginfo.width - cropH = imginfo.width + let background = this.background this.backgroundUploading = true - this.$store.state.api.backendInteractor.updateBg({params: {img, cropX, cropY, cropW, cropH}}).then((data) => { + this.$store.state.api.backendInteractor.updateBg({ background }).then((data) => { if (!data.error) { - let clone = JSON.parse(JSON.stringify(this.$store.state.users.currentUser)) - clone.background_image = data.url - this.$store.commit('addNewUsers', [clone]) - this.$store.commit('setCurrentUser', clone) + this.$store.commit('addNewUsers', [data]) + this.$store.commit('setCurrentUser', data) this.backgroundPreview = null } else { this.backgroundUploadError = this.$t('upload.error.base') + data.error @@ -234,72 +229,51 @@ const UserSettings = { this.backgroundUploading = false }) }, - importFollows () { - this.followListUploading = true - const followList = this.followList - this.$store.state.api.backendInteractor.followImport({params: followList}) + importFollows (file) { + return this.$store.state.api.backendInteractor.importFollows(file) .then((status) => { - if (status) { - this.followsImported = true - } else { - this.followImportError = true + if (!status) { + throw new Error('failed') } - this.followListUploading = false }) }, - /* This function takes an Array of Users - * and outputs a file with all the addresses for the user to download - */ - exportPeople (users, filename) { - // Get all the friends addresses - var UserAddresses = users.map(function (user) { + importBlocks (file) { + return this.$store.state.api.backendInteractor.importBlocks(file) + .then((status) => { + if (!status) { + throw new Error('failed') + } + }) + }, + generateExportableUsersContent (users) { + // Get addresses + return users.map((user) => { // check is it's a local user if (user && user.is_local) { // append the instance address // eslint-disable-next-line no-undef - user.screen_name += '@' + location.hostname + return user.screen_name + '@' + location.hostname } return user.screen_name }).join('\n') - // Make the user download the file - var fileToDownload = document.createElement('a') - fileToDownload.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(UserAddresses)) - fileToDownload.setAttribute('download', filename) - fileToDownload.style.display = 'none' - document.body.appendChild(fileToDownload) - fileToDownload.click() - document.body.removeChild(fileToDownload) - }, - exportFollows () { - this.enableFollowsExport = false - this.$store.state.api.backendInteractor - .exportFriends({ - id: this.$store.state.users.currentUser.id - }) - .then((friendList) => { - this.exportPeople(friendList, 'friends.csv') - setTimeout(() => { this.enableFollowsExport = true }, 2000) - }) }, - followListChange () { - // eslint-disable-next-line no-undef - let formData = new FormData() - formData.append('list', this.$refs.followlist.files[0]) - this.followList = formData + getFollowsContent () { + return this.$store.state.api.backendInteractor.exportFriends({ id: this.$store.state.users.currentUser.id }) + .then(this.generateExportableUsersContent) }, - dismissImported () { - this.followsImported = false - this.followImportError = false + getBlocksContent () { + return this.$store.state.api.backendInteractor.fetchBlocks() + .then(this.generateExportableUsersContent) }, confirmDelete () { this.deletingAccount = true }, deleteAccount () { - this.$store.state.api.backendInteractor.deleteAccount({password: this.deleteAccountConfirmPasswordInput}) + this.$store.state.api.backendInteractor.deleteAccount({ password: this.deleteAccountConfirmPasswordInput }) .then((res) => { if (res.status === 'success') { this.$store.dispatch('logout') - this.$router.push({name: 'root'}) + this.$router.push({ name: 'root' }) } else { this.deleteAccountError = res.error } @@ -334,6 +308,37 @@ const UserSettings = { if (window.confirm(`${this.$i18n.t('settings.revoke_token')}?`)) { this.$store.dispatch('revokeToken', id) } + }, + filterUnblockedUsers (userIds) { + return reject(userIds, (userId) => { + const user = this.$store.getters.findUser(userId) + return !user || user.statusnet_blocking || user.id === this.$store.state.users.currentUser.id + }) + }, + filterUnMutedUsers (userIds) { + return reject(userIds, (userId) => { + const user = this.$store.getters.findUser(userId) + return !user || user.muted || user.id === this.$store.state.users.currentUser.id + }) + }, + queryUserIds (query) { + return this.$store.dispatch('searchUsers', query) + .then((users) => map(users, 'id')) + }, + blockUsers (ids) { + return this.$store.dispatch('blockUsers', ids) + }, + unblockUsers (ids) { + return this.$store.dispatch('unblockUsers', ids) + }, + muteUsers (ids) { + return this.$store.dispatch('muteUsers', ids) + }, + unmuteUsers (ids) { + return this.$store.dispatch('unmuteUsers', ids) + }, + identity (value) { + return value } } } diff --git a/src/components/user_settings/user_settings.vue b/src/components/user_settings/user_settings.vue index c08698dc..34ea8569 100644 --- a/src/components/user_settings/user_settings.vue +++ b/src/components/user_settings/user_settings.vue @@ -2,15 +2,23 @@ <div class="settings panel panel-default"> <div class="panel-heading"> <div class="title"> - {{$t('settings.user_settings')}} + {{ $t('settings.user_settings') }} </div> <transition name="fade"> <template v-if="currentSaveStateNotice"> - <div @click.prevent class="alert error" v-if="currentSaveStateNotice.error"> + <div + v-if="currentSaveStateNotice.error" + class="alert error" + @click.prevent + > {{ $t('settings.saving_err') }} </div> - <div @click.prevent class="alert transparent" v-if="!currentSaveStateNotice.error"> + <div + v-if="!currentSaveStateNotice.error" + class="alert transparent" + @click.prevent + > {{ $t('settings.saving_ok') }} </div> </template> @@ -19,191 +27,520 @@ <div class="panel-body profile-edit"> <tab-switcher> <div :label="$t('settings.profile_tab')"> - <div class="setting-item" > - <h2>{{$t('settings.name_bio')}}</h2> - <p>{{$t('settings.name')}}</p> - <EmojiInput - type="text" + <div class="setting-item"> + <h2>{{ $t('settings.name_bio') }}</h2> + <p>{{ $t('settings.name') }}</p> + <EmojiInput v-model="newName" - id="username" - classname="name-changer" - /> - <p>{{$t('settings.bio')}}</p> + :suggest="emojiSuggestor" + > + <input + id="username" + v-model="newName" + classname="name-changer" + > + </EmojiInput> + <p>{{ $t('settings.bio') }}</p> <EmojiInput - type="textarea" v-model="newBio" - classname="bio" - /> + :suggest="emojiUserSuggestor" + > + <textarea + v-model="newBio" + classname="bio" + /> + </EmojiInput> <p> - <input type="checkbox" v-model="newLocked" id="account-locked"> - <label for="account-locked">{{$t('settings.lock_account_description')}}</label> + <input + id="account-locked" + v-model="newLocked" + type="checkbox" + > + <label for="account-locked">{{ $t('settings.lock_account_description') }}</label> </p> <div> - <label for="default-vis">{{$t('settings.default_vis')}}</label> - <div id="default-vis" class="visibility-tray"> + <label for="default-vis">{{ $t('settings.default_vis') }}</label> + <div + id="default-vis" + class="visibility-tray" + > <scope-selector - :showAll="true" - :userDefault="newDefaultScope" - :onScopeChange="changeVis"/> + :show-all="true" + :user-default="newDefaultScope" + :initial-scope="newDefaultScope" + :on-scope-change="changeVis" + /> </div> </div> <p> - <input type="checkbox" v-model="newNoRichText" id="account-no-rich-text"> - <label for="account-no-rich-text">{{$t('settings.no_rich_text_description')}}</label> + <input + id="account-no-rich-text" + v-model="newNoRichText" + type="checkbox" + > + <label for="account-no-rich-text">{{ $t('settings.no_rich_text_description') }}</label> </p> <p> - <input type="checkbox" v-model="hideFollows" id="account-hide-follows"> - <label for="account-hide-follows">{{$t('settings.hide_follows_description')}}</label> + <input + id="account-hide-follows" + v-model="hideFollows" + type="checkbox" + > + <label for="account-hide-follows">{{ $t('settings.hide_follows_description') }}</label> </p> <p> - <input type="checkbox" v-model="hideFollowers" id="account-hide-followers"> - <label for="account-hide-followers">{{$t('settings.hide_followers_description')}}</label> + <input + id="account-hide-followers" + v-model="hideFollowers" + type="checkbox" + > + <label for="account-hide-followers">{{ $t('settings.hide_followers_description') }}</label> </p> <p> - <input type="checkbox" v-model="showRole" id="account-show-role"> - <label for="account-show-role" v-if="role === 'admin'">{{$t('settings.show_admin_badge')}}</label> - <label for="account-show-role" v-if="role === 'moderator'">{{$t('settings.show_moderator_badge')}}</label> + <input + id="account-show-role" + v-model="showRole" + type="checkbox" + > + <label + v-if="role === 'admin'" + for="account-show-role" + >{{ $t('settings.show_admin_badge') }}</label> + <label + v-if="role === 'moderator'" + for="account-show-role" + >{{ $t('settings.show_moderator_badge') }}</label> </p> - <button :disabled='newName && newName.length === 0' class="btn btn-default" @click="updateProfile">{{$t('general.submit')}}</button> + <button + :disabled="newName && newName.length === 0" + class="btn btn-default" + @click="updateProfile" + > + {{ $t('general.submit') }} + </button> </div> <div class="setting-item"> - <h2>{{$t('settings.avatar')}}</h2> - <p class="visibility-notice">{{$t('settings.avatar_size_instruction')}}</p> - <p>{{$t('settings.current_avatar')}}</p> - <img :src="user.profile_image_url_original" class="current-avatar" /> - <p>{{$t('settings.set_new_avatar')}}</p> - <button class="btn" type="button" id="pick-avatar" v-show="pickAvatarBtnVisible">{{$t('settings.upload_a_photo')}}</button> - <image-cropper trigger="#pick-avatar" :submitHandler="submitAvatar" @open="pickAvatarBtnVisible=false" @close="pickAvatarBtnVisible=true" /> + <h2>{{ $t('settings.avatar') }}</h2> + <p class="visibility-notice"> + {{ $t('settings.avatar_size_instruction') }} + </p> + <p>{{ $t('settings.current_avatar') }}</p> + <img + :src="user.profile_image_url_original" + class="current-avatar" + > + <p>{{ $t('settings.set_new_avatar') }}</p> + <button + v-show="pickAvatarBtnVisible" + id="pick-avatar" + class="btn" + type="button" + > + {{ $t('settings.upload_a_photo') }} + </button> + <image-cropper + trigger="#pick-avatar" + :submit-handler="submitAvatar" + @open="pickAvatarBtnVisible=false" + @close="pickAvatarBtnVisible=true" + /> </div> <div class="setting-item"> - <h2>{{$t('settings.profile_banner')}}</h2> - <p>{{$t('settings.current_profile_banner')}}</p> - <img :src="user.cover_photo" class="banner" /> - <p>{{$t('settings.set_new_profile_banner')}}</p> - <img class="banner" v-bind:src="bannerPreview" v-if="bannerPreview" /> + <h2>{{ $t('settings.profile_banner') }}</h2> + <p>{{ $t('settings.current_profile_banner') }}</p> + <img + :src="user.cover_photo" + class="banner" + > + <p>{{ $t('settings.set_new_profile_banner') }}</p> + <img + v-if="bannerPreview" + class="banner" + :src="bannerPreview" + > <div> - <input type="file" @change="uploadFile('banner', $event)" /> + <input + type="file" + @change="uploadFile('banner', $event)" + > </div> - <i class=" icon-spin4 animate-spin uploading" v-if="bannerUploading"></i> - <button class="btn btn-default" v-else-if="bannerPreview" @click="submitBanner">{{$t('general.submit')}}</button> - <div class='alert error' v-if="bannerUploadError"> + <i + v-if="bannerUploading" + class=" icon-spin4 animate-spin uploading" + /> + <button + v-else-if="bannerPreview" + class="btn btn-default" + @click="submitBanner" + > + {{ $t('general.submit') }} + </button> + <div + v-if="bannerUploadError" + class="alert error" + > Error: {{ bannerUploadError }} - <i class="button-icon icon-cancel" @click="clearUploadError('banner')"></i> + <i + class="button-icon icon-cancel" + @click="clearUploadError('banner')" + /> </div> </div> <div class="setting-item"> - <h2>{{$t('settings.profile_background')}}</h2> - <p>{{$t('settings.set_new_profile_background')}}</p> - <img class="bg" v-bind:src="backgroundPreview" v-if="backgroundPreview" /> + <h2>{{ $t('settings.profile_background') }}</h2> + <p>{{ $t('settings.set_new_profile_background') }}</p> + <img + v-if="backgroundPreview" + class="bg" + :src="backgroundPreview" + > <div> - <input type="file" @change="uploadFile('background', $event)" /> + <input + type="file" + @change="uploadFile('background', $event)" + > </div> - <i class=" icon-spin4 animate-spin uploading" v-if="backgroundUploading"></i> - <button class="btn btn-default" v-else-if="backgroundPreview" @click="submitBg">{{$t('general.submit')}}</button> - <div class='alert error' v-if="backgroundUploadError"> + <i + v-if="backgroundUploading" + class=" icon-spin4 animate-spin uploading" + /> + <button + v-else-if="backgroundPreview" + class="btn btn-default" + @click="submitBg" + > + {{ $t('general.submit') }} + </button> + <div + v-if="backgroundUploadError" + class="alert error" + > Error: {{ backgroundUploadError }} - <i class="button-icon icon-cancel" @click="clearUploadError('background')"></i> + <i + class="button-icon icon-cancel" + @click="clearUploadError('background')" + /> </div> </div> </div> <div :label="$t('settings.security_tab')"> <div class="setting-item"> - <h2>{{$t('settings.change_password')}}</h2> + <h2>{{ $t('settings.change_password') }}</h2> <div> - <p>{{$t('settings.current_password')}}</p> - <input type="password" v-model="changePasswordInputs[0]"> + <p>{{ $t('settings.current_password') }}</p> + <input + v-model="changePasswordInputs[0]" + type="password" + > </div> <div> - <p>{{$t('settings.new_password')}}</p> - <input type="password" v-model="changePasswordInputs[1]"> + <p>{{ $t('settings.new_password') }}</p> + <input + v-model="changePasswordInputs[1]" + type="password" + > </div> <div> - <p>{{$t('settings.confirm_new_password')}}</p> - <input type="password" v-model="changePasswordInputs[2]"> + <p>{{ $t('settings.confirm_new_password') }}</p> + <input + v-model="changePasswordInputs[2]" + type="password" + > </div> - <button class="btn btn-default" @click="changePassword">{{$t('general.submit')}}</button> - <p v-if="changedPassword">{{$t('settings.changed_password')}}</p> - <p v-else-if="changePasswordError !== false">{{$t('settings.change_password_error')}}</p> - <p v-if="changePasswordError">{{changePasswordError}}</p> + <button + class="btn btn-default" + @click="changePassword" + > + {{ $t('general.submit') }} + </button> + <p v-if="changedPassword"> + {{ $t('settings.changed_password') }} + </p> + <p v-else-if="changePasswordError !== false"> + {{ $t('settings.change_password_error') }} + </p> + <p v-if="changePasswordError"> + {{ changePasswordError }} + </p> </div> <div class="setting-item"> - <h2>{{$t('settings.oauth_tokens')}}</h2> + <h2>{{ $t('settings.oauth_tokens') }}</h2> <table class="oauth-tokens"> <thead> <tr> - <th>{{$t('settings.app_name')}}</th> - <th>{{$t('settings.valid_until')}}</th> - <th></th> + <th>{{ $t('settings.app_name') }}</th> + <th>{{ $t('settings.valid_until') }}</th> + <th /> </tr> </thead> <tbody> - <tr v-for="oauthToken in oauthTokens" :key="oauthToken.id"> - <td>{{oauthToken.appName}}</td> - <td>{{oauthToken.validUntil}}</td> + <tr + v-for="oauthToken in oauthTokens" + :key="oauthToken.id" + > + <td>{{ oauthToken.appName }}</td> + <td>{{ oauthToken.validUntil }}</td> <td class="actions"> - <button class="btn btn-default" @click="revokeToken(oauthToken.id)"> - {{$t('settings.revoke_token')}} + <button + class="btn btn-default" + @click="revokeToken(oauthToken.id)" + > + {{ $t('settings.revoke_token') }} </button> </td> </tr> </tbody> </table> </div> - + <mfa /> <div class="setting-item"> - <h2>{{$t('settings.delete_account')}}</h2> - <p v-if="!deletingAccount">{{$t('settings.delete_account_description')}}</p> + <h2>{{ $t('settings.delete_account') }}</h2> + <p v-if="!deletingAccount"> + {{ $t('settings.delete_account_description') }} + </p> <div v-if="deletingAccount"> - <p>{{$t('settings.delete_account_instructions')}}</p> - <p>{{$t('login.password')}}</p> - <input type="password" v-model="deleteAccountConfirmPasswordInput"> - <button class="btn btn-default" @click="deleteAccount">{{$t('settings.delete_account')}}</button> + <p>{{ $t('settings.delete_account_instructions') }}</p> + <p>{{ $t('login.password') }}</p> + <input + v-model="deleteAccountConfirmPasswordInput" + type="password" + > + <button + class="btn btn-default" + @click="deleteAccount" + > + {{ $t('settings.delete_account') }} + </button> </div> - <p v-if="deleteAccountError !== false">{{$t('settings.delete_account_error')}}</p> - <p v-if="deleteAccountError">{{deleteAccountError}}</p> - <button class="btn btn-default" v-if="!deletingAccount" @click="confirmDelete">{{$t('general.submit')}}</button> + <p v-if="deleteAccountError !== false"> + {{ $t('settings.delete_account_error') }} + </p> + <p v-if="deleteAccountError"> + {{ deleteAccountError }} + </p> + <button + v-if="!deletingAccount" + class="btn btn-default" + @click="confirmDelete" + > + {{ $t('general.submit') }} + </button> </div> </div> - <div :label="$t('settings.data_import_export_tab')" v-if="pleromaBackend"> + <div + v-if="pleromaBackend" + :label="$t('settings.notifications')" + > <div class="setting-item"> - <h2>{{$t('settings.follow_import')}}</h2> - <p>{{$t('settings.import_followers_from_a_csv_file')}}</p> - <form> - <input type="file" ref="followlist" v-on:change="followListChange" /> - </form> - <i class=" icon-spin4 animate-spin uploading" v-if="followListUploading"></i> - <button class="btn btn-default" v-else @click="importFollows">{{$t('general.submit')}}</button> - <div v-if="followsImported"> - <i class="icon-cross" @click="dismissImported"></i> - <p>{{$t('settings.follows_imported')}}</p> - </div> - <div v-else-if="followImportError"> - <i class="icon-cross" @click="dismissImported"></i> - <p>{{$t('settings.follow_import_error')}}</p> + <div class="select-multiple"> + <span class="label">{{ $t('settings.notification_setting') }}</span> + <ul class="option-list"> + <li> + <input + id="notification-setting-follows" + v-model="notificationSettings.follows" + type="checkbox" + > + <label for="notification-setting-follows"> + {{ $t('settings.notification_setting_follows') }} + </label> + </li> + <li> + <input + id="notification-setting-followers" + v-model="notificationSettings.followers" + type="checkbox" + > + <label for="notification-setting-followers"> + {{ $t('settings.notification_setting_followers') }} + </label> + </li> + <li> + <input + id="notification-setting-non-follows" + v-model="notificationSettings.non_follows" + type="checkbox" + > + <label for="notification-setting-non-follows"> + {{ $t('settings.notification_setting_non_follows') }} + </label> + </li> + <li> + <input + id="notification-setting-non-followers" + v-model="notificationSettings.non_followers" + type="checkbox" + > + <label for="notification-setting-non-followers"> + {{ $t('settings.notification_setting_non_followers') }} + </label> + </li> + </ul> </div> + <p>{{ $t('settings.notification_mutes') }}</p> + <p>{{ $t('settings.notification_blocks') }}</p> + <button + class="btn btn-default" + @click="updateNotificationSettings" + > + {{ $t('general.submit') }} + </button> + </div> + </div> + + <div + v-if="pleromaBackend" + :label="$t('settings.data_import_export_tab')" + > + <div class="setting-item"> + <h2>{{ $t('settings.follow_import') }}</h2> + <p>{{ $t('settings.import_followers_from_a_csv_file') }}</p> + <Importer + :submit-handler="importFollows" + :success-message="$t('settings.follows_imported')" + :error-message="$t('settings.follow_import_error')" + /> </div> - <div class="setting-item" v-if="enableFollowsExport"> - <h2>{{$t('settings.follow_export')}}</h2> - <button class="btn btn-default" @click="exportFollows">{{$t('settings.follow_export_button')}}</button> + <div class="setting-item"> + <h2>{{ $t('settings.follow_export') }}</h2> + <Exporter + :get-content="getFollowsContent" + filename="friends.csv" + :export-button-label="$t('settings.follow_export_button')" + /> + </div> + <div class="setting-item"> + <h2>{{ $t('settings.block_import') }}</h2> + <p>{{ $t('settings.import_blocks_from_a_csv_file') }}</p> + <Importer + :submit-handler="importBlocks" + :success-message="$t('settings.blocks_imported')" + :error-message="$t('settings.block_import_error')" + /> </div> - <div class="setting-item" v-else> - <h2>{{$t('settings.follow_export_processing')}}</h2> + <div class="setting-item"> + <h2>{{ $t('settings.block_export') }}</h2> + <Exporter + :get-content="getBlocksContent" + filename="blocks.csv" + :export-button-label="$t('settings.block_export_button')" + /> </div> </div> <div :label="$t('settings.blocks_tab')"> - <block-list :refresh="true"> - <template slot="empty">{{$t('settings.no_blocks')}}</template> - </block-list> + <div class="profile-edit-usersearch-wrapper"> + <Autosuggest + :filter="filterUnblockedUsers" + :query="queryUserIds" + :placeholder="$t('settings.search_user_to_block')" + > + <BlockCard + slot-scope="row" + :user-id="row.item" + /> + </Autosuggest> + </div> + <BlockList + :refresh="true" + :get-key="identity" + > + <template + slot="header" + slot-scope="{selected}" + > + <div class="profile-edit-bulk-actions"> + <ProgressButton + v-if="selected.length > 0" + class="btn btn-default" + :click="() => blockUsers(selected)" + > + {{ $t('user_card.block') }} + <template slot="progress"> + {{ $t('user_card.block_progress') }} + </template> + </ProgressButton> + <ProgressButton + v-if="selected.length > 0" + class="btn btn-default" + :click="() => unblockUsers(selected)" + > + {{ $t('user_card.unblock') }} + <template slot="progress"> + {{ $t('user_card.unblock_progress') }} + </template> + </ProgressButton> + </div> + </template> + <template + slot="item" + slot-scope="{item}" + > + <BlockCard :user-id="item" /> + </template> + <template slot="empty"> + {{ $t('settings.no_blocks') }} + </template> + </BlockList> </div> <div :label="$t('settings.mutes_tab')"> - <mute-list :refresh="true"> - <template slot="empty">{{$t('settings.no_mutes')}}</template> - </mute-list> + <div class="profile-edit-usersearch-wrapper"> + <Autosuggest + :filter="filterUnMutedUsers" + :query="queryUserIds" + :placeholder="$t('settings.search_user_to_mute')" + > + <MuteCard + slot-scope="row" + :user-id="row.item" + /> + </Autosuggest> + </div> + <MuteList + :refresh="true" + :get-key="identity" + > + <template + slot="header" + slot-scope="{selected}" + > + <div class="profile-edit-bulk-actions"> + <ProgressButton + v-if="selected.length > 0" + class="btn btn-default" + :click="() => muteUsers(selected)" + > + {{ $t('user_card.mute') }} + <template slot="progress"> + {{ $t('user_card.mute_progress') }} + </template> + </ProgressButton> + <ProgressButton + v-if="selected.length > 0" + class="btn btn-default" + :click="() => unmuteUsers(selected)" + > + {{ $t('user_card.unmute') }} + <template slot="progress"> + {{ $t('user_card.unmute_progress') }} + </template> + </ProgressButton> + </div> + </template> + <template + slot="item" + slot-scope="{item}" + > + <MuteCard :user-id="item" /> + </template> + <template slot="empty"> + {{ $t('settings.no_mutes') }} + </template> + </MuteList> </div> </tab-switcher> </div> @@ -221,6 +558,10 @@ margin: 0; } + .visibility-tray { + padding-top: 5px; + } + input[type=file] { padding: 5px; height: auto; @@ -262,5 +603,19 @@ text-align: right; } } + + &-usersearch-wrapper { + padding: 1em; + } + + &-bulk-actions { + text-align: right; + padding: 0 1em; + min-height: 28px; + + button { + width: 10em; + } + } } </style> |
