Add OAuth Personal Access Token management

This commit is contained in:
Bubka
2021-10-29 17:12:58 +02:00
parent c9dee47be3
commit 55a47a75f4
14 changed files with 377 additions and 188 deletions

View File

@ -11,7 +11,7 @@
<a class="has-text-grey" href="https://github.com/Bubka/2FAuth"><b>2FAuth</b> <font-awesome-icon :icon="['fab', 'github-alt']" /></a> - v{{ appVersion }}
</div>
<div v-else class="content has-text-centered">
<router-link :to="{ name: 'settings' }" class="has-text-grey">{{ $t('settings.settings') }}</router-link> - <a class="has-text-grey" @click="logout">{{ $t('auth.sign_out') }}</a>
<router-link :to="{ name: 'settings.options' }" class="has-text-grey">{{ $t('settings.settings') }}</router-link> - <a class="has-text-grey" @click="logout">{{ $t('auth.sign_out') }}</a>
</div>
</footer>
</template>

View File

@ -0,0 +1,55 @@
<template>
<div class="options-header has-background-black-ter">
<div class="columns is-centered">
<div class="form-column column is-two-thirds-tablet is-half-desktop is-one-third-widescreen is-one-third-fullhd">
<div class="tabs is-centered is-fullwidth">
<ul>
<li v-for="tab in tabs" :key="tab.view" :class="{ 'is-active': tab.view === activeTab }">
<a @click="selectTab(tab.view)">{{ tab.name }}</a>
</li>
</ul>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'SettingTabs',
data(){
return {
tabs: [
{
'name' : this.$t('settings.options'),
'view' : 'settings.options'
},
{
'name' : this.$t('settings.account'),
'view' : 'settings.account'
},
{
'name' : this.$t('settings.oauth'),
'view' : 'settings.oauth'
},
]
}
},
props: {
activeTab: {
type: String,
default: ''
},
},
methods: {
selectTab(viewName) {
this.$router.push({ name: viewName })
},
}
}
</script>

View File

@ -11,6 +11,7 @@ import FormCheckbox from './FormCheckbox'
import FormButtons from './FormButtons'
import VueFooter from './Footer'
import Kicker from './Kicker'
import SettingTabs from './SettingTabs'
// Components that are registered globaly.
[
@ -25,7 +26,8 @@ import Kicker from './Kicker'
FormCheckbox,
FormButtons,
VueFooter,
Kicker
Kicker,
SettingTabs
].forEach(Component => {
Vue.component(Component.name, Component)
})

View File

@ -19,6 +19,13 @@ Vue.mixin({
this.$router.push({ name: 'login' })
},
exitSettings: function(event) {
if (event) {
this.$notify({ clean: true })
this.$router.push({ name: 'accounts' })
}
}
}
})

View File

@ -16,7 +16,10 @@ import Login from './views/auth/Login'
import Register from './views/auth/Register'
import PasswordRequest from './views/auth/password/Request'
import PasswordReset from './views/auth/password/Reset'
import Settings from './views/settings/Index'
import SettingsOptions from './views/settings/Options'
import SettingsAccount from './views/settings/Account'
import SettingsOAuth from './views/settings/OAuth'
import GeneratePAT from './views/settings/PATokens/Create'
import Errors from './views/Error'
const router = new Router({
@ -34,7 +37,10 @@ const router = new Router({
{ path: '/group/create', name: 'createGroup', component: CreateGroup, meta: { requiresAuth: true } },
{ path: '/group/:groupId/edit', name: 'editGroup', component: EditGroup, meta: { requiresAuth: true }, props: true },
{ path: '/settings', name: 'settings', component: Settings, meta: { requiresAuth: true } },
{ path: '/settings/options', name: 'settings.options', component: SettingsOptions, meta: { requiresAuth: true } },
{ path: '/settings/account', name: 'settings.account', component: SettingsAccount, meta: { requiresAuth: true } },
{ path: '/settings/oauth', name: 'settings.oauth', component: SettingsOAuth, meta: { requiresAuth: true } },
{ path: '/settings/oauth/pat/create', name: 'settings.oauth.generatePAT', component: GeneratePAT, meta: { requiresAuth: true } },
{ path: '/login', name: 'login', component: Login },
{ path: '/register', name: 'register', component: Register },

View File

@ -1,12 +1,33 @@
<template>
<form-wrapper>
<form @submit.prevent="handleSubmit" @keydown="form.onKeydown($event)">
<form-field :form="form" fieldName="name" :label="$t('auth.forms.name')" autofocus />
<form-field :form="form" fieldName="email" inputType="email" :label="$t('auth.forms.email')" />
<form-field :form="form" fieldName="password" inputType="password" :label="$t('auth.forms.current_password.label')" :help="$t('auth.forms.current_password.help')" :hasOffset="true" />
<form-buttons :isBusy="form.isBusy" :caption="$t('commons.update')" />
</form>
</form-wrapper>
<div>
<setting-tabs :activeTab="'settings.account'"></setting-tabs>
<div class="options-tabs">
<form-wrapper>
<form @submit.prevent="submitProfile" @keydown="formProfile.onKeydown($event)">
<h4 class="title is-4 has-text-grey-light">{{ $t('settings.profile') }}</h4>
<form-field :form="formProfile" fieldName="name" :label="$t('auth.forms.name')" autofocus />
<form-field :form="formProfile" fieldName="email" inputType="email" :label="$t('auth.forms.email')" />
<form-field :form="formProfile" fieldName="password" inputType="password" :label="$t('auth.forms.current_password.label')" :help="$t('auth.forms.current_password.help')" />
<form-buttons :isBusy="formProfile.isBusy" :caption="$t('commons.update')" />
</form>
<form @submit.prevent="submitPassword" @keydown="formPassword.onKeydown($event)">
<h4 class="title is-4 pt-6 has-text-grey-light">{{ $t('settings.change_password') }}</h4>
<form-field :form="formPassword" fieldName="password" inputType="password" :label="$t('auth.forms.new_password')" />
<form-field :form="formPassword" fieldName="password_confirmation" inputType="password" :label="$t('auth.forms.confirm_new_password')" />
<form-field :form="formPassword" fieldName="currentPassword" inputType="password" :label="$t('auth.forms.current_password.label')" :help="$t('auth.forms.current_password.help')" />
<form-buttons :isBusy="formPassword.isBusy" :caption="$t('auth.forms.change_password')" />
</form>
</form-wrapper>
</div>
<vue-footer :showButtons="true">
<!-- Cancel button -->
<p class="control">
<a class="button is-dark is-rounded" @click.stop="exitSettings">
{{ $t('commons.close') }}
</a>
</p>
</vue-footer>
</div>
</template>
<script>
@ -16,25 +37,30 @@
export default {
data(){
return {
form: new Form({
formProfile: new Form({
name : '',
email : '',
password : '',
}),
formPassword: new Form({
currentPassword : '',
password : '',
password_confirmation : '',
})
}
},
async mounted() {
const { data } = await this.form.get('/api/user')
const { data } = await this.formProfile.get('/api/user')
this.form.fill(data)
this.formProfile.fill(data)
},
methods : {
handleSubmit(e) {
submitProfile(e) {
e.preventDefault()
this.form.put('/api/user', {returnError: true})
this.formProfile.put('/api/user', {returnError: true})
.then(response => {
this.$notify({ type: 'is-success', text: this.$t('auth.forms.profile_saved') })
})
@ -44,6 +70,26 @@
this.$notify({ type: 'is-danger', text: error.response.data.message })
}
else if( error.response.status !== 422 ) {
this.$router.push({ name: 'genericError', params: { err: error.response } });
}
});
},
submitPassword(e) {
e.preventDefault()
this.formPassword.patch('/api/user/password', {returnError: true})
.then(response => {
this.$notify({ type: 'is-success', text: response.data.message })
})
.catch(error => {
if( error.response.status === 400 ) {
this.$notify({ type: 'is-danger', text: error.response.data.message })
}
else if( error.response.status !== 422 ) {
this.$router.push({ name: 'genericError', params: { err: error.response } });
}
});

View File

@ -1,84 +0,0 @@
<template>
<div>
<div class="options-header has-background-black-ter">
<div class="columns is-centered">
<div class="form-column column is-two-thirds-tablet is-half-desktop is-one-third-widescreen is-one-third-fullhd">
<div class="tabs is-centered is-fullwidth">
<ul>
<li v-for="tab in tabs" :class="{ 'is-active': tab.isActive }">
<a @click="selectTab(tab)">{{ tab.name }}</a>
</li>
</ul>
</div>
</div>
</div>
</div>
<div class="options-tabs">
<options v-if="activeTab === $t('settings.options')"></options>
<account v-if="activeTab === $t('settings.account')"></account>
<password v-if="activeTab === $t('settings.password')"></password>
</div>
<vue-footer :showButtons="true">
<!-- Cancel button -->
<p class="control">
<a class="button is-dark is-rounded" @click.stop="exitSettings">
{{ $t('commons.close') }}
</a>
</p>
</vue-footer>
</div>
</template>
<script>
import Options from './Options'
import Account from './Account'
import Password from './Password'
export default {
data(){
return {
tabs: [
{
'name' : this.$t('settings.options'),
'isActive': true
},
{
'name' : this.$t('settings.account'),
'isActive': false
},
{
'name' : this.$t('settings.password'),
'isActive': false
},
],
activeTab: this.$t('settings.options')
}
},
components: {
Options,
Account,
Password
},
methods: {
selectTab(selectedTab) {
this.tabs.forEach(tab => {
tab.isActive = (tab.name == selectedTab.name);
if( tab.name == selectedTab.name ) {
this.activeTab = selectedTab.name
}
});
},
exitSettings: function(event) {
if (event) {
this.$notify({ clean: true })
this.$router.push({ name: 'accounts' })
}
}
}
};
</script>

View File

@ -0,0 +1,120 @@
<template>
<div>
<setting-tabs :activeTab="'settings.oauth'"></setting-tabs>
<div class="options-tabs">
<div class="columns is-centered">
<div class="form-column column is-two-thirds-tablet is-half-desktop is-one-third-widescreen is-one-third-fullhd">
<h4 class="title is-4 has-text-grey-light">{{ $t('settings.personal_access_tokens') }}</h4>
<div class="is-size-7-mobile">
{{ $t('settings.token_legend')}}
</div>
<div class="mt-3 mb-6">
<router-link class="is-link mt-5" :to="{ name: 'settings.oauth.generatePAT' }">
<font-awesome-icon :icon="['fas', 'plus-circle']" /> {{ $t('settings.generate_new_token')}}
</router-link>
</div>
<div v-if="tokens.length > 0">
<div v-for="token in tokens" :key="token.id" class="group-item has-text-light is-size-5 is-size-6-mobile">
<font-awesome-icon v-if="token.value" class="has-text-success" :icon="['fas', 'check']" /> {{ token.name }}
<div class="tags is-pulled-right">
<a v-if="token.value" class="tag" v-clipboard="() => token.value" v-clipboard:success="clipboardSuccessHandler">{{ $t('commons.copy') }}</a>
<a class="tag is-dark " @click="revokeToken(token.id)">{{ $t('settings.revoke') }}</a>
</div>
<span v-if="token.value" class="is-size-7-mobile is-size-6 my-3">
{{ $t('settings.make_sure_copy_token') }}
</span>
<span v-if="token.value" class="pat is-family-monospace is-size-6 is-size-7-mobile has-text-success">
{{ token.value }}
</span>
</div>
</div>
<div v-if="isFetching && tokens.length === 0" class="has-text-centered">
<span class="is-size-4">
<font-awesome-icon :icon="['fas', 'spinner']" spin />
</span>
</div>
<!-- footer -->
<vue-footer :showButtons="true">
<!-- close button -->
<p class="control">
<router-link :to="{ name: 'accounts', params: { toRefresh: false } }" class="button is-dark is-rounded">{{ $t('commons.close') }}</router-link>
</p>
</vue-footer>
</div>
</div>
</div>
</div>
</template>
<script>
import Form from './../../components/Form'
export default {
data(){
return {
tokens : [],
isFetching: false,
form: new Form({
token : '',
})
}
},
mounted() {
this.fetchTokens()
},
methods : {
/**
* Get all groups from backend
*/
async fetchTokens() {
this.isFetching = true
await this.axios.get('/api/oauth/personal-access-tokens').then(response => {
const tokens = []
response.data.forEach((data) => {
if (data.id === this.$route.params.token_id) {
data.value = this.$route.params.accessToken
tokens.unshift(data)
}
else {
tokens.push(data)
}
})
this.tokens = tokens
})
this.isFetching = false
},
clipboardSuccessHandler ({ value, event }) {
this.$notify({ type: 'is-success', text: this.$t('commons.copied_to_clipboard') })
},
clipboardErrorHandler ({ value, event }) {
console.log('error', value)
},
/**
* revoke a token (after confirmation)
*/
async revokeToken(tokenId) {
if(confirm(this.$t('settings.confirm.revoke'))) {
await this.axios.delete('/api/oauth/personal-access-tokens/' + tokenId).then(response => {
// Remove the revoked token from the collection
this.tokens = this.tokens.filter(a => a.id !== tokenId)
this.$notify({ type: 'is-success', text: this.$t('settings.token_revoked') })
});
}
}
},
}
</script>

View File

@ -1,40 +1,53 @@
<template>
<form-wrapper>
<!-- <form @submit.prevent="handleSubmit" @change="handleSubmit" @keydown="form.onKeydown($event)"> -->
<form>
<h4 class="title is-4 has-text-grey-light">{{ $t('settings.general') }}</h4>
<!-- Language -->
<form-select v-on:lang="saveSetting('lang', $event)" :options="langs" :form="form" fieldName="lang" :label="$t('settings.forms.language.label')" :help="$t('settings.forms.language.help')" />
<!-- display mode -->
<form-toggle v-on:displayMode="saveSetting('displayMode', $event)" :choices="layouts" :form="form" fieldName="displayMode" :label="$t('settings.forms.display_mode.label')" :help="$t('settings.forms.display_mode.help')" />
<!-- show icon -->
<form-checkbox v-on:showAccountsIcons="saveSetting('showAccountsIcons', $event)" :form="form" fieldName="showAccountsIcons" :label="$t('settings.forms.show_accounts_icons.label')" :help="$t('settings.forms.show_accounts_icons.help')" />
<div>
<setting-tabs :activeTab="'settings.options'"></setting-tabs>
<div class="options-tabs">
<form-wrapper>
<!-- <form @submit.prevent="handleSubmit" @change="handleSubmit" @keydown="form.onKeydown($event)"> -->
<form>
<h4 class="title is-4 has-text-grey-light">{{ $t('settings.general') }}</h4>
<!-- Language -->
<form-select v-on:lang="saveSetting('lang', $event)" :options="langs" :form="form" fieldName="lang" :label="$t('settings.forms.language.label')" :help="$t('settings.forms.language.help')" />
<!-- display mode -->
<form-toggle v-on:displayMode="saveSetting('displayMode', $event)" :choices="layouts" :form="form" fieldName="displayMode" :label="$t('settings.forms.display_mode.label')" :help="$t('settings.forms.display_mode.help')" />
<!-- show icon -->
<form-checkbox v-on:showAccountsIcons="saveSetting('showAccountsIcons', $event)" :form="form" fieldName="showAccountsIcons" :label="$t('settings.forms.show_accounts_icons.label')" :help="$t('settings.forms.show_accounts_icons.help')" />
<h4 class="title is-4 pt-4 has-text-grey-light">{{ $t('groups.groups') }}</h4>
<!-- default group -->
<form-select v-on:defaultGroup="saveSetting('defaultGroup', $event)" :options="groups" :form="form" fieldName="defaultGroup" :label="$t('settings.forms.default_group.label')" :help="$t('settings.forms.default_group.help')" />
<!-- retain active group -->
<form-checkbox v-on:rememberActiveGroup="saveSetting('rememberActiveGroup', $event)" :form="form" fieldName="rememberActiveGroup" :label="$t('settings.forms.remember_active_group.label')" :help="$t('settings.forms.remember_active_group.help')" />
<h4 class="title is-4 pt-4 has-text-grey-light">{{ $t('groups.groups') }}</h4>
<!-- default group -->
<form-select v-on:defaultGroup="saveSetting('defaultGroup', $event)" :options="groups" :form="form" fieldName="defaultGroup" :label="$t('settings.forms.default_group.label')" :help="$t('settings.forms.default_group.help')" />
<!-- retain active group -->
<form-checkbox v-on:rememberActiveGroup="saveSetting('rememberActiveGroup', $event)" :form="form" fieldName="rememberActiveGroup" :label="$t('settings.forms.remember_active_group.label')" :help="$t('settings.forms.remember_active_group.help')" />
<h4 class="title is-4 pt-4 has-text-grey-light">{{ $t('settings.security') }}</h4>
<!-- auto lock -->
<form-select v-on:kickUserAfter="saveSetting('kickUserAfter', $event)" :options="kickUserAfters" :form="form" fieldName="kickUserAfter" :label="$t('settings.forms.auto_lock.label')" :help="$t('settings.forms.auto_lock.help')" />
<!-- protect db -->
<form-checkbox v-on:useEncryption="saveSetting('useEncryption', $event)" :form="form" fieldName="useEncryption" :label="$t('settings.forms.use_encryption.label')" :help="$t('settings.forms.use_encryption.help')" />
<!-- otp as dot -->
<form-checkbox v-on:showOtpAsDot="saveSetting('showOtpAsDot', $event)" :form="form" fieldName="showOtpAsDot" :label="$t('settings.forms.show_otp_as_dot.label')" :help="$t('settings.forms.show_otp_as_dot.help')" />
<!-- close otp on copy -->
<form-checkbox v-on:closeOtpOnCopy="saveSetting('closeOtpOnCopy', $event)" :form="form" fieldName="closeOtpOnCopy" :label="$t('settings.forms.close_otp_on_copy.label')" :help="$t('settings.forms.close_otp_on_copy.help')" />
<h4 class="title is-4 pt-4 has-text-grey-light">{{ $t('settings.security') }}</h4>
<!-- auto lock -->
<form-select v-on:kickUserAfter="saveSetting('kickUserAfter', $event)" :options="kickUserAfters" :form="form" fieldName="kickUserAfter" :label="$t('settings.forms.auto_lock.label')" :help="$t('settings.forms.auto_lock.help')" />
<!-- protect db -->
<form-checkbox v-on:useEncryption="saveSetting('useEncryption', $event)" :form="form" fieldName="useEncryption" :label="$t('settings.forms.use_encryption.label')" :help="$t('settings.forms.use_encryption.help')" />
<!-- otp as dot -->
<form-checkbox v-on:showOtpAsDot="saveSetting('showOtpAsDot', $event)" :form="form" fieldName="showOtpAsDot" :label="$t('settings.forms.show_otp_as_dot.label')" :help="$t('settings.forms.show_otp_as_dot.help')" />
<!-- close otp on copy -->
<form-checkbox v-on:closeOtpOnCopy="saveSetting('closeOtpOnCopy', $event)" :form="form" fieldName="closeOtpOnCopy" :label="$t('settings.forms.close_otp_on_copy.label')" :help="$t('settings.forms.close_otp_on_copy.help')" />
<h4 class="title is-4 pt-4 has-text-grey-light">{{ $t('settings.data_input') }}</h4>
<!-- basic qrcode -->
<form-checkbox v-on:useBasicQrcodeReader="saveSetting('useBasicQrcodeReader', $event)" :form="form" fieldName="useBasicQrcodeReader" :label="$t('settings.forms.use_basic_qrcode_reader.label')" :help="$t('settings.forms.use_basic_qrcode_reader.help')" />
<!-- direct capture -->
<form-checkbox v-on:useDirectCapture="saveSetting('useDirectCapture', $event)" :form="form" fieldName="useDirectCapture" :label="$t('settings.forms.useDirectCapture.label')" :help="$t('settings.forms.useDirectCapture.help')" />
<!-- default capture mode -->
<form-select v-on:defaultCaptureMode="saveSetting('defaultCaptureMode', $event)" :options="captureModes" :form="form" fieldName="defaultCaptureMode" :label="$t('settings.forms.defaultCaptureMode.label')" :help="$t('settings.forms.defaultCaptureMode.help')" />
</form>
</form-wrapper>
<h4 class="title is-4 pt-4 has-text-grey-light">{{ $t('settings.data_input') }}</h4>
<!-- basic qrcode -->
<form-checkbox v-on:useBasicQrcodeReader="saveSetting('useBasicQrcodeReader', $event)" :form="form" fieldName="useBasicQrcodeReader" :label="$t('settings.forms.use_basic_qrcode_reader.label')" :help="$t('settings.forms.use_basic_qrcode_reader.help')" />
<!-- direct capture -->
<form-checkbox v-on:useDirectCapture="saveSetting('useDirectCapture', $event)" :form="form" fieldName="useDirectCapture" :label="$t('settings.forms.useDirectCapture.label')" :help="$t('settings.forms.useDirectCapture.help')" />
<!-- default capture mode -->
<form-select v-on:defaultCaptureMode="saveSetting('defaultCaptureMode', $event)" :options="captureModes" :form="form" fieldName="defaultCaptureMode" :label="$t('settings.forms.defaultCaptureMode.label')" :help="$t('settings.forms.defaultCaptureMode.help')" />
</form>
</form-wrapper>
</div>
<vue-footer :showButtons="true">
<!-- Cancel button -->
<p class="control">
<a class="button is-dark is-rounded" @click.stop="exitSettings">
{{ $t('commons.close') }}
</a>
</p>
</vue-footer>
</div>
</template>
<script>
@ -150,10 +163,10 @@
fetchGroups() {
this.axios.get('api/groups').then(response => {
this.axios.get('/api/groups').then(response => {
response.data.forEach((data) => {
if( data.id >0 ) {
this.groups.push({
this.groups.push({
text: data.name,
value: data.id
})

View File

@ -0,0 +1,50 @@
<template>
<form-wrapper :title="$t('settings.forms.new_token')">
<form @submit.prevent="generatePAToken" @keydown="form.onKeydown($event)">
<form-field :form="form" fieldName="name" inputType="text" :label="$t('commons.name')" autofocus />
<div class="field is-grouped">
<div class="control">
<v-button>{{ $t('commons.generate') }}</v-button>
</div>
<div class="control">
<button type="button" class="button is-text" @click="cancelGeneration">{{ $t('commons.cancel') }}</button>
</div>
</div>
</form>
</form-wrapper>
</template>
<script>
import Form from './../../../components/Form'
export default {
data() {
return {
form: new Form({
name: ''
})
}
},
methods: {
async generatePAToken() {
const { data } = await this.form.post('/api/oauth/personal-access-tokens')
if( this.form.errors.any() === false ) {
this.$router.push({ name: 'settings.oauth', params: { accessToken: data.accessToken, token_id: data.token.id } });
}
},
cancelGeneration: function() {
this.$router.push({ name: 'settings.oauth' });
},
},
}
</script>

View File

@ -1,49 +0,0 @@
<template>
<form-wrapper>
<form @submit.prevent="handleSubmit" @keydown="form.onKeydown($event)">
<form-field :form="form" fieldName="password" inputType="password" :label="$t('auth.forms.new_password')" />
<form-field :form="form" fieldName="password_confirmation" inputType="password" :label="$t('auth.forms.confirm_new_password')" />
<form-field :form="form" fieldName="currentPassword" inputType="password" :label="$t('auth.forms.current_password.label')" :help="$t('auth.forms.current_password.help')" :hasOffset="true" />
<form-buttons :isBusy="form.isBusy" :caption="$t('auth.forms.change_password')" />
</form>
</form-wrapper>
</template>
<script>
import Form from './../../components/Form'
export default {
data(){
return {
form: new Form({
currentPassword : '',
password : '',
password_confirmation : '',
})
}
},
methods : {
handleSubmit(e) {
e.preventDefault()
this.form.patch('/api/user/password', {returnError: true})
.then(response => {
this.$notify({ type: 'is-success', text: response.data.message })
})
.catch(error => {
if( error.response.status === 400 ) {
this.$notify({ type: 'is-danger', text: error.response.data.message })
}
else if( error.response.status !== 422 ) {
this.$router.push({ name: 'genericError', params: { err: error.response } });
}
});
}
},
}
</script>