Merge branch 'feature/2fa' into 'develop'
adds 2FA/two_factor_authentication support See merge request pleroma/pleroma-fe!556
This commit is contained in:
commit
e53f11c30f
@ -15,6 +15,7 @@
|
|||||||
"lint-fix": "eslint --fix --ext .js,.vue src test/unit/specs test/e2e/specs"
|
"lint-fix": "eslint --fix --ext .js,.vue src test/unit/specs test/e2e/specs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@chenfengyuan/vue-qrcode": "^1.0.0",
|
||||||
"babel-plugin-add-module-exports": "^0.2.1",
|
"babel-plugin-add-module-exports": "^0.2.1",
|
||||||
"babel-plugin-lodash": "^3.2.11",
|
"babel-plugin-lodash": "^3.2.11",
|
||||||
"chromatism": "^3.0.0",
|
"chromatism": "^3.0.0",
|
||||||
|
@ -92,6 +92,7 @@ const setSettings = async ({ apiConfig, staticConfig, store }) => {
|
|||||||
? 0
|
? 0
|
||||||
: config.logoMargin
|
: config.logoMargin
|
||||||
})
|
})
|
||||||
|
store.commit('authFlow/setInitialStrategy', config.loginMethod)
|
||||||
|
|
||||||
copyInstanceOption('redirectRootNoLogin')
|
copyInstanceOption('redirectRootNoLogin')
|
||||||
copyInstanceOption('redirectRootLogin')
|
copyInstanceOption('redirectRootLogin')
|
||||||
@ -100,7 +101,6 @@ const setSettings = async ({ apiConfig, staticConfig, store }) => {
|
|||||||
copyInstanceOption('formattingOptionsEnabled')
|
copyInstanceOption('formattingOptionsEnabled')
|
||||||
copyInstanceOption('hideMutedPosts')
|
copyInstanceOption('hideMutedPosts')
|
||||||
copyInstanceOption('collapseMessageWithSubject')
|
copyInstanceOption('collapseMessageWithSubject')
|
||||||
copyInstanceOption('loginMethod')
|
|
||||||
copyInstanceOption('scopeCopy')
|
copyInstanceOption('scopeCopy')
|
||||||
copyInstanceOption('subjectLineBehavior')
|
copyInstanceOption('subjectLineBehavior')
|
||||||
copyInstanceOption('postContentType')
|
copyInstanceOption('postContentType')
|
||||||
|
@ -13,7 +13,7 @@ import FollowRequests from 'components/follow_requests/follow_requests.vue'
|
|||||||
import OAuthCallback from 'components/oauth_callback/oauth_callback.vue'
|
import OAuthCallback from 'components/oauth_callback/oauth_callback.vue'
|
||||||
import UserSearch from 'components/user_search/user_search.vue'
|
import UserSearch from 'components/user_search/user_search.vue'
|
||||||
import Notifications from 'components/notifications/notifications.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 ChatPanel from 'components/chat_panel/chat_panel.vue'
|
||||||
import WhoToFollow from 'components/who_to_follow/who_to_follow.vue'
|
import WhoToFollow from 'components/who_to_follow/who_to_follow.vue'
|
||||||
import About from 'components/about/about.vue'
|
import About from 'components/about/about.vue'
|
||||||
@ -42,7 +42,7 @@ export default (store) => {
|
|||||||
{ name: 'friend-requests', path: '/friend-requests', component: FollowRequests },
|
{ name: 'friend-requests', path: '/friend-requests', component: FollowRequests },
|
||||||
{ name: 'user-settings', path: '/user-settings', component: UserSettings },
|
{ name: 'user-settings', path: '/user-settings', component: UserSettings },
|
||||||
{ name: 'notifications', path: '/:username/notifications', component: Notifications },
|
{ 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: 'chat', path: '/chat', component: ChatPanel, props: () => ({ floating: false }) },
|
||||||
{ name: 'oauth-callback', path: '/oauth-callback', component: OAuthCallback, props: (route) => ({ code: route.query.code }) },
|
{ 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 }) },
|
{ name: 'user-search', path: '/user-search', component: UserSearch, props: (route) => ({ query: route.query.query }) },
|
||||||
|
26
src/components/auth_form/auth_form.js
Normal file
26
src/components/auth_form/auth_form.js
Normal file
@ -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
|
@ -1,28 +1,44 @@
|
|||||||
|
import { mapState, mapGetters, mapActions, mapMutations } from 'vuex'
|
||||||
import oauthApi from '../../services/new_api/oauth.js'
|
import oauthApi from '../../services/new_api/oauth.js'
|
||||||
|
|
||||||
const LoginForm = {
|
const LoginForm = {
|
||||||
data: () => ({
|
data: () => ({
|
||||||
user: {},
|
user: {},
|
||||||
authError: false
|
error: false
|
||||||
}),
|
}),
|
||||||
computed: {
|
computed: {
|
||||||
loginMethod () { return this.$store.state.instance.loginMethod },
|
isPasswordAuth () { return this.requiredPassword },
|
||||||
loggingIn () { return this.$store.state.users.loggingIn },
|
isTokenAuth () { return this.requiredToken },
|
||||||
registrationOpen () { return this.$store.state.instance.registrationOpen }
|
...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: {
|
methods: {
|
||||||
oAuthLogin () {
|
...mapMutations('authFlow', ['requireMFA']),
|
||||||
|
...mapActions({ login: 'authFlow/login' }),
|
||||||
|
submit () {
|
||||||
|
this.isTokenMethod ? this.submitToken() : this.submitPassword()
|
||||||
|
},
|
||||||
|
submitToken () {
|
||||||
oauthApi.login({
|
oauthApi.login({
|
||||||
oauth: this.$store.state.oauth,
|
oauth: this.oauth,
|
||||||
instance: this.$store.state.instance.server,
|
instance: this.instance.server,
|
||||||
commit: this.$store.commit
|
commit: this.$store.commit
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
submit () {
|
submitPassword () {
|
||||||
const data = {
|
const data = {
|
||||||
oauth: this.$store.state.oauth,
|
oauth: this.oauth,
|
||||||
instance: this.$store.state.instance.server
|
instance: this.instance.server
|
||||||
}
|
}
|
||||||
this.clearError()
|
this.error = false
|
||||||
|
|
||||||
oauthApi.getOrCreateApp(data).then((app) => {
|
oauthApi.getOrCreateApp(data).then((app) => {
|
||||||
oauthApi.getTokenWithCredentials(
|
oauthApi.getTokenWithCredentials(
|
||||||
{
|
{
|
||||||
@ -31,24 +47,27 @@ const LoginForm = {
|
|||||||
username: this.user.username,
|
username: this.user.username,
|
||||||
password: this.user.password
|
password: this.user.password
|
||||||
}
|
}
|
||||||
).then(async (result) => {
|
).then((result) => {
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
this.authError = result.error
|
if (result.error === 'mfa_required') {
|
||||||
this.user.password = ''
|
this.requireMFA({app: app, settings: result})
|
||||||
|
} else {
|
||||||
|
this.error = result.error
|
||||||
|
this.focusOnPasswordInput()
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.$store.commit('setToken', result.access_token)
|
this.login(result).then(() => {
|
||||||
try {
|
|
||||||
await this.$store.dispatch('loginUser', result.access_token)
|
|
||||||
this.$router.push({name: 'friends'})
|
this.$router.push({name: 'friends'})
|
||||||
} catch (e) {
|
})
|
||||||
console.log(e)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
clearError () {
|
clearError () { this.error = false },
|
||||||
this.authError = false
|
focusOnPasswordInput () {
|
||||||
|
let passwordInput = this.$refs.passwordInput
|
||||||
|
passwordInput.focus()
|
||||||
|
passwordInput.setSelectionRange(0, passwordInput.value.length)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,47 +1,53 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="login panel panel-default">
|
<div class="login panel panel-default">
|
||||||
<!-- Default panel contents -->
|
<!-- Default panel contents -->
|
||||||
<div class="panel-heading">
|
|
||||||
{{$t('login.login')}}
|
<div class="panel-heading">{{$t('login.login')}}</div>
|
||||||
</div>
|
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
<form v-if="loginMethod == 'password'" v-on:submit.prevent='submit(user)' class='login-form'>
|
<form class='login-form' @submit.prevent='submit'>
|
||||||
|
<template v-if="isPasswordAuth">
|
||||||
<div class='form-group'>
|
<div class='form-group'>
|
||||||
<label for='username'>{{$t('login.username')}}</label>
|
<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>
|
||||||
<div class='form-group'>
|
<div class='form-group'>
|
||||||
<label for='password'>{{$t('login.password')}}</label>
|
<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>
|
||||||
<div class='form-group'>
|
</template>
|
||||||
<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>
|
|
||||||
|
|
||||||
<form v-if="loginMethod == 'token'" v-on:submit.prevent='oAuthLogin' class="login-form">
|
<div class="form-group" v-if="isTokenAuth">
|
||||||
<div class="form-group">
|
<p>{{$t('login.description')}}</p>
|
||||||
<p>{{$t('login.description')}}</p>
|
</div>
|
||||||
</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>
|
|
||||||
|
|
||||||
<div v-if="authError" class='form-group'>
|
<div class='form-group'>
|
||||||
<div class='alert error'>
|
<div class='login-bottom'>
|
||||||
{{authError}}
|
<div>
|
||||||
<i class="button-icon icon-cancel" @click="clearError"></i>
|
<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>
|
||||||
</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>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script src="./login_form.js" ></script>
|
<script src="./login_form.js" ></script>
|
||||||
|
41
src/components/mfa_form/recovery_form.js
Normal file
41
src/components/mfa_form/recovery_form.js
Normal file
@ -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'})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
42
src/components/mfa_form/recovery_form.vue
Normal file
42
src/components/mfa_form/recovery_form.vue
Normal file
@ -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>
|
40
src/components/mfa_form/totp_form.js
Normal file
40
src/components/mfa_form/totp_form.js
Normal file
@ -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'})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
45
src/components/mfa_form/totp_form.vue
Normal file
45
src/components/mfa_form/totp_form.vue
Normal file
@ -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>
|
@ -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 PostStatusForm from '../post_status_form/post_status_form.vue'
|
||||||
import UserCard from '../user_card/user_card.vue'
|
import UserCard from '../user_card/user_card.vue'
|
||||||
|
import { mapState } from 'vuex'
|
||||||
|
|
||||||
const UserPanel = {
|
const UserPanel = {
|
||||||
computed: {
|
computed: {
|
||||||
user () { return this.$store.state.users.currentUser }
|
signedIn () { return this.user },
|
||||||
|
...mapState({ user: state => state.users.currentUser })
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
LoginForm,
|
AuthForm,
|
||||||
PostStatusForm,
|
PostStatusForm,
|
||||||
UserCard
|
UserCard
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="user-panel">
|
<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"/>
|
<UserCard :user="user" :hideBio="true" rounded="top"/>
|
||||||
<div class="panel-footer">
|
<div class="panel-footer">
|
||||||
<post-status-form v-if='user'></post-status-form>
|
<post-status-form v-if='user'></post-status-form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<login-form v-if='!user'></login-form>
|
<auth-form v-else key="user-panel"/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script src="./user_panel.js"></script>
|
<script src="./user_panel.js"></script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.user-panel .signed-in {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
9
src/components/user_settings/confirm.js
Normal file
9
src/components/user_settings/confirm.js
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
const Confirm = {
|
||||||
|
props: ['disabled'],
|
||||||
|
data: () => ({}),
|
||||||
|
methods: {
|
||||||
|
confirm () { this.$emit('confirm') },
|
||||||
|
cancel () { this.$emit('cancel') }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export default Confirm
|
14
src/components/user_settings/confirm.vue
Normal file
14
src/components/user_settings/confirm.vue
Normal file
@ -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>
|
152
src/components/user_settings/mfa.js
Normal file
152
src/components/user_settings/mfa.js
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
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
|
||||||
|
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()
|
||||||
|
this.settings = result.settings
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted () {
|
||||||
|
this.fetchSettings().then(() => {
|
||||||
|
this.readyInit = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export default Mfa
|
121
src/components/user_settings/mfa.vue
Normal file
121
src/components/user_settings/mfa.vue
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
<template>
|
||||||
|
<div class="setting-item mfa-settings" v-if="readyInit">
|
||||||
|
|
||||||
|
<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>
|
17
src/components/user_settings/mfa_backup_codes.js
Normal file
17
src/components/user_settings/mfa_backup_codes.js
Normal file
@ -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 }
|
||||||
|
}
|
||||||
|
}
|
22
src/components/user_settings/mfa_backup_codes.vue
Normal file
22
src/components/user_settings/mfa_backup_codes.vue
Normal file
@ -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>
|
49
src/components/user_settings/mfa_totp.js
Normal file
49
src/components/user_settings/mfa_totp.js
Normal file
@ -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')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
23
src/components/user_settings/mfa_totp.vue
Normal file
23
src/components/user_settings/mfa_totp.vue
Normal file
@ -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>
|
@ -17,6 +17,7 @@ import Importer from '../importer/importer.vue'
|
|||||||
import Exporter from '../exporter/exporter.vue'
|
import Exporter from '../exporter/exporter.vue'
|
||||||
import withSubscription from '../../hocs/with_subscription/with_subscription'
|
import withSubscription from '../../hocs/with_subscription/with_subscription'
|
||||||
import userSearchApi from '../../services/new_api/user_search.js'
|
import userSearchApi from '../../services/new_api/user_search.js'
|
||||||
|
import Mfa from './mfa.vue'
|
||||||
|
|
||||||
const BlockList = withSubscription({
|
const BlockList = withSubscription({
|
||||||
fetch: (props, $store) => $store.dispatch('fetchBlocks'),
|
fetch: (props, $store) => $store.dispatch('fetchBlocks'),
|
||||||
@ -75,7 +76,8 @@ const UserSettings = {
|
|||||||
MuteCard,
|
MuteCard,
|
||||||
ProgressButton,
|
ProgressButton,
|
||||||
Importer,
|
Importer,
|
||||||
Exporter
|
Exporter,
|
||||||
|
Mfa
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
user () {
|
user () {
|
||||||
|
@ -152,7 +152,7 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<mfa />
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<h2>{{$t('settings.delete_account')}}</h2>
|
<h2>{{$t('settings.delete_account')}}</h2>
|
||||||
<p v-if="!deletingAccount">{{$t('settings.delete_account_description')}}</p>
|
<p v-if="!deletingAccount">{{$t('settings.delete_account_description')}}</p>
|
||||||
|
@ -27,7 +27,11 @@
|
|||||||
"optional": "optional",
|
"optional": "optional",
|
||||||
"show_more": "Show more",
|
"show_more": "Show more",
|
||||||
"show_less": "Show less",
|
"show_less": "Show less",
|
||||||
"cancel": "Cancel"
|
"cancel": "Cancel",
|
||||||
|
"disable": "Disable",
|
||||||
|
"enable": "Enable",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"verify": "Verify"
|
||||||
},
|
},
|
||||||
"image_cropper": {
|
"image_cropper": {
|
||||||
"crop_picture": "Crop picture",
|
"crop_picture": "Crop picture",
|
||||||
@ -48,7 +52,15 @@
|
|||||||
"placeholder": "e.g. lain",
|
"placeholder": "e.g. lain",
|
||||||
"register": "Register",
|
"register": "Register",
|
||||||
"username": "Username",
|
"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": {
|
"media_modal": {
|
||||||
"previous": "Previous",
|
"previous": "Previous",
|
||||||
@ -138,6 +150,29 @@
|
|||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"app_name": "App name",
|
"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",
|
"attachmentRadius": "Attachments",
|
||||||
"attachments": "Attachments",
|
"attachments": "Attachments",
|
||||||
"autoload": "Enable automatic loading when scrolled to the bottom",
|
"autoload": "Enable automatic loading when scrolled to the bottom",
|
||||||
|
@ -9,7 +9,11 @@
|
|||||||
"general": {
|
"general": {
|
||||||
"apply": "Применить",
|
"apply": "Применить",
|
||||||
"submit": "Отправить",
|
"submit": "Отправить",
|
||||||
"cancel": "Отмена"
|
"cancel": "Отмена",
|
||||||
|
"disable": "Оключить",
|
||||||
|
"enable": "Включить",
|
||||||
|
"confirm": "Подтвердить",
|
||||||
|
"verify": "Проверить"
|
||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"login": "Войти",
|
"login": "Войти",
|
||||||
@ -17,7 +21,15 @@
|
|||||||
"password": "Пароль",
|
"password": "Пароль",
|
||||||
"placeholder": "e.c. lain",
|
"placeholder": "e.c. lain",
|
||||||
"register": "Зарегистрироваться",
|
"register": "Зарегистрироваться",
|
||||||
"username": "Имя пользователя"
|
"username": "Имя пользователя",
|
||||||
|
"authentication_code": "Код аутентификации",
|
||||||
|
"enter_recovery_code": "Ввести код восстановления",
|
||||||
|
"enter_two_factor_code": "Ввести код аутентификации",
|
||||||
|
"recovery_code": "Код восстановления",
|
||||||
|
"heading" : {
|
||||||
|
"TotpForm" : "Двухфакторная аутентификация",
|
||||||
|
"RecoveryForm" : "Two-factor recovery"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"back": "Назад",
|
"back": "Назад",
|
||||||
@ -79,6 +91,28 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"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": "Прикреплённые файлы",
|
"attachmentRadius": "Прикреплённые файлы",
|
||||||
"attachments": "Вложения",
|
"attachments": "Вложения",
|
||||||
"autoload": "Включить автоматическую загрузку при прокрутке вниз",
|
"autoload": "Включить автоматическую загрузку при прокрутке вниз",
|
||||||
|
@ -10,6 +10,7 @@ import apiModule from './modules/api.js'
|
|||||||
import configModule from './modules/config.js'
|
import configModule from './modules/config.js'
|
||||||
import chatModule from './modules/chat.js'
|
import chatModule from './modules/chat.js'
|
||||||
import oauthModule from './modules/oauth.js'
|
import oauthModule from './modules/oauth.js'
|
||||||
|
import authFlowModule from './modules/auth_flow.js'
|
||||||
import mediaViewerModule from './modules/media_viewer.js'
|
import mediaViewerModule from './modules/media_viewer.js'
|
||||||
import oauthTokensModule from './modules/oauth_tokens.js'
|
import oauthTokensModule from './modules/oauth_tokens.js'
|
||||||
import reportsModule from './modules/reports.js'
|
import reportsModule from './modules/reports.js'
|
||||||
@ -77,6 +78,7 @@ const persistedStateOptions = {
|
|||||||
config: configModule,
|
config: configModule,
|
||||||
chat: chatModule,
|
chat: chatModule,
|
||||||
oauth: oauthModule,
|
oauth: oauthModule,
|
||||||
|
authFlow: authFlowModule,
|
||||||
mediaViewer: mediaViewerModule,
|
mediaViewer: mediaViewerModule,
|
||||||
oauthTokens: oauthTokensModule,
|
oauthTokens: oauthTokensModule,
|
||||||
reports: reportsModule
|
reports: reportsModule
|
||||||
|
89
src/modules/auth_flow.js
Normal file
89
src/modules/auth_flow.js
Normal file
@ -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
|
||||||
|
}
|
@ -27,7 +27,6 @@ const defaultState = {
|
|||||||
scopeCopy: true,
|
scopeCopy: true,
|
||||||
subjectLineBehavior: 'email',
|
subjectLineBehavior: 'email',
|
||||||
postContentType: 'text/plain',
|
postContentType: 'text/plain',
|
||||||
loginMethod: 'password',
|
|
||||||
nsfwCensorImage: undefined,
|
nsfwCensorImage: undefined,
|
||||||
vapidPublicKey: undefined,
|
vapidPublicKey: undefined,
|
||||||
noAttachmentLinks: false,
|
noAttachmentLinks: false,
|
||||||
|
@ -17,6 +17,13 @@ const ADMIN_USERS_URL = '/api/pleroma/admin/users'
|
|||||||
const SUGGESTIONS_URL = '/api/v1/suggestions'
|
const SUGGESTIONS_URL = '/api/v1/suggestions'
|
||||||
const NOTIFICATION_SETTINGS_URL = '/api/pleroma/notification_settings'
|
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_LOGIN_URL = '/api/v1/accounts/verify_credentials'
|
||||||
const MASTODON_USER_FAVORITES_TIMELINE_URL = '/api/v1/favourites'
|
const MASTODON_USER_FAVORITES_TIMELINE_URL = '/api/v1/favourites'
|
||||||
const MASTODON_USER_NOTIFICATIONS_URL = '/api/v1/notifications'
|
const MASTODON_USER_NOTIFICATIONS_URL = '/api/v1/notifications'
|
||||||
@ -649,6 +656,51 @@ const changePassword = ({credentials, password, newPassword, newPasswordConfirma
|
|||||||
.then((response) => response.json())
|
.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}) => {
|
const fetchMutes = ({credentials}) => {
|
||||||
return promisedRequest({ url: MASTODON_USER_MUTES_URL, credentials })
|
return promisedRequest({ url: MASTODON_USER_MUTES_URL, credentials })
|
||||||
.then((users) => users.map(parseUser))
|
.then((users) => users.map(parseUser))
|
||||||
@ -776,6 +828,11 @@ const apiService = {
|
|||||||
importFollows,
|
importFollows,
|
||||||
deleteAccount,
|
deleteAccount,
|
||||||
changePassword,
|
changePassword,
|
||||||
|
settingsMFA,
|
||||||
|
mfaDisableOTP,
|
||||||
|
generateMfaBackupCodes,
|
||||||
|
mfaSetupOTP,
|
||||||
|
mfaConfirmOTP,
|
||||||
fetchFollowRequests,
|
fetchFollowRequests,
|
||||||
approveUser,
|
approveUser,
|
||||||
denyUser,
|
denyUser,
|
||||||
|
@ -116,6 +116,12 @@ const backendInteractorService = (credentials) => {
|
|||||||
const deleteAccount = ({password}) => apiService.deleteAccount({credentials, password})
|
const deleteAccount = ({password}) => apiService.deleteAccount({credentials, password})
|
||||||
const changePassword = ({password, newPassword, newPasswordConfirmation}) => apiService.changePassword({credentials, password, newPassword, newPasswordConfirmation})
|
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 fetchFavoritedByUsers = (id) => apiService.fetchFavoritedByUsers({id})
|
||||||
const fetchRebloggedByUsers = (id) => apiService.fetchRebloggedByUsers({id})
|
const fetchRebloggedByUsers = (id) => apiService.fetchRebloggedByUsers({id})
|
||||||
const reportUser = (params) => apiService.reportUser({credentials, ...params})
|
const reportUser = (params) => apiService.reportUser({credentials, ...params})
|
||||||
@ -166,6 +172,11 @@ const backendInteractorService = (credentials) => {
|
|||||||
importFollows,
|
importFollows,
|
||||||
deleteAccount,
|
deleteAccount,
|
||||||
changePassword,
|
changePassword,
|
||||||
|
fetchSettingsMFA,
|
||||||
|
generateMfaBackupCodes,
|
||||||
|
mfaSetupOTP,
|
||||||
|
mfaConfirmOTP,
|
||||||
|
mfaDisableOTP,
|
||||||
fetchFollowRequests,
|
fetchFollowRequests,
|
||||||
approveUser,
|
approveUser,
|
||||||
denyUser,
|
denyUser,
|
||||||
|
38
src/services/new_api/mfa.js
Normal file
38
src/services/new_api/mfa.js
Normal file
@ -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
|
@ -71,12 +71,45 @@ const getToken = ({app, instance, code}) => {
|
|||||||
body: form
|
body: form
|
||||||
}).then((data) => data.json())
|
}).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())
|
||||||
|
}
|
||||||
|
|
||||||
const oauth = {
|
const oauth = {
|
||||||
login,
|
login,
|
||||||
getToken,
|
getToken,
|
||||||
getTokenWithCredentials,
|
getTokenWithCredentials,
|
||||||
getOrCreateApp
|
getOrCreateApp,
|
||||||
|
verifyOTPCode,
|
||||||
|
verifyRecoveryCode
|
||||||
}
|
}
|
||||||
|
|
||||||
export default oauth
|
export default oauth
|
||||||
|
Loading…
Reference in New Issue
Block a user