diff options
Diffstat (limited to 'src')
47 files changed, 1512 insertions, 227 deletions
diff --git a/src/App.scss b/src/App.scss index e4f58e4e..1591f1da 100644 --- a/src/App.scss +++ b/src/App.scss @@ -47,6 +47,8 @@ body { color: var(--text, $fallback--text); max-width: 100vw; overflow-x: hidden; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } a { diff --git a/src/App.vue b/src/App.vue index 21abd694..769e075d 100644 --- a/src/App.vue +++ b/src/App.vue @@ -49,6 +49,7 @@ </div> <chat-panel :floating="true" v-if="currentUser && chat" class="floating-chat mobile-hidden"></chat-panel> <UserReportingModal /> + <portal-target name="modal" /> </div> </template> diff --git a/src/boot/after_store.js b/src/boot/after_store.js index c0faa0a2..cbe445ef 100644 --- a/src/boot/after_store.js +++ b/src/boot/after_store.js @@ -3,6 +3,8 @@ import VueRouter from 'vue-router' import routes from './routes' import App from '../App.vue' import { windowWidth } from '../services/window_utils/window_utils' +import { getOrCreateApp, getClientToken } from '../services/new_api/oauth.js' +import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js' const getStatusnetConfig = async ({ store }) => { try { @@ -92,15 +94,14 @@ const setSettings = async ({ apiConfig, staticConfig, store }) => { ? 0 : config.logoMargin }) + store.commit('authFlow/setInitialStrategy', config.loginMethod) copyInstanceOption('redirectRootNoLogin') copyInstanceOption('redirectRootLogin') copyInstanceOption('showInstanceSpecificPanel') copyInstanceOption('minimalScopesMode') - copyInstanceOption('formattingOptionsEnabled') copyInstanceOption('hideMutedPosts') copyInstanceOption('collapseMessageWithSubject') - copyInstanceOption('loginMethod') copyInstanceOption('scopeCopy') copyInstanceOption('subjectLineBehavior') copyInstanceOption('postContentType') @@ -197,6 +198,17 @@ const getCustomEmoji = async ({ store }) => { } } +const getAppSecret = async ({ store }) => { + const { state, commit } = store + const { oauth, instance } = state + return getOrCreateApp({ ...oauth, instance: instance.server, commit }) + .then((app) => getClientToken({ ...app, instance: instance.server })) + .then((token) => { + commit('setAppToken', token.access_token) + commit('setBackendInteractor', backendInteractorService(store.getters.getToken())) + }) +} + const getNodeInfo = async ({ store }) => { try { const res = await window.fetch('/nodeinfo/2.0.json') @@ -237,14 +249,14 @@ const setConfig = async ({ store }) => { const apiConfig = configInfos[0] const staticConfig = configInfos[1] - await setSettings({ store, apiConfig, staticConfig }) + await setSettings({ store, apiConfig, staticConfig }).then(getAppSecret({ store })) } const checkOAuthToken = async ({ store }) => { return new Promise(async (resolve, reject) => { - if (store.state.oauth.token) { + if (store.getters.getUserToken()) { try { - await store.dispatch('loginUser', store.state.oauth.token) + await store.dispatch('loginUser', store.getters.getUserToken()) } catch (e) { console.log(e) } diff --git a/src/boot/routes.js b/src/boot/routes.js index 1a179099..055a0aea 100644 --- a/src/boot/routes.js +++ b/src/boot/routes.js @@ -13,7 +13,7 @@ import FollowRequests from 'components/follow_requests/follow_requests.vue' import OAuthCallback from 'components/oauth_callback/oauth_callback.vue' import UserSearch from 'components/user_search/user_search.vue' import Notifications from 'components/notifications/notifications.vue' -import LoginForm from 'components/login_form/login_form.vue' +import AuthForm from 'components/auth_form/auth_form.js' import ChatPanel from 'components/chat_panel/chat_panel.vue' import WhoToFollow from 'components/who_to_follow/who_to_follow.vue' import About from 'components/about/about.vue' @@ -42,7 +42,7 @@ export default (store) => { { name: 'friend-requests', path: '/friend-requests', component: FollowRequests }, { name: 'user-settings', path: '/user-settings', component: UserSettings }, { name: 'notifications', path: '/:username/notifications', component: Notifications }, - { name: 'login', path: '/login', component: LoginForm }, + { name: 'login', path: '/login', component: AuthForm }, { name: 'chat', path: '/chat', component: ChatPanel, props: () => ({ floating: false }) }, { name: 'oauth-callback', path: '/oauth-callback', component: OAuthCallback, props: (route) => ({ code: route.query.code }) }, { name: 'user-search', path: '/user-search', component: UserSearch, props: (route) => ({ query: route.query.query }) }, diff --git a/src/components/attachment/attachment.vue b/src/components/attachment/attachment.vue index c58bebd3..fc9cc7a2 100644 --- a/src/components/attachment/attachment.vue +++ b/src/components/attachment/attachment.vue @@ -68,6 +68,7 @@ max-height: 200px; max-width: 100%; display: flex; + align-items: center; video { max-width: 100%; } diff --git a/src/components/auth_form/auth_form.js b/src/components/auth_form/auth_form.js new file mode 100644 index 00000000..e9a6e2d5 --- /dev/null +++ b/src/components/auth_form/auth_form.js @@ -0,0 +1,26 @@ +import LoginForm from '../login_form/login_form.vue' +import MFARecoveryForm from '../mfa_form/recovery_form.vue' +import MFATOTPForm from '../mfa_form/totp_form.vue' +import { mapGetters } from 'vuex' + +const AuthForm = { + name: 'AuthForm', + render (createElement) { + return createElement('component', { is: this.authForm }) + }, + computed: { + authForm () { + if (this.requiredTOTP) { return 'MFATOTPForm' } + if (this.requiredRecovery) { return 'MFARecoveryForm' } + return 'LoginForm' + }, + ...mapGetters('authFlow', ['requiredTOTP', 'requiredRecovery']) + }, + components: { + MFARecoveryForm, + MFATOTPForm, + LoginForm + } +} + +export default AuthForm diff --git a/src/components/avatar_list/avatar_list.vue b/src/components/avatar_list/avatar_list.vue index c0238570..343ef529 100644 --- a/src/components/avatar_list/avatar_list.vue +++ b/src/components/avatar_list/avatar_list.vue @@ -1,6 +1,11 @@ <template> <div class="avatars"> - <router-link :to="userProfileLink(user)" class="avatars-item" v-for="user in slicedUsers"> + <router-link + :to="userProfileLink(user)" + class="avatars-item" + v-for="user in slicedUsers" + :key="user.id" + > <UserAvatar :user="user" class="avatar-small" /> </router-link> </div> diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js index b3074590..8ae89421 100644 --- a/src/components/conversation/conversation.js +++ b/src/components/conversation/conversation.js @@ -139,6 +139,7 @@ const conversation = { return (this.isExpanded) && id === this.status.id }, setHighlight (id) { + if (!id) return this.highlight = id this.$store.dispatch('fetchFavsAndRepeats', id) }, diff --git a/src/components/dialog_modal/dialog_modal.vue b/src/components/dialog_modal/dialog_modal.vue index 7621fb20..3da543de 100644 --- a/src/components/dialog_modal/dialog_modal.vue +++ b/src/components/dialog_modal/dialog_modal.vue @@ -62,6 +62,7 @@ .title { margin-bottom: 0; + text-align: center; } } @@ -80,6 +81,7 @@ background-color: var(--lightBg, $fallback--lightBg); border-top: 1px solid $fallback--bg; border-top: 1px solid var(--bg, $fallback--bg); + display: flex; justify-content: flex-end; button { diff --git a/src/components/login_form/login_form.js b/src/components/login_form/login_form.js index dc917e47..93214646 100644 --- a/src/components/login_form/login_form.js +++ b/src/components/login_form/login_form.js @@ -1,54 +1,80 @@ +import { mapState, mapGetters, mapActions, mapMutations } from 'vuex' import oauthApi from '../../services/new_api/oauth.js' + const LoginForm = { data: () => ({ user: {}, - authError: false + error: false }), computed: { - loginMethod () { return this.$store.state.instance.loginMethod }, - loggingIn () { return this.$store.state.users.loggingIn }, - registrationOpen () { return this.$store.state.instance.registrationOpen } + isPasswordAuth () { return this.requiredPassword }, + isTokenAuth () { return this.requiredToken }, + ...mapState({ + registrationOpen: state => state.instance.registrationOpen, + instance: state => state.instance, + loggingIn: state => state.users.loggingIn, + oauth: state => state.oauth + }), + ...mapGetters( + 'authFlow', ['requiredPassword', 'requiredToken', 'requiredMFA'] + ) }, methods: { - oAuthLogin () { - oauthApi.login({ - oauth: this.$store.state.oauth, - instance: this.$store.state.instance.server, + ...mapMutations('authFlow', ['requireMFA']), + ...mapActions({ login: 'authFlow/login' }), + submit () { + this.isTokenAuth ? this.submitToken() : this.submitPassword() + }, + submitToken () { + const { clientId } = this.oauth + const data = { + clientId, + instance: this.instance.server, commit: this.$store.commit - }) + } + + oauthApi.getOrCreateApp(data) + .then((app) => { oauthApi.login({ ...app, ...data }) }) }, - submit () { + submitPassword () { + const { clientId } = this.oauth const data = { - oauth: this.$store.state.oauth, - instance: this.$store.state.instance.server + clientId, + oauth: this.oauth, + instance: this.instance.server, + commit: this.$store.commit } - this.clearError() + this.error = false + oauthApi.getOrCreateApp(data).then((app) => { oauthApi.getTokenWithCredentials( { - app, + ...app, instance: data.instance, username: this.user.username, password: this.user.password } - ).then(async (result) => { + ).then((result) => { if (result.error) { - this.authError = result.error - this.user.password = '' + if (result.error === 'mfa_required') { + this.requireMFA({app: app, settings: result}) + } else { + this.error = result.error + this.focusOnPasswordInput() + } return } - this.$store.commit('setToken', result.access_token) - try { - await this.$store.dispatch('loginUser', result.access_token) + this.login(result).then(() => { this.$router.push({name: 'friends'}) - } catch (e) { - console.log(e) - } + }) }) }) }, - clearError () { - this.authError = false + clearError () { this.error = false }, + focusOnPasswordInput () { + let passwordInput = this.$refs.passwordInput + passwordInput.focus() + passwordInput.setSelectionRange(0, passwordInput.value.length) } } } diff --git a/src/components/login_form/login_form.vue b/src/components/login_form/login_form.vue index c6be2e00..a2c5cf8f 100644 --- a/src/components/login_form/login_form.vue +++ b/src/components/login_form/login_form.vue @@ -1,47 +1,53 @@ <template> - <div class="login panel panel-default"> - <!-- Default panel contents --> - <div class="panel-heading"> - {{$t('login.login')}} - </div> - <div class="panel-body"> - <form v-if="loginMethod == 'password'" v-on:submit.prevent='submit(user)' class='login-form'> +<div class="login panel panel-default"> + <!-- Default panel contents --> + + <div class="panel-heading">{{$t('login.login')}}</div> + + <div class="panel-body"> + <form class='login-form' @submit.prevent='submit'> + <template v-if="isPasswordAuth"> <div class='form-group'> <label for='username'>{{$t('login.username')}}</label> - <input :disabled="loggingIn" v-model='user.username' class='form-control' id='username' v-bind:placeholder="$t('login.placeholder')"> + <input :disabled="loggingIn" v-model='user.username' + class='form-control' id='username' + :placeholder="$t('login.placeholder')"> </div> <div class='form-group'> <label for='password'>{{$t('login.password')}}</label> - <input :disabled="loggingIn" v-model='user.password' class='form-control' id='password' type='password'> + <input :disabled="loggingIn" v-model='user.password' + ref='passwordInput' class='form-control' id='password' type='password'> </div> - <div class='form-group'> - <div class='login-bottom'> - <div><router-link :to="{name: 'registration'}" v-if='registrationOpen' class='register'>{{$t('login.register')}}</router-link></div> - <button :disabled="loggingIn" type='submit' class='btn btn-default'>{{$t('login.login')}}</button> - </div> - </div> - </form> + </template> - <form v-if="loginMethod == 'token'" v-on:submit.prevent='oAuthLogin' class="login-form"> - <div class="form-group"> - <p>{{$t('login.description')}}</p> - </div> - <div class='form-group'> - <div class='login-bottom'> - <div><router-link :to="{name: 'registration'}" v-if='registrationOpen' class='register'>{{$t('login.register')}}</router-link></div> - <button :disabled="loggingIn" type='submit' class='btn btn-default'>{{$t('login.login')}}</button> + <div class="form-group" v-if="isTokenAuth"> + <p>{{$t('login.description')}}</p> + </div> + + <div class='form-group'> + <div class='login-bottom'> + <div> + <router-link :to="{name: 'registration'}" + v-if='registrationOpen' + class='register'> + {{$t('login.register')}} + </router-link> </div> - </div> - </form> - - <div v-if="authError" class='form-group'> - <div class='alert error'> - {{authError}} - <i class="button-icon icon-cancel" @click="clearError"></i> + <button :disabled="loggingIn" type='submit' class='btn btn-default'> + {{$t('login.login')}} + </button> </div> </div> + </form> + </div> + + <div v-if="error" class='form-group'> + <div class='alert error'> + {{error}} + <i class="button-icon icon-cancel" @click="clearError"></i> </div> </div> +</div> </template> <script src="./login_form.js" ></script> diff --git a/src/components/mfa_form/recovery_form.js b/src/components/mfa_form/recovery_form.js new file mode 100644 index 00000000..fbe9b437 --- /dev/null +++ b/src/components/mfa_form/recovery_form.js @@ -0,0 +1,41 @@ +import mfaApi from '../../services/new_api/mfa.js' +import { mapState, mapGetters, mapActions, mapMutations } from 'vuex' + +export default { + data: () => ({ + code: null, + error: false + }), + computed: { + ...mapGetters({ + authApp: 'authFlow/app', + authSettings: 'authFlow/settings' + }), + ...mapState({ instance: 'instance' }) + }, + methods: { + ...mapMutations('authFlow', ['requireTOTP', 'abortMFA']), + ...mapActions({ login: 'authFlow/login' }), + clearError () { this.error = false }, + submit () { + const data = { + app: this.authApp, + instance: this.instance.server, + mfaToken: this.authSettings.mfa_token, + code: this.code + } + + mfaApi.verifyRecoveryCode(data).then((result) => { + if (result.error) { + this.error = result.error + this.code = null + return + } + + this.login(result).then(() => { + this.$router.push({name: 'friends'}) + }) + }) + } + } +} diff --git a/src/components/mfa_form/recovery_form.vue b/src/components/mfa_form/recovery_form.vue new file mode 100644 index 00000000..e0e2d65b --- /dev/null +++ b/src/components/mfa_form/recovery_form.vue @@ -0,0 +1,42 @@ +<template> +<div class="login panel panel-default"> + <!-- Default panel contents --> + + <div class="panel-heading">{{$t('login.heading.recovery')}}</div> + + <div class="panel-body"> + <form class='login-form' @submit.prevent='submit'> + <div class='form-group'> + <label for='code'>{{$t('login.recovery_code')}}</label> + <input v-model='code' class='form-control' id='code'> + </div> + + <div class='form-group'> + <div class='login-bottom'> + <div> + <a href="#" @click.prevent="requireTOTP"> + {{$t('login.enter_two_factor_code')}} + </a> + <br /> + <a href="#" @click.prevent="abortMFA"> + {{$t('general.cancel')}} + </a> + </div> + <button type='submit' class='btn btn-default'> + {{$t('general.verify')}} + </button> + </div> + </div> + + </form> + </div> + + <div v-if="error" class='form-group'> + <div class='alert error'> + {{error}} + <i class="button-icon icon-cancel" @click="clearError"></i> + </div> + </div> +</div> +</template> +<script src="./recovery_form.js" ></script> diff --git a/src/components/mfa_form/totp_form.js b/src/components/mfa_form/totp_form.js new file mode 100644 index 00000000..6c94fe52 --- /dev/null +++ b/src/components/mfa_form/totp_form.js @@ -0,0 +1,40 @@ +import mfaApi from '../../services/new_api/mfa.js' +import { mapState, mapGetters, mapActions, mapMutations } from 'vuex' +export default { + data: () => ({ + code: null, + error: false + }), + computed: { + ...mapGetters({ + authApp: 'authFlow/app', + authSettings: 'authFlow/settings' + }), + ...mapState({ instance: 'instance' }) + }, + methods: { + ...mapMutations('authFlow', ['requireRecovery', 'abortMFA']), + ...mapActions({ login: 'authFlow/login' }), + clearError () { this.error = false }, + submit () { + const data = { + app: this.authApp, + instance: this.instance.server, + mfaToken: this.authSettings.mfa_token, + code: this.code + } + + mfaApi.verifyOTPCode(data).then((result) => { + if (result.error) { + this.error = result.error + this.code = null + return + } + + this.login(result).then(() => { + this.$router.push({name: 'friends'}) + }) + }) + } + } +} diff --git a/src/components/mfa_form/totp_form.vue b/src/components/mfa_form/totp_form.vue new file mode 100644 index 00000000..c547785e --- /dev/null +++ b/src/components/mfa_form/totp_form.vue @@ -0,0 +1,45 @@ +<template> +<div class="login panel panel-default"> + <!-- Default panel contents --> + + <div class="panel-heading"> + {{$t('login.heading.totp')}} + </div> + + <div class="panel-body"> + <form class='login-form' @submit.prevent='submit'> + <div class='form-group'> + <label for='code'> + {{$t('login.authentication_code')}} + </label> + <input v-model='code' class='form-control' id='code'> + </div> + + <div class='form-group'> + <div class='login-bottom'> + <div> + <a href="#" @click.prevent="requireRecovery"> + {{$t('login.enter_recovery_code')}} + </a> + <br /> + <a href="#" @click.prevent="abortMFA"> + {{$t('general.cancel')}} + </a> + </div> + <button type='submit' class='btn btn-default'> + {{$t('general.verify')}} + </button> + </div> + </div> + </form> + </div> + + <div v-if="error" class='form-group'> + <div class='alert error'> + {{error}} + <i class="button-icon icon-cancel" @click="clearError"></i> + </div> + </div> +</div> +</template> +<script src="./totp_form.js"></script> diff --git a/src/components/moderation_tools/moderation_tools.vue b/src/components/moderation_tools/moderation_tools.vue index 146673a5..6a5e8cc0 100644 --- a/src/components/moderation_tools/moderation_tools.vue +++ b/src/components/moderation_tools/moderation_tools.vue @@ -65,18 +65,20 @@ {{ $t('user_card.admin_menu.moderation') }} </button> </Popper> - <DialogModal v-if="showDeleteUserDialog" :onCancel='deleteUserDialog.bind(this, false)'> - <span slot="header">{{ $t('user_card.admin_menu.delete_user') }}</span> - <p>{{ $t('user_card.admin_menu.delete_user_confirmation') }}</p> - <span slot="footer"> - <button @click='deleteUserDialog(false)'> - {{ $t('general.cancel') }} - </button> - <button class="danger" @click='deleteUser()'> - {{ $t('user_card.admin_menu.delete_user') }} - </button> - </span> - </DialogModal> + <portal to="modal"> + <DialogModal v-if="showDeleteUserDialog" :onCancel='deleteUserDialog.bind(this, false)'> + <template slot="header">{{ $t('user_card.admin_menu.delete_user') }}</template> + <p>{{ $t('user_card.admin_menu.delete_user_confirmation') }}</p> + <template slot="footer"> + <button class="btn btn-default" @click='deleteUserDialog(false)'> + {{ $t('general.cancel') }} + </button> + <button class="btn btn-default danger" @click='deleteUser()'> + {{ $t('user_card.admin_menu.delete_user') }} + </button> + </template> + </DialogModal> + </portal> </div> </template> diff --git a/src/components/oauth_callback/oauth_callback.js b/src/components/oauth_callback/oauth_callback.js index e3d45ee1..2c6ca235 100644 --- a/src/components/oauth_callback/oauth_callback.js +++ b/src/components/oauth_callback/oauth_callback.js @@ -4,14 +4,16 @@ const oac = { props: ['code'], mounted () { if (this.code) { + const { clientId } = this.$store.state.oauth + oauth.getToken({ - app: this.$store.state.oauth, + clientId, instance: this.$store.state.instance.server, code: this.code }).then((result) => { this.$store.commit('setToken', result.access_token) this.$store.dispatch('loginUser', result.access_token) - this.$router.push({name: 'friends'}) + this.$router.push({ name: 'friends' }) }) } } diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js index a8f86492..a7874acd 100644 --- a/src/components/post_status_form/post_status_form.js +++ b/src/components/post_status_form/post_status_form.js @@ -147,9 +147,6 @@ const PostStatusForm = { return true } }, - formattingOptionsEnabled () { - return this.$store.state.instance.formattingOptionsEnabled - }, postFormats () { return this.$store.state.instance.postFormats || [] }, diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue index faa32be7..9929aab0 100644 --- a/src/components/post_status_form/post_status_form.vue +++ b/src/components/post_status_form/post_status_form.vue @@ -67,7 +67,7 @@ </textarea> </EmojiInput> <div class="visibility-tray"> - <div class="text-format" v-if="formattingOptionsEnabled"> + <div class="text-format" v-if="postFormats.length > 1"> <label for="post-content-type" class="select"> <select id="post-content-type" v-model="newStatus.contentType" class="form-control"> <option v-for="postFormat in postFormats" :key="postFormat" :value="postFormat"> @@ -77,6 +77,11 @@ <i class="icon-down-open"></i> </label> </div> + <div class="text-format" v-if="postFormats.length === 1"> + <span class="only-format"> + {{$t(`post_status.content_type["${postFormats[0]}"]`)}} + </span> + </div> <scope-selector :showAll="showAllScopes" @@ -167,6 +172,14 @@ } } + .text-format { + .only-format { + color: $fallback--faint; + color: var(--faint, $fallback--faint); + } + } + + .error { text-align: center; } diff --git a/src/components/settings/settings.vue b/src/components/settings/settings.vue index 4cf6fae2..7067c508 100644 --- a/src/components/settings/settings.vue +++ b/src/components/settings/settings.vue @@ -102,7 +102,7 @@ </label> </div> </li> - <li> + <li v-if="postFormats.length > 0"> <div> {{$t('settings.post_status_content_type')}} <label for="postContentType" class="select"> diff --git a/src/components/user_panel/user_panel.js b/src/components/user_panel/user_panel.js index d4478290..c2f51eb6 100644 --- a/src/components/user_panel/user_panel.js +++ b/src/components/user_panel/user_panel.js @@ -1,13 +1,15 @@ -import LoginForm from '../login_form/login_form.vue' +import AuthForm from '../auth_form/auth_form.js' import PostStatusForm from '../post_status_form/post_status_form.vue' import UserCard from '../user_card/user_card.vue' +import { mapState } from 'vuex' const UserPanel = { computed: { - user () { return this.$store.state.users.currentUser } + signedIn () { return this.user }, + ...mapState({ user: state => state.users.currentUser }) }, components: { - LoginForm, + AuthForm, PostStatusForm, UserCard } diff --git a/src/components/user_panel/user_panel.vue b/src/components/user_panel/user_panel.vue index 8310f30e..37e28ca5 100644 --- a/src/components/user_panel/user_panel.vue +++ b/src/components/user_panel/user_panel.vue @@ -1,13 +1,20 @@ <template> <div class="user-panel"> - <div v-if='user' class="panel panel-default" style="overflow: visible;"> + + <div v-if="signedIn" key="user-panel" class="panel panel-default signed-in"> <UserCard :user="user" :hideBio="true" rounded="top"/> <div class="panel-footer"> <post-status-form v-if='user'></post-status-form> </div> </div> - <login-form v-if='!user'></login-form> + <auth-form v-else key="user-panel"/> </div> </template> <script src="./user_panel.js"></script> + +<style lang="scss"> +.user-panel .signed-in { + overflow: visible; +} +</style> 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..46a42e38 --- /dev/null +++ b/src/components/user_settings/confirm.vue @@ -0,0 +1,14 @@ +<template> +<div> + <slot></slot> + <button class="btn btn-default" @click="confirm" :disabled="disabled"> + {{$t('general.confirm')}} + </button> + <button class="btn btn-default" @click="cancel" :disabled="disabled"> + {{$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..d44a3ab7 --- /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..1f1be60d --- /dev/null +++ b/src/components/user_settings/mfa.vue @@ -0,0 +1,121 @@ +<template> +<div class="setting-item mfa-settings" v-if="readyInit && settings.available"> + + <div class="mfa-heading"> + <h2>{{$t('settings.mfa.title')}}</h2> + </div> + + <div> + <div class="setting-item" v-if="!setupInProgress"> + <!-- 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 :backup-codes="backupCodes" v-if="!confirmNewBackupCodes" /> + <button class="btn btn-default" @click="getBackupCodes" v-if="!confirmNewBackupCodes"> + {{$t('settings.mfa.generate_new_recovery_codes')}} + </button> + + <div v-if="confirmNewBackupCodes"> + <confirm @confirm="confirmBackupCodes" @cancel="cancelBackupCodes" + :disabled="backupCodes.inProgress"> + <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 :backup-codes="backupCodes" v-if="!setupOTPInProgress"/> + + + <button class="btn btn-default" @click="cancelSetup" v-if="canSetupOTP"> + {{$t('general.cancel')}} + </button> + + <button class="btn btn-default" v-if="canSetupOTP" @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 }"></qrcode> + <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 type="text" v-model="otpConfirmToken"> + + <p>{{$t('settings.enter_current_password_to_confirm')}}:</p> + <input type="password" v-model="currentPassword"> + <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 class="alert error" v-if="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..c275bd63 --- /dev/null +++ b/src/components/user_settings/mfa_backup_codes.vue @@ -0,0 +1,22 @@ +<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">{{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..6b73c8f4 --- /dev/null +++ b/src/components/user_settings/mfa_totp.vue @@ -0,0 +1,23 @@ +<template> +<div> + <div class="method-item"> + <strong>{{$t('settings.mfa.otp')}}</strong> + <button class="btn btn-default" v-if="!isActivated" @click="doActivate"> + {{$t('general.enable')}} + </button> + + <button class="btn btn-default" :disabled="deactivate" @click="doDeactivate" + v-if="isActivated"> + {{$t('general.disable')}} + </button> + </div> + + <confirm @confirm="confirmDeactivate" @cancel="cancelDeactivate" + :disabled="inProgress" v-if="deactivate"> + {{$t('settings.enter_current_password_to_confirm')}}: + <input type="password" v-model="currentPassword"> + </confirm> + <div class="alert error" v-if="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 d5db4c2d..ced3efaa 100644 --- a/src/components/user_settings/user_settings.js +++ b/src/components/user_settings/user_settings.js @@ -18,6 +18,7 @@ import Importer from '../importer/importer.vue' import Exporter from '../exporter/exporter.vue' import withSubscription from '../../hocs/with_subscription/with_subscription' import userSearchApi from '../../services/new_api/user_search.js' +import Mfa from './mfa.vue' const BlockList = withSubscription({ fetch: (props, $store) => $store.dispatch('fetchBlocks'), @@ -46,7 +47,9 @@ const UserSettings = { pickAvatarBtnVisible: true, bannerUploading: false, backgroundUploading: false, + banner: null, bannerPreview: null, + background: null, backgroundPreview: null, bannerUploadError: null, backgroundUploadError: null, @@ -76,7 +79,8 @@ const UserSettings = { MuteCard, ProgressButton, Importer, - Exporter + Exporter, + Mfa }, computed: { user () { @@ -212,22 +216,12 @@ const UserSettings = { }, 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 diff --git a/src/components/user_settings/user_settings.vue b/src/components/user_settings/user_settings.vue index 5dab4eb7..94f1b1f1 100644 --- a/src/components/user_settings/user_settings.vue +++ b/src/components/user_settings/user_settings.vue @@ -46,6 +46,7 @@ <scope-selector :showAll="true" :userDefault="newDefaultScope" + :initialScope="newDefaultScope" :onScopeChange="changeVis"/> </div> </div> @@ -153,7 +154,7 @@ </tbody> </table> </div> - + <mfa /> <div class="setting-item"> <h2>{{$t('settings.delete_account')}}</h2> <p v-if="!deletingAccount">{{$t('settings.delete_account_description')}}</p> diff --git a/src/i18n/en.json b/src/i18n/en.json index 2b5c5468..a29f394b 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -27,7 +27,11 @@ "optional": "optional", "show_more": "Show more", "show_less": "Show less", - "cancel": "Cancel" + "cancel": "Cancel", + "disable": "Disable", + "enable": "Enable", + "confirm": "Confirm", + "verify": "Verify" }, "image_cropper": { "crop_picture": "Crop picture", @@ -48,7 +52,15 @@ "placeholder": "e.g. lain", "register": "Register", "username": "Username", - "hint": "Log in to join the discussion" + "hint": "Log in to join the discussion", + "authentication_code": "Authentication code", + "enter_recovery_code": "Enter a recovery code", + "enter_two_factor_code": "Enter a two-factor code", + "recovery_code": "Recovery code", + "heading" : { + "totp" : "Two-factor authentication", + "recovery" : "Two-factor recovery" + } }, "media_modal": { "previous": "Previous", @@ -138,6 +150,29 @@ }, "settings": { "app_name": "App name", + "security": "Security", + "enter_current_password_to_confirm": "Enter your current password to confirm your identity", + "mfa": { + "otp" : "OTP", + "setup_otp" : "Setup OTP", + "wait_pre_setup_otp" : "presetting OTP", + "confirm_and_enable" : "Confirm & enable OTP", + "title": "Two-factor Authentication", + "generate_new_recovery_codes" : "Generate new recovery codes", + "warning_of_generate_new_codes" : "When you generate new recovery codes, your old codes won’t work anymore.", + "recovery_codes" : "Recovery codes.", + "waiting_a_recovery_codes": "Receiving backup codes...", + "recovery_codes_warning" : "Write the codes down or save them somewhere secure - otherwise you won't see them again. If you lose access to your 2FA app and recovery codes you'll be locked out of your account.", + "authentication_methods" : "Authentication methods", + "scan": { + "title": "Scan", + "desc": "Using your two-factor app, scan this QR code or enter text key:", + "secret_code": "Key" + }, + "verify": { + "desc": "To enable two-factor authentication, enter the code from your two-factor app:" + } + }, "attachmentRadius": "Attachments", "attachments": "Attachments", "autoload": "Enable automatic loading when scrolled to the bottom", diff --git a/src/i18n/ja.json b/src/i18n/ja.json index b77f5531..87ab9dfd 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -2,6 +2,10 @@ "chat": { "title": "チャット" }, + "exporter": { + "export": "エクスポート", + "processing": "おまちください。しばらくすると、あなたのファイルをダウンロードするように、メッセージがでます。" + }, "features_panel": { "chat": "チャット", "gopher": "Gopher", @@ -19,7 +23,22 @@ "apply": "てきよう", "submit": "そうしん", "more": "つづき", - "generic_error": "エラーになりました" + "generic_error": "エラーになりました", + "optional": "かかなくてもよい", + "show_more": "つづきをみる", + "show_less": "たたむ", + "cancel": "キャンセル" + }, + "image_cropper": { + "crop_picture": "がぞうをきりぬく", + "save": "セーブ", + "save_without_cropping": "きりぬかずにセーブ", + "cancel": "キャンセル" + }, + "importer": { + "submit": "そうしん", + "success": "インポートできました。", + "error": "インポートがエラーになりました。" }, "login": { "login": "ログイン", @@ -31,12 +50,17 @@ "username": "ユーザーめい", "hint": "はなしあいにくわわるには、ログインしてください" }, + "media_modal": { + "previous": "まえ", + "next": "つぎ" + }, "nav": { "about": "これはなに?", "back": "もどる", "chat": "ローカルチャット", "friend_requests": "フォローリクエスト", "mentions": "メンション", + "interactions": "やりとり", "dms": "ダイレクトメッセージ", "public_tl": "パブリックタイムライン", "timeline": "タイムライン", @@ -55,18 +79,33 @@ "repeated_you": "あなたのステータスがリピートされました", "no_more_notifications": "つうちはありません" }, + "interactions": { + "favs_repeats": "リピートとおきにいり", + "follows": "あたらしいフォロー", + "load_older": "ふるいやりとりをみる" + }, "post_status": { "new_status": "とうこうする", "account_not_locked_warning": "あなたのアカウントは {0} ではありません。あなたをフォローすれば、だれでも、フォロワーげんていのステータスをよむことができます。", "account_not_locked_warning_link": "ロックされたアカウント", "attachments_sensitive": "ファイルをNSFWにする", "content_type": { - "text/plain": "プレーンテキスト" + "text/plain": "プレーンテキスト", + "text/html": "HTML", + "text/markdown": "Markdown", + "text/bbcode": "BBCode" }, "content_warning": "せつめい (かかなくてもよい)", "default": "はねだくうこうに、つきました。", + "direct_warning_to_all": "このとうこうは、メンションされたすべてのユーザーが、みることができます。", + "direct_warning_to_first_only": "このとうこうは、メッセージのはじめでメンションされたユーザーだけが、みることができます。", "direct_warning": "このステータスは、メンションされたユーザーだけが、よむことができます。", "posting": "とうこう", + "scope_notice": { + "public": "このとうこうは、だれでもみることができます", + "private": "このとうこうは、あなたのフォロワーだけが、みることができます", + "unlisted": "このとうこうは、パブリックタイムラインと、つながっているすべてのネットワークでは、みることができません" + }, "scope": { "direct": "ダイレクト: メンションされたユーザーのみにとどきます。", "private": "フォロワーげんてい: フォロワーのみにとどきます。", @@ -83,6 +122,9 @@ "token": "しょうたいトークン", "captcha": "CAPTCHA", "new_captcha": "もじがよめないときは、がぞうをクリックすると、あたらしいがぞうになります", + "username_placeholder": "れい: lain", + "fullname_placeholder": "れい: いわくら れいん", + "bio_placeholder": "れい:\nごきげんよう。わたしはれいん。\nわたしはアニメのおんなのこで、にほんのベッドタウンにすんでいます。ワイヤードで、わたしにあったことが、あるかもしれませんね。", "validations": { "username_required": "なにかかいてください", "fullname_required": "なにかかいてください", @@ -92,7 +134,11 @@ "password_confirmation_match": "パスワードがちがいます" } }, + "selectable_list": { + "select_all": "すべてえらぶ" + }, "settings": { + "app_name": "アプリのなまえ", "attachmentRadius": "ファイル", "attachments": "ファイル", "autoload": "したにスクロールしたとき、じどうてきによみこむ。", @@ -101,6 +147,12 @@ "avatarRadius": "アバター", "background": "バックグラウンド", "bio": "プロフィール", + "block_export": "ブロックのエクスポート", + "block_export_button": "ブロックをCSVファイルにエクスポート", + "block_import": "ブロックのインポート", + "block_import_error": "ブロックのインポートがエラーになりました", + "blocks_imported": "ブロックをインポートしました! じっさいにブロックするまでには、もうしばらくかかります。", + "blocks_tab": "ブロック", "btnRadius": "ボタン", "cBlue": "リプライとフォロー", "cGreen": "リピート", @@ -135,12 +187,15 @@ "general": "ぜんぱん", "hide_attachments_in_convo": "スレッドのファイルをかくす", "hide_attachments_in_tl": "タイムラインのファイルをかくす", + "hide_muted_posts": "ミュートしたユーザーのとうこうをかくす", + "max_thumbnails": "ひとつのとうこうにいれられるサムネイルのかず", "hide_isp": "インスタンススペシフィックパネルをかくす", "preload_images": "がぞうをさきよみする", "use_one_click_nsfw": "NSFWなファイルを1クリックでひらく", "hide_post_stats": "とうこうのとうけいをかくす (れい: おきにいりのかず)", "hide_user_stats": "ユーザーのとうけいをかくす (れい: フォロワーのかず)", "hide_filtered_statuses": "フィルターされたとうこうをかくす", + "import_blocks_from_a_csv_file": "CSVファイルからブロックをインポートする", "import_followers_from_a_csv_file": "CSVファイルからフォローをインポートする", "import_theme": "ロード", "inputRadius": "インプットフィールド", @@ -155,6 +210,7 @@ "lock_account_description": "あなたがみとめたひとだけ、あなたのアカウントをフォローできる", "loop_video": "ビデオをくりかえす", "loop_video_silent_only": "おとのないビデオだけくりかえす", + "mutes_tab": "ミュート", "play_videos_in_modal": "ビデオをメディアビューアーでみる", "use_contain_fit": "がぞうのサムネイルを、きりぬかない", "name": "なまえ", @@ -166,16 +222,18 @@ "notification_visibility_mentions": "メンション", "notification_visibility_repeats": "リピート", "no_rich_text_description": "リッチテキストをつかわない", + "no_blocks": "ブロックしていません", + "no_mutes": "ミュートしていません", "hide_follows_description": "フォローしているひとをみせない", "hide_followers_description": "フォロワーをみせない", - "show_admin_badge": "アドミンのしるしをみる", - "show_moderator_badge": "モデレーターのしるしをみる", + "show_admin_badge": "アドミンのしるしをみせる", + "show_moderator_badge": "モデレーターのしるしをみせる", "nsfw_clickthrough": "NSFWなファイルをかくす", "oauth_tokens": "OAuthトークン", "token": "トークン", - "refresh_token": "トークンを更新", - "valid_until": "まで有効", - "revoke_token": "取り消す", + "refresh_token": "トークンをリフレッシュ", + "valid_until": "おわりのとき", + "revoke_token": "とりけす", "panelRadius": "パネル", "pause_on_unfocused": "タブにフォーカスがないときストリーミングをとめる", "presets": "プリセット", @@ -188,10 +246,14 @@ "reply_visibility_all": "すべてのリプライをみる", "reply_visibility_following": "わたしにあてられたリプライと、フォローしているひとからのリプライをみる", "reply_visibility_self": "わたしにあてられたリプライをみる", + "autohide_floating_post_button": "あたらしいとうこうのボタンを、じどうてきにかくす (モバイル)", "saving_err": "せっていをセーブできませんでした", "saving_ok": "せっていをセーブしました", + "search_user_to_block": "ブロックしたいひとを、ここでけんさくできます", + "search_user_to_mute": "ミュートしたいひとを、ここでけんさくできます", "security_tab": "セキュリティ", "scope_copy": "リプライするとき、こうかいはんいをコピーする (DMのこうかいはんいは、つねにコピーされます)", + "minimal_scopes_mode": "こうかいはんいせんたくオプションを、ちいさくする", "set_new_avatar": "あたらしいアバターをせっていする", "set_new_profile_background": "あたらしいプロフィールのバックグラウンドをせっていする", "set_new_profile_banner": "あたらしいプロフィールバナーを設定する", @@ -209,6 +271,7 @@ "theme_help": "カラーテーマをカスタマイズできます", "theme_help_v2_1": "チェックボックスをONにすると、コンポーネントごとに、いろと、とうめいどを、オーバーライドできます。「すべてクリア」ボタンをおすと、すべてのオーバーライドを、やめます。", "theme_help_v2_2": "バックグラウンドとテキストのコントラストをあらわすアイコンがあります。マウスをホバーすると、くわしいせつめいがでます。とうめいないろをつかっているときは、もっともわるいばあいのコントラストがしめされます。", + "upload_a_photo": "がぞうをアップロード", "tooltipRadius": "ツールチップとアラート", "user_settings": "ユーザーせってい", "values": { @@ -216,6 +279,13 @@ "true": "はい" }, "notifications": "つうち", + "notification_setting": "つうちをうけとる:", + "notification_setting_follows": "あなたがフォローしているひとから", + "notification_setting_non_follows": "あなたがフォローしていないひとから", + "notification_setting_followers": "あなたをフォローしているひとから", + "notification_setting_non_followers": "あなたをフォローしていないひとから", + "notification_mutes": "あるユーザーからのつうちをとめるには、ミュートしてください。", + "notification_blocks": "ブロックしているユーザーからのつうちは、すべてとまります。", "enable_web_push_notifications": "ウェブプッシュつうちをゆるす", "style": { "switcher": { @@ -325,6 +395,11 @@ "checkbox": "りようきやくを、よみました", "link": "ハイパーリンク" } + }, + "version": { + "title": "バージョン", + "backend_version": "バックエンドのバージョン", + "frontend_version": "フロントエンドのバージョン" } }, "timeline": { @@ -336,7 +411,19 @@ "repeated": "リピート", "show_new": "よみこみ", "up_to_date": "さいしん", - "no_more_statuses": "これでおわりです" + "no_more_statuses": "これでおわりです", + "no_statuses": "ありません" + }, + "status": { + "favorites": "おきにいり", + "repeats": "リピート", + "delete": "ステータスをけす", + "pin": "プロフィールにピンどめする", + "unpin": "プロフィールにピンどめするのをやめる", + "pinned": "ピンどめ", + "delete_confirm": "ほんとうに、このステータスを、けしてもいいですか?", + "reply_to": "へんしん:", + "replies_list": "へんしん:" }, "user_card": { "approve": "うけいれ", @@ -359,10 +446,47 @@ "muted": "ミュートしています!", "per_day": "/日", "remote_follow": "リモートフォロー", - "statuses": "ステータス" + "report": "つうほう", + "statuses": "ステータス", + "unblock": "ブロックをやめる", + "unblock_progress": "ブロックをとりけしています...", + "block_progress": "ブロックしています...", + "unmute": "ミュートをやめる", + "unmute_progress": "ミュートをとりけしています...", + "mute_progress": "ミュートしています...", + "admin_menu": { + "moderation": "モデレーション", + "grant_admin": "アドミンにする", + "revoke_admin": "アドミンをやめさせる", + "grant_moderator": "モデレーターにする", + "revoke_moderator": "モデレーターをやめさせる", + "activate_account": "アカウントをアクティブにする", + "deactivate_account": "アカウントをアクティブでなくする", + "delete_account": "アカウントをけす", + "force_nsfw": "すべてのとうこうをNSFWにする", + "strip_media": "とうこうからメディアをなくす", + "force_unlisted": "とうこうをアンリステッドにする", + "sandbox": "とうこうをフォロワーのみにする", + "disable_remote_subscription": "ほかのインスタンスからフォローされないようにする", + "disable_any_subscription": "フォローされないようにする", + "quarantine": "ほかのインスタンスのユーザーのとうこうをとめる", + "delete_user": "ユーザーをけす", + "delete_user_confirmation": "あなたは、ほんとうに、きはたしかですか? これは、とりけすことが、できません。" + } }, "user_profile": { - "timeline_title": "ユーザータイムライン" + "timeline_title": "ユーザータイムライン", + "profile_does_not_exist": "ごめんなさい。このプロフィールは、そんざいしません。", + "profile_loading_error": "ごめんなさい。プロフィールのロードがエラーになりました。" + }, + "user_reporting": { + "title": "つうほうする: {0}", + "add_comment_description": "このつうほうは、あなたのインスタンスのモデレーターに、おくられます。このアカウントを、つうほうするりゆうを、せつめいすることができます:", + "additional_comments": "ついかのコメント", + "forward_description": "このアカウントは、ほかのインスタンスのものです。そのインスタンスにも、このつうほうのコピーを、おくりますか?", + "forward_to": "コピーをおくる: {0}", + "submit": "そうしん", + "generic_error": "あなたのリクエストをうけつけようとしましたが、エラーになってしまいました。" }, "who_to_follow": { "more": "くわしく", diff --git a/src/i18n/ja_pedantic.json b/src/i18n/ja_pedantic.json index 8417a813..9036baf5 100644 --- a/src/i18n/ja_pedantic.json +++ b/src/i18n/ja_pedantic.json @@ -2,6 +2,10 @@ "chat": { "title": "チャット" }, + "exporter": { + "export": "エクスポート", + "processing": "処理中です。処理が完了すると、ファイルをダウンロードするよう指示があります。" + }, "features_panel": { "chat": "チャット", "gopher": "Gopher", @@ -19,7 +23,22 @@ "apply": "適用", "submit": "送信", "more": "続き", - "generic_error": "エラーになりました" + "generic_error": "エラーになりました", + "optional": "省略可", + "show_more": "もっと見る", + "show_less": "たたむ", + "cancel": "キャンセル" + }, + "image_cropper": { + "crop_picture": "画像を切り抜く", + "save": "保存", + "save_without_cropping": "切り抜かずに保存", + "cancel": "キャンセル" + }, + "importer": { + "submit": "送信", + "success": "正常にインポートされました。", + "error": "このファイルをインポートするとき、エラーが発生しました。" }, "login": { "login": "ログイン", @@ -31,12 +50,17 @@ "username": "ユーザー名", "hint": "会話に加わるには、ログインしてください" }, + "media_modal": { + "previous": "前", + "next": "次" + }, "nav": { "about": "このインスタンスについて", "back": "戻る", "chat": "ローカルチャット", "friend_requests": "フォローリクエスト", "mentions": "通知", + "interactions": "インタラクション", "dms": "ダイレクトメッセージ", "public_tl": "パブリックタイムライン", "timeline": "タイムライン", @@ -55,18 +79,33 @@ "repeated_you": "あなたのステータスがリピートされました", "no_more_notifications": "通知はありません" }, + "interactions": { + "favs_repeats": "リピートとお気に入り", + "follows": "新しいフォロワー", + "load_older": "古いインタラクションを見る" + }, "post_status": { "new_status": "投稿する", "account_not_locked_warning": "あなたのアカウントは {0} ではありません。あなたをフォローすれば、誰でも、フォロワー限定のステータスを読むことができます。", "account_not_locked_warning_link": "ロックされたアカウント", "attachments_sensitive": "ファイルをNSFWにする", "content_type": { - "text/plain": "プレーンテキスト" + "text/plain": "プレーンテキスト", + "text/html": "HTML", + "text/markdown": "Markdown", + "text/bbcode": "BBCode" }, "content_warning": "説明 (省略可)", "default": "羽田空港に着きました。", + "direct_warning_to_all": "この投稿は、メンションされたすべてのユーザーが、見ることができます。", + "direct_warning_to_first_only": "この投稿は、メッセージの冒頭でメンションされたユーザーだけが、見ることができます。", "direct_warning": "このステータスは、メンションされたユーザーだけが、読むことができます。", "posting": "投稿", + "scope_notice": { + "public": "この投稿は、誰でも見ることができます", + "private": "この投稿は、あなたのフォロワーだけが、見ることができます。", + "unlisted": "この投稿は、パブリックタイムラインと、接続しているすべてのネットワークには、表示されません。" + }, "scope": { "direct": "ダイレクト: メンションされたユーザーのみに届きます。", "private": "フォロワーげんてい: フォロワーのみに届きます。", @@ -83,6 +122,9 @@ "token": "招待トークン", "captcha": "CAPTCHA", "new_captcha": "文字が読めないときは、画像をクリックすると、新しい画像になります", + "username_placeholder": "例: lain", + "fullname_placeholder": "例: 岩倉玲音", + "bio_placeholder": "例:\nこんにちは。私は玲音。\n私はアニメのキャラクターで、日本の郊外に住んでいます。私をWiredで見たことがあるかもしれません。", "validations": { "username_required": "必須", "fullname_required": "必須", @@ -92,7 +134,11 @@ "password_confirmation_match": "パスワードが違います" } }, + "selectable_list": { + "select_all": "すべて選択" + }, "settings": { + "app_name": "アプリの名称", "attachmentRadius": "ファイル", "attachments": "ファイル", "autoload": "下にスクロールしたとき、自動的に読み込む。", @@ -101,6 +147,12 @@ "avatarRadius": "アバター", "background": "バックグラウンド", "bio": "プロフィール", + "block_export": "ブロックのエクスポート", + "block_export_button": "ブロックをCSVファイルにエクスポートする", + "block_import": "ブロックのインポート", + "block_import_error": "ブロックのインポートに失敗しました", + "blocks_imported": "ブロックをインポートしました! 実際に処理されるまでに、しばらく時間がかかります。", + "blocks_tab": "ブロック", "btnRadius": "ボタン", "cBlue": "返信とフォロー", "cGreen": "リピート", @@ -128,19 +180,22 @@ "follow_export": "フォローのエクスポート", "follow_export_button": "エクスポート", "follow_export_processing": "お待ちください。まもなくファイルをダウンロードできます。", - "follow_import": "フォローインポート", + "follow_import": "フォローのインポート", "follow_import_error": "フォローのインポートがエラーになりました。", "follows_imported": "フォローがインポートされました! 少し時間がかかるかもしれません。", "foreground": "フォアグラウンド", "general": "全般", "hide_attachments_in_convo": "スレッドのファイルを隠す", "hide_attachments_in_tl": "タイムラインのファイルを隠す", + "hide_muted_posts": "ミュートしているユーザーの投稿を隠す", + "max_thumbnails": "投稿に含まれるサムネイルの最大数", "hide_isp": "インスタンス固有パネルを隠す", "preload_images": "画像を先読みする", "use_one_click_nsfw": "NSFWなファイルを1クリックで開く", "hide_post_stats": "投稿の統計を隠す (例: お気に入りの数)", "hide_user_stats": "ユーザーの統計を隠す (例: フォロワーの数)", "hide_filtered_statuses": "フィルターされた投稿を隠す", + "import_blocks_from_a_csv_file": "CSVファイルからブロックをインポートする", "import_followers_from_a_csv_file": "CSVファイルからフォローをインポートする", "import_theme": "ロード", "inputRadius": "インプットフィールド", @@ -155,6 +210,7 @@ "lock_account_description": "あなたが認めた人だけ、あなたのアカウントをフォローできる", "loop_video": "ビデオを繰り返す", "loop_video_silent_only": "音のないビデオだけ繰り返す", + "mutes_tab": "ミュート", "play_videos_in_modal": "ビデオをメディアビューアーで見る", "use_contain_fit": "画像のサムネイルを、切り抜かない", "name": "名前", @@ -166,6 +222,8 @@ "notification_visibility_mentions": "メンション", "notification_visibility_repeats": "リピート", "no_rich_text_description": "リッチテキストを使わない", + "no_blocks": "ブロックはありません", + "no_mutes": "ミュートはありません", "hide_follows_description": "フォローしている人を見せない", "hide_followers_description": "フォロワーを見せない", "show_admin_badge": "管理者のバッジを見せる", @@ -188,10 +246,14 @@ "reply_visibility_all": "すべてのリプライを見る", "reply_visibility_following": "私に宛てられたリプライと、フォローしている人からのリプライを見る", "reply_visibility_self": "私に宛てられたリプライを見る", + "autohide_floating_post_button": "新しい投稿ボタンを自動的に隠す (モバイル)", "saving_err": "設定を保存できませんでした", "saving_ok": "設定を保存しました", + "search_user_to_block": "ブロックしたいユーザーを検索", + "search_user_to_mute": "ミュートしたいユーザーを検索", "security_tab": "セキュリティ", "scope_copy": "返信するとき、公開範囲をコピーする (DMの公開範囲は、常にコピーされます)", + "minimal_scopes_mode": "公開範囲選択オプションを最小にする", "set_new_avatar": "新しいアバターを設定する", "set_new_profile_background": "新しいプロフィールのバックグラウンドを設定する", "set_new_profile_banner": "新しいプロフィールバナーを設定する", @@ -210,12 +272,20 @@ "theme_help_v2_1": "チェックボックスをONにすると、コンポーネントごとに、色と透明度をオーバーライドできます。「すべてクリア」ボタンを押すと、すべてのオーバーライドをやめます。", "theme_help_v2_2": "バックグラウンドとテキストのコントラストを表すアイコンがあります。マウスをホバーすると、詳しい説明が出ます。透明な色を使っているときは、最悪の場合のコントラストが示されます。", "tooltipRadius": "ツールチップとアラート", + "upload_a_photo": "画像をアップロード", "user_settings": "ユーザー設定", "values": { "false": "いいえ", "true": "はい" }, "notifications": "通知", + "notification_setting": "通知を受け取る:", + "notification_setting_follows": "あなたがフォローしているユーザーから", + "notification_setting_non_follows": "あなたがフォローしていないユーザーから", + "notification_setting_followers": "あなたをフォローしているユーザーから", + "notification_setting_non_followers": "あなたをフォローしていないユーザーから", + "notification_mutes": "特定のユーザーからの通知を止めるには、ミュートしてください。", + "notification_blocks": "ブロックしているユーザーからの通知は、すべて止まります。", "enable_web_push_notifications": "ウェブプッシュ通知を許可する", "style": { "switcher": { @@ -325,6 +395,11 @@ "checkbox": "利用規約を読みました", "link": "ハイパーリンク" } + }, + "version": { + "title": "バージョン", + "backend_version": "バックエンドのバージョン", + "frontend_version": "フロントエンドのバージョン" } }, "timeline": { @@ -336,7 +411,19 @@ "repeated": "リピート", "show_new": "読み込み", "up_to_date": "最新", - "no_more_statuses": "これで終わりです" + "no_more_statuses": "これで終わりです", + "no_statuses": "ステータスはありません" + }, + "status": { + "favorites": "お気に入り", + "repeats": "リピート", + "delete": "ステータスを削除", + "pin": "プロフィールにピン留め", + "unpin": "プロフィールのピン留めを外す", + "pinned": "ピン留め", + "delete_confirm": "本当にこのステータスを削除してもよろしいですか?", + "reply_to": "返信", + "replies_list": "返信:" }, "user_card": { "approve": "受け入れ", @@ -359,10 +446,47 @@ "muted": "ミュートしています!", "per_day": "/日", "remote_follow": "リモートフォロー", - "statuses": "ステータス" + "report": "通報", + "statuses": "ステータス", + "unblock": "ブロック解除", + "unblock_progress": "ブロックを解除しています...", + "block_progress": "ブロックしています...", + "unmute": "ミュート解除", + "unmute_progress": "ミュートを解除しています...", + "mute_progress": "ミュートしています...", + "admin_menu": { + "moderation": "モデレーション", + "grant_admin": "管理者権限を付与", + "revoke_admin": "管理者権限を解除", + "grant_moderator": "モデレーター権限を付与", + "revoke_moderator": "モデレーター権限を解除", + "activate_account": "アカウントをアクティブにする", + "deactivate_account": "アカウントをアクティブでなくする", + "delete_account": "アカウントを削除", + "force_nsfw": "すべての投稿をNSFWにする", + "strip_media": "投稿からメディアを除去する", + "force_unlisted": "投稿を未収載にする", + "sandbox": "投稿をフォロワーのみにする", + "disable_remote_subscription": "他のインスタンスからフォローされないようにする", + "disable_any_subscription": "フォローされないようにする", + "quarantine": "他のインスタンスからの投稿を止める", + "delete_user": "ユーザーを削除", + "delete_user_confirmation": "あなたの精神状態に何か問題はございませんか? この操作を取り消すことはできません。" + } }, "user_profile": { - "timeline_title": "ユーザータイムライン" + "timeline_title": "ユーザータイムライン", + "profile_does_not_exist": "申し訳ない。このプロフィールは存在しません。", + "profile_loading_error": "申し訳ない。プロフィールの読み込みがエラーになりました。" + }, + "user_reporting": { + "title": "通報する: {0}", + "add_comment_description": "この通報は、あなたのインスタンスのモデレーターに送られます。このアカウントを通報する理由を説明することができます:", + "additional_comments": "追加のコメント", + "forward_description": "このアカウントは他のサーバーに置かれています。この通報のコピーをリモートのサーバーに送りますか?", + "forward_to": "転送する: {0}", + "submit": "送信", + "generic_error": "あなたのリクエストを処理しようとしましたが、エラーになりました。" }, "who_to_follow": { "more": "詳細", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 7a22194a..d24ef0cb 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -9,7 +9,11 @@ "general": { "apply": "Применить", "submit": "Отправить", - "cancel": "Отмена" + "cancel": "Отмена", + "disable": "Оключить", + "enable": "Включить", + "confirm": "Подтвердить", + "verify": "Проверить" }, "login": { "login": "Войти", @@ -17,7 +21,15 @@ "password": "Пароль", "placeholder": "e.c. lain", "register": "Зарегистрироваться", - "username": "Имя пользователя" + "username": "Имя пользователя", + "authentication_code": "Код аутентификации", + "enter_recovery_code": "Ввести код восстановления", + "enter_two_factor_code": "Ввести код аутентификации", + "recovery_code": "Код восстановления", + "heading" : { + "TotpForm" : "Двухфакторная аутентификация", + "RecoveryForm" : "Two-factor recovery" + } }, "nav": { "back": "Назад", @@ -79,6 +91,28 @@ } }, "settings": { + "enter_current_password_to_confirm": "Введите свой текущий пароль", + "mfa": { + "otp" : "OTP", + "setup_otp" : "Настройка OTP", + "wait_pre_setup_otp" : "предварительная настройка OTP", + "confirm_and_enable" : "Подтвердить и включить OTP", + "title": "Двухфакторная аутентификация", + "generate_new_recovery_codes" : "Получить новые коды востановления", + "warning_of_generate_new_codes" : "После получения новых кодов восстановления, старые больше не будут работать.", + "recovery_codes" : "Коды восстановления.", + "waiting_a_recovery_codes": "Получение кодов восстановления ...", + "recovery_codes_warning" : "Запишите эти коды и держите в безопасном месте - иначе вы их больше не увидите. Если вы потеряете доступ к OTP приложению - без резервных кодов вы больше не сможете залогиниться.", + "authentication_methods" : "Методы аутентификации", + "scan": { + "title": "Сканирование", + "desc": "Используйте приложение для двухэтапной аутентификации для сканирования этого QR-код или введите текстовый ключ:", + "secret_code": "Ключ" + }, + "verify": { + "desc": "Чтобы включить двухэтапную аутентификации, введите код из вашего приложение для двухэтапной аутентификации:" + } + }, "attachmentRadius": "Прикреплённые файлы", "attachments": "Вложения", "autoload": "Включить автоматическую загрузку при прокрутке вниз", diff --git a/src/main.js b/src/main.js index 92f843b1..5758c7bd 100644 --- a/src/main.js +++ b/src/main.js @@ -10,6 +10,7 @@ import apiModule from './modules/api.js' import configModule from './modules/config.js' import chatModule from './modules/chat.js' import oauthModule from './modules/oauth.js' +import authFlowModule from './modules/auth_flow.js' import mediaViewerModule from './modules/media_viewer.js' import oauthTokensModule from './modules/oauth_tokens.js' import reportsModule from './modules/reports.js' @@ -24,6 +25,7 @@ import messages from './i18n/messages.js' import VueChatScroll from 'vue-chat-scroll' import VueClickOutside from 'v-click-outside' +import PortalVue from 'portal-vue' import afterStoreSetup from './boot/after_store.js' @@ -42,6 +44,7 @@ Vue.use(VueTimeago, { Vue.use(VueI18n) Vue.use(VueChatScroll) Vue.use(VueClickOutside) +Vue.use(PortalVue) const i18n = new VueI18n({ // By default, use the browser locale, we will update it if neccessary @@ -75,6 +78,7 @@ const persistedStateOptions = { config: configModule, chat: chatModule, oauth: oauthModule, + authFlow: authFlowModule, mediaViewer: mediaViewerModule, oauthTokens: oauthTokensModule, reports: reportsModule diff --git a/src/modules/auth_flow.js b/src/modules/auth_flow.js new file mode 100644 index 00000000..86328cf3 --- /dev/null +++ b/src/modules/auth_flow.js @@ -0,0 +1,89 @@ +const PASSWORD_STRATEGY = 'password' +const TOKEN_STRATEGY = 'token' + +// MFA strategies +const TOTP_STRATEGY = 'totp' +const RECOVERY_STRATEGY = 'recovery' + +// initial state +const state = { + app: null, + settings: {}, + strategy: PASSWORD_STRATEGY, + initStrategy: PASSWORD_STRATEGY // default strategy from config +} + +const resetState = (state) => { + state.strategy = state.initStrategy + state.settings = {} + state.app = null +} + +// getters +const getters = { + app: (state, getters) => { + return state.app + }, + settings: (state, getters) => { + return state.settings + }, + requiredPassword: (state, getters, rootState) => { + return state.strategy === PASSWORD_STRATEGY + }, + requiredToken: (state, getters, rootState) => { + return state.strategy === TOKEN_STRATEGY + }, + requiredTOTP: (state, getters, rootState) => { + return state.strategy === TOTP_STRATEGY + }, + requiredRecovery: (state, getters, rootState) => { + return state.strategy === RECOVERY_STRATEGY + } +} + +// mutations +const mutations = { + setInitialStrategy (state, strategy) { + if (strategy) { + state.initStrategy = strategy + state.strategy = strategy + } + }, + requirePassword (state) { + state.strategy = PASSWORD_STRATEGY + }, + requireToken (state) { + state.strategy = TOKEN_STRATEGY + }, + requireMFA (state, {app, settings}) { + state.settings = settings + state.app = app + state.strategy = TOTP_STRATEGY // default strategy of MFA + }, + requireRecovery (state) { + state.strategy = RECOVERY_STRATEGY + }, + requireTOTP (state) { + state.strategy = TOTP_STRATEGY + }, + abortMFA (state) { + resetState(state) + } +} + +// actions +const actions = { + async login ({state, dispatch, commit}, {access_token}) { + commit('setToken', access_token, { root: true }) + await dispatch('loginUser', access_token, { root: true }) + resetState(state) + } +} + +export default { + namespaced: true, + state, + getters, + mutations, + actions +} diff --git a/src/modules/instance.js b/src/modules/instance.js index d4185f6a..59beb23c 100644 --- a/src/modules/instance.js +++ b/src/modules/instance.js @@ -16,7 +16,6 @@ const defaultState = { redirectRootNoLogin: '/main/all', redirectRootLogin: '/main/friends', showInstanceSpecificPanel: false, - formattingOptionsEnabled: false, alwaysShowSubjectInput: true, hideMutedPosts: false, collapseMessageWithSubject: false, @@ -27,7 +26,6 @@ const defaultState = { scopeCopy: true, subjectLineBehavior: 'email', postContentType: 'text/plain', - loginMethod: 'password', nsfwCensorImage: undefined, vapidPublicKey: undefined, noAttachmentLinks: false, diff --git a/src/modules/oauth.js b/src/modules/oauth.js index 144ff830..11cb10fe 100644 --- a/src/modules/oauth.js +++ b/src/modules/oauth.js @@ -1,16 +1,39 @@ const oauth = { state: { - client_id: false, - client_secret: false, - token: false + clientId: false, + clientSecret: false, + /* App token is authentication for app without any user, used mostly for + * MastoAPI's registration of new users, stored so that we can fall back to + * it on logout + */ + appToken: false, + /* User token is authentication for app with user, this is for every calls + * that need authorized user to be successful (i.e. posting, liking etc) + */ + userToken: false }, mutations: { - setClientData (state, data) { - state.client_id = data.client_id - state.client_secret = data.client_secret + setClientData (state, { clientId, clientSecret }) { + state.clientId = clientId + state.clientSecret = clientSecret + }, + setAppToken (state, token) { + state.appToken = token }, setToken (state, token) { - state.token = token + state.userToken = token + } + }, + getters: { + getToken: state => () => { + // state.token is userToken with older name, coming from persistent state + // added here for smoother transition, otherwise user will be logged out + return state.userToken || state.token || state.appToken + }, + getUserToken: state => () => { + // state.token is userToken with older name, coming from persistent state + // added here for smoother transition, otherwise user will be logged out + return state.userToken || state.token } } } diff --git a/src/modules/users.js b/src/modules/users.js index e72a657c..22340271 100644 --- a/src/modules/users.js +++ b/src/modules/users.js @@ -3,7 +3,6 @@ import userSearchApi from '../services/new_api/user_search.js' import { compact, map, each, merge, last, concat, uniq } from 'lodash' import { set } from 'vue' import { registerPushNotifications, unregisterPushNotifications } from '../services/push/push.js' -import oauthApi from '../services/new_api/oauth' import { humanizeErrors } from './errors' // TODO: Unify with mergeOrAdd in statuses.js @@ -357,7 +356,13 @@ const users = { }, searchUsers (store, query) { // TODO: Move userSearch api into api.service - return userSearchApi.search({query, store: { state: store.rootState }}) + return userSearchApi.search({ + query, + store: { + state: store.rootState, + getters: store.rootGetters + } + }) .then((users) => { store.commit('addNewUsers', users) return users @@ -368,31 +373,21 @@ const users = { let rootState = store.rootState - let response = await rootState.api.backendInteractor.register(userInfo) - if (response.ok) { - const data = { - oauth: rootState.oauth, - instance: rootState.instance.server - } - let app = await oauthApi.getOrCreateApp(data) - let result = await oauthApi.getTokenWithCredentials({ - app, - instance: data.instance, - username: userInfo.username, - password: userInfo.password - }) + try { + let data = await rootState.api.backendInteractor.register(userInfo) store.commit('signUpSuccess') - store.commit('setToken', result.access_token) - store.dispatch('loginUser', result.access_token) - } else { - const data = await response.json() - let errors = JSON.parse(data.error) + store.commit('setToken', data.access_token) + store.dispatch('loginUser', data.access_token) + } catch (e) { + let errors = e.message // replace ap_id with username - if (errors.ap_id) { - errors.username = errors.ap_id - delete errors.ap_id + if (typeof errors === 'object') { + if (errors.ap_id) { + errors.username = errors.ap_id + delete errors.ap_id + } + errors = humanizeErrors(errors) } - errors = humanizeErrors(errors) store.commit('signUpFailure', errors) throw Error(errors) } @@ -406,7 +401,7 @@ const users = { store.dispatch('disconnectFromChat') store.commit('setToken', false) store.dispatch('stopFetching', 'friends') - store.commit('setBackendInteractor', backendInteractorService()) + store.commit('setBackendInteractor', backendInteractorService(store.getters.getToken())) store.dispatch('stopFetching', 'notifications') store.commit('clearNotifications') store.commit('resetStatuses') diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js index d6904d30..ab1fc0b2 100644 --- a/src/services/api/api.service.js +++ b/src/services/api/api.service.js @@ -1,7 +1,4 @@ /* eslint-env browser */ -const LOGIN_URL = '/api/account/verify_credentials.json' -const REGISTRATION_URL = '/api/account/register.json' -const BG_UPDATE_URL = '/api/qvitter/update_background_image.json' const EXTERNAL_PROFILE_URL = '/api/externalprofile/show.json' const QVITTER_USER_NOTIFICATIONS_READ_URL = '/api/qvitter/statuses/notifications/read.json' const BLOCKS_IMPORT_URL = '/api/pleroma/blocks_import' @@ -18,6 +15,15 @@ const ADMIN_USERS_URL = '/api/pleroma/admin/users' const SUGGESTIONS_URL = '/api/v1/suggestions' const NOTIFICATION_SETTINGS_URL = '/api/pleroma/notification_settings' +const MFA_SETTINGS_URL = '/api/pleroma/profile/mfa' +const MFA_BACKUP_CODES_URL = '/api/pleroma/profile/mfa/backup_codes' + +const MFA_SETUP_OTP_URL = '/api/pleroma/profile/mfa/setup/totp' +const MFA_CONFIRM_OTP_URL = '/api/pleroma/profile/mfa/confirm/totp' +const MFA_DISABLE_OTP_URL = '/api/pleroma/profile/mfa/totp' + +const MASTODON_LOGIN_URL = '/api/v1/accounts/verify_credentials' +const MASTODON_REGISTRATION_URL = '/api/v1/accounts' const MASTODON_USER_FAVORITES_TIMELINE_URL = '/api/v1/favourites' const MASTODON_USER_NOTIFICATIONS_URL = '/api/v1/notifications' const MASTODON_FAVORITE_URL = id => `/api/v1/statuses/${id}/favourite` @@ -125,22 +131,16 @@ const updateAvatar = ({credentials, avatar}) => { .then((data) => parseUser(data)) } -const updateBg = ({credentials, params}) => { - let url = BG_UPDATE_URL - +const updateBg = ({ credentials, background }) => { const form = new FormData() - - each(params, (value, key) => { - if (value) { - form.append(key, value) - } - }) - - return fetch(url, { + form.append('pleroma_background_image', background) + return fetch(MASTODON_PROFILE_UPDATE_URL, { headers: authHeaders(credentials), - method: 'POST', + method: 'PATCH', body: form - }).then((data) => data.json()) + }) + .then((data) => data.json()) + .then((data) => parseUser(data)) } const updateBanner = ({credentials, banner}) => { @@ -177,19 +177,29 @@ const updateProfile = ({credentials, params}) => { // homepage // location // token -const register = (params) => { - const form = new FormData() - - each(params, (value, key) => { - if (value) { - form.append(key, value) - } - }) - - return fetch(REGISTRATION_URL, { +const register = ({ params, credentials }) => { + const { nickname, ...rest } = params + return fetch(MASTODON_REGISTRATION_URL, { method: 'POST', - body: form + headers: { + ...authHeaders(credentials), + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + nickname, + locale: 'en_US', + agreement: true, + ...rest + }) }) + .then((response) => [response.ok, response]) + .then(([ok, response]) => { + if (ok) { + return response.json() + } else { + return response.json().then((error) => { throw new Error(error) }) + } + }) } const getCaptcha = () => fetch('/api/pleroma/captcha').then(resp => resp.json()) @@ -513,8 +523,7 @@ const fetchPinnedStatuses = ({ id, credentials }) => { } const verifyCredentials = (user) => { - return fetch(LOGIN_URL, { - method: 'POST', + return fetch(MASTODON_LOGIN_URL, { headers: authHeaders(user) }) .then((response) => { @@ -649,6 +658,51 @@ const changePassword = ({credentials, password, newPassword, newPasswordConfirma .then((response) => response.json()) } +const settingsMFA = ({credentials}) => { + return fetch(MFA_SETTINGS_URL, { + headers: authHeaders(credentials), + method: 'GET' + }).then((data) => data.json()) +} + +const mfaDisableOTP = ({credentials, password}) => { + const form = new FormData() + + form.append('password', password) + + return fetch(MFA_DISABLE_OTP_URL, { + body: form, + method: 'DELETE', + headers: authHeaders(credentials) + }) + .then((response) => response.json()) +} + +const mfaConfirmOTP = ({credentials, password, token}) => { + const form = new FormData() + + form.append('password', password) + form.append('code', token) + + return fetch(MFA_CONFIRM_OTP_URL, { + body: form, + headers: authHeaders(credentials), + method: 'POST' + }).then((data) => data.json()) +} +const mfaSetupOTP = ({credentials}) => { + return fetch(MFA_SETUP_OTP_URL, { + headers: authHeaders(credentials), + method: 'GET' + }).then((data) => data.json()) +} +const generateMfaBackupCodes = ({credentials}) => { + return fetch(MFA_BACKUP_CODES_URL, { + headers: authHeaders(credentials), + method: 'GET' + }).then((data) => data.json()) +} + const fetchMutes = ({credentials}) => { return promisedRequest({ url: MASTODON_USER_MUTES_URL, credentials }) .then((users) => users.map(parseUser)) @@ -776,6 +830,11 @@ const apiService = { importFollows, deleteAccount, changePassword, + settingsMFA, + mfaDisableOTP, + generateMfaBackupCodes, + mfaSetupOTP, + mfaConfirmOTP, fetchFollowRequests, approveUser, denyUser, diff --git a/src/services/backend_interactor_service/backend_interactor_service.js b/src/services/backend_interactor_service/backend_interactor_service.js index 36152429..8614a0f2 100644 --- a/src/services/backend_interactor_service/backend_interactor_service.js +++ b/src/services/backend_interactor_service/backend_interactor_service.js @@ -103,9 +103,9 @@ const backendInteractorService = (credentials) => { const unpinOwnStatus = (id) => apiService.unpinOwnStatus({credentials, id}) const getCaptcha = () => apiService.getCaptcha() - const register = (params) => apiService.register(params) + const register = (params) => apiService.register({ credentials, params }) const updateAvatar = ({avatar}) => apiService.updateAvatar({credentials, avatar}) - const updateBg = ({params}) => apiService.updateBg({credentials, params}) + const updateBg = ({ background }) => apiService.updateBg({ credentials, background }) const updateBanner = ({banner}) => apiService.updateBanner({credentials, banner}) const updateProfile = ({params}) => apiService.updateProfile({credentials, params}) @@ -116,6 +116,12 @@ const backendInteractorService = (credentials) => { const deleteAccount = ({password}) => apiService.deleteAccount({credentials, password}) const changePassword = ({password, newPassword, newPasswordConfirmation}) => apiService.changePassword({credentials, password, newPassword, newPasswordConfirmation}) + const fetchSettingsMFA = () => apiService.settingsMFA({credentials}) + const generateMfaBackupCodes = () => apiService.generateMfaBackupCodes({credentials}) + const mfaSetupOTP = () => apiService.mfaSetupOTP({credentials}) + const mfaConfirmOTP = ({password, token}) => apiService.mfaConfirmOTP({credentials, password, token}) + const mfaDisableOTP = ({password}) => apiService.mfaDisableOTP({credentials, password}) + const fetchFavoritedByUsers = (id) => apiService.fetchFavoritedByUsers({id}) const fetchRebloggedByUsers = (id) => apiService.fetchRebloggedByUsers({id}) const reportUser = (params) => apiService.reportUser({credentials, ...params}) @@ -166,6 +172,11 @@ const backendInteractorService = (credentials) => { importFollows, deleteAccount, changePassword, + fetchSettingsMFA, + generateMfaBackupCodes, + mfaSetupOTP, + mfaConfirmOTP, + mfaDisableOTP, fetchFollowRequests, approveUser, denyUser, diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js index 1f03058d..cdce1538 100644 --- a/src/services/entity_normalizer/entity_normalizer.service.js +++ b/src/services/entity_normalizer/entity_normalizer.service.js @@ -60,6 +60,9 @@ export const parseUser = (data) => { if (data.pleroma) { const relationship = data.pleroma.relationship + output.background_image = data.pleroma.background_image + output.token = data.pleroma.chat_token + if (relationship) { output.follows_you = relationship.followed_by output.following = relationship.following @@ -71,6 +74,23 @@ export const parseUser = (data) => { moderator: data.pleroma.is_moderator, admin: data.pleroma.is_admin } + // TODO: Clean up in UI? This is duplication from what BE does for qvitterapi + if (output.rights.admin) { + output.role = 'admin' + } else if (output.rights.moderator) { + output.role = 'moderator' + } else { + output.role = 'member' + } + } + + if (data.source) { + output.description = data.source.note + output.default_scope = data.source.privacy + if (data.source.pleroma) { + output.no_rich_text = data.source.pleroma.no_rich_text + output.show_role = data.source.pleroma.show_role + } } // TODO: handle is_local @@ -105,8 +125,6 @@ export const parseUser = (data) => { output.muted = data.muted - // QVITTER ONLY FOR NOW - // Really only applies to logged in user, really.. I THINK if (data.rights) { output.rights = { moderator: data.rights.delete_others_notice, diff --git a/src/services/new_api/mfa.js b/src/services/new_api/mfa.js new file mode 100644 index 00000000..ddf90e6b --- /dev/null +++ b/src/services/new_api/mfa.js @@ -0,0 +1,38 @@ +const verifyOTPCode = ({app, instance, mfaToken, code}) => { + const url = `${instance}/oauth/mfa/challenge` + const form = new window.FormData() + + form.append('client_id', app.client_id) + form.append('client_secret', app.client_secret) + form.append('mfa_token', mfaToken) + form.append('code', code) + form.append('challenge_type', 'totp') + + return window.fetch(url, { + method: 'POST', + body: form + }).then((data) => data.json()) +} + +const verifyRecoveryCode = ({app, instance, mfaToken, code}) => { + const url = `${instance}/oauth/mfa/challenge` + const form = new window.FormData() + + form.append('client_id', app.client_id) + form.append('client_secret', app.client_secret) + form.append('mfa_token', mfaToken) + form.append('code', code) + form.append('challenge_type', 'recovery') + + return window.fetch(url, { + method: 'POST', + body: form + }).then((data) => data.json()) +} + +const mfa = { + verifyOTPCode, + verifyRecoveryCode +} + +export default mfa diff --git a/src/services/new_api/oauth.js b/src/services/new_api/oauth.js index 9e656507..030e9980 100644 --- a/src/services/new_api/oauth.js +++ b/src/services/new_api/oauth.js @@ -1,51 +1,57 @@ -import {reduce} from 'lodash' +import { reduce } from 'lodash' + +const REDIRECT_URI = `${window.location.origin}/oauth-callback` + +export const getOrCreateApp = ({ clientId, clientSecret, instance, commit }) => { + if (clientId && clientSecret) { + return Promise.resolve({ clientId, clientSecret }) + } -const getOrCreateApp = ({oauth, instance}) => { const url = `${instance}/api/v1/apps` const form = new window.FormData() - form.append('client_name', `PleromaFE_${Math.random()}`) - form.append('redirect_uris', `${window.location.origin}/oauth-callback`) + form.append('client_name', `PleromaFE_${window.___pleromafe_commit_hash}_${(new Date()).toISOString()}`) + form.append('redirect_uris', REDIRECT_URI) form.append('scopes', 'read write follow') return window.fetch(url, { method: 'POST', body: form - }).then((data) => data.json()) + }) + .then((data) => data.json()) + .then((app) => ({ clientId: app.client_id, clientSecret: app.client_secret })) + .then((app) => commit('setClientData', app) || app) } -const login = (args) => { - getOrCreateApp(args).then((app) => { - args.commit('setClientData', app) - - const data = { - response_type: 'code', - client_id: app.client_id, - redirect_uri: app.redirect_uri, - scope: 'read write follow' - } - const dataString = reduce(data, (acc, v, k) => { - const encoded = `${k}=${encodeURIComponent(v)}` - if (!acc) { - return encoded - } else { - return `${acc}&${encoded}` - } - }, false) +const login = ({ instance, clientId }) => { + const data = { + response_type: 'code', + client_id: clientId, + redirect_uri: REDIRECT_URI, + scope: 'read write follow' + } + + const dataString = reduce(data, (acc, v, k) => { + const encoded = `${k}=${encodeURIComponent(v)}` + if (!acc) { + return encoded + } else { + return `${acc}&${encoded}` + } + }, false) - // Do the redirect... - const url = `${args.instance}/oauth/authorize?${dataString}` + // Do the redirect... + const url = `${instance}/oauth/authorize?${dataString}` - window.location.href = url - }) + window.location.href = url } -const getTokenWithCredentials = ({app, instance, username, password}) => { +const getTokenWithCredentials = ({ clientId, clientSecret, instance, username, password }) => { const url = `${instance}/oauth/token` const form = new window.FormData() - form.append('client_id', app.client_id) - form.append('client_secret', app.client_secret) + form.append('client_id', clientId) + form.append('client_secret', clientSecret) form.append('grant_type', 'password') form.append('username', username) form.append('password', password) @@ -56,12 +62,12 @@ const getTokenWithCredentials = ({app, instance, username, password}) => { }).then((data) => data.json()) } -const getToken = ({app, instance, code}) => { +const getToken = ({ clientId, clientSecret, instance, code }) => { const url = `${instance}/oauth/token` const form = new window.FormData() - form.append('client_id', app.client_id) - form.append('client_secret', app.client_secret) + form.append('client_id', clientId) + form.append('client_secret', clientSecret) form.append('grant_type', 'authorization_code') form.append('code', code) form.append('redirect_uri', `${window.location.origin}/oauth-callback`) @@ -69,6 +75,53 @@ const getToken = ({app, instance, code}) => { return window.fetch(url, { method: 'POST', body: form + }) + .then((data) => data.json()) +} + +export const getClientToken = ({ clientId, clientSecret, instance }) => { + const url = `${instance}/oauth/token` + const form = new window.FormData() + + form.append('client_id', clientId) + form.append('client_secret', clientSecret) + form.append('grant_type', 'client_credentials') + form.append('redirect_uri', `${window.location.origin}/oauth-callback`) + + return window.fetch(url, { + method: 'POST', + body: form + }).then((data) => data.json()) +} +const verifyOTPCode = ({app, instance, mfaToken, code}) => { + const url = `${instance}/oauth/mfa/challenge` + const form = new window.FormData() + + form.append('client_id', app.client_id) + form.append('client_secret', app.client_secret) + form.append('mfa_token', mfaToken) + form.append('code', code) + form.append('challenge_type', 'totp') + + return window.fetch(url, { + method: 'POST', + body: form + }).then((data) => data.json()) +} + +const verifyRecoveryCode = ({app, instance, mfaToken, code}) => { + const url = `${instance}/oauth/mfa/challenge` + const form = new window.FormData() + + form.append('client_id', app.client_id) + form.append('client_secret', app.client_secret) + form.append('mfa_token', mfaToken) + form.append('code', code) + form.append('challenge_type', 'recovery') + + return window.fetch(url, { + method: 'POST', + body: form }).then((data) => data.json()) } @@ -76,7 +129,9 @@ const oauth = { login, getToken, getTokenWithCredentials, - getOrCreateApp + getOrCreateApp, + verifyOTPCode, + verifyRecoveryCode } export default oauth diff --git a/src/services/new_api/utils.js b/src/services/new_api/utils.js index 078f392f..6696573b 100644 --- a/src/services/new_api/utils.js +++ b/src/services/new_api/utils.js @@ -5,9 +5,9 @@ const queryParams = (params) => { } const headers = (store) => { - const accessToken = store.state.oauth.token + const accessToken = store.getters.getToken() if (accessToken) { - return {'Authorization': `Bearer ${accessToken}`} + return { 'Authorization': `Bearer ${accessToken}` } } else { return {} } |
