mirror of
https://github.com/Bubka/2FAuth.git
synced 2025-05-30 06:49:11 +02:00
Convert the standard Create form to an advanced form
This commit is contained in:
parent
d72292c60f
commit
207ee2d3fb
@ -31,19 +31,21 @@ class OTP
|
|||||||
// $remainingTime = $nextOtpAt - time()
|
// $remainingTime = $nextOtpAt - time()
|
||||||
|
|
||||||
return $totp = [
|
return $totp = [
|
||||||
'otp' => $twofaccount->token(),
|
'token' => $twofaccount->token(),
|
||||||
'position' => $positionInCurrentPeriod
|
'position' => $positionInCurrentPeriod
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// It's a HOTP
|
// It's a HOTP
|
||||||
$hotp = [
|
$hotp = [
|
||||||
'otp' => $twofaccount->token(),
|
'token' => $twofaccount->token(),
|
||||||
'counter' => $twofaccount->hotpCounter
|
'hotpCounter' => $twofaccount->hotpCounter
|
||||||
];
|
];
|
||||||
|
|
||||||
// now we update the counter for the next OTP generation
|
// now we update the counter for the next OTP generation
|
||||||
$twofaccount->increaseCounter();
|
$twofaccount->increaseHotpCounter();
|
||||||
|
|
||||||
|
$hotp['nextHotpCounter'] = $twofaccount->hotpCounter;
|
||||||
$hotp['nextUri'] = $twofaccount->uri;
|
$hotp['nextUri'] = $twofaccount->uri;
|
||||||
|
|
||||||
if( !$isPreview ) {
|
if( !$isPreview ) {
|
||||||
|
@ -34,19 +34,36 @@ class TwoFAccountController extends Controller
|
|||||||
|
|
||||||
// see https://github.com/google/google-authenticator/wiki/Key-Uri-Format
|
// see https://github.com/google/google-authenticator/wiki/Key-Uri-Format
|
||||||
// for otpauth uri format validation
|
// for otpauth uri format validation
|
||||||
|
|
||||||
$this->validate($request, [
|
$this->validate($request, [
|
||||||
'service' => 'required',
|
'service' => 'required|string',
|
||||||
'uri' => 'required|regex:/^otpauth:\/\/[h,t]otp\//i',
|
'account' => 'nullable|string',
|
||||||
|
'icon' => 'nullable|string',
|
||||||
|
'uri' => 'nullable|string|regex:/^otpauth:\/\/[h,t]otp\//i',
|
||||||
|
'otpType' => 'required_without:uri|in:TOTP,HOTP',
|
||||||
|
'secret' => 'required_without:uri|string',
|
||||||
|
'digits' => 'nullable|integer|between:6,10',
|
||||||
|
'algorithm' => 'nullable|in:sha1,sha256,sha512,md5',
|
||||||
|
'totpPeriod' => 'nullable|integer|min:1',
|
||||||
|
'hotpCounter' => 'nullable|integer|min:0',
|
||||||
|
'imageLink' => 'nullable|url',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
OTP::get($request->uri);
|
// Two possible cases :
|
||||||
|
// - The most common case, the uri is provided thanks to a QR code live scan or file upload
|
||||||
|
// -> We use this uri to populate the account
|
||||||
|
// - The advanced form has been used and provide no uri but all individual parameters
|
||||||
|
// -> We use the parameters collection to populate the account
|
||||||
|
$twofaccount = new TwoFAccount;
|
||||||
|
|
||||||
$twofaccount = TwoFAccount::create([
|
if( $request->uri ) {
|
||||||
'service' => $request->service,
|
$twofaccount->populateFromUri($request->uri);
|
||||||
'account' => $request->account,
|
}
|
||||||
'uri' => $request->uri,
|
else {
|
||||||
'icon' => $request->icon
|
$twofaccount->populate($request->all());
|
||||||
]);
|
}
|
||||||
|
|
||||||
|
$twofaccount->save();
|
||||||
|
|
||||||
// Possible group association
|
// Possible group association
|
||||||
$groupId = Options::get('defaultGroup') === '-1' ? (int) Options::get('activeGroup') : (int) Options::get('defaultGroup');
|
$groupId = Options::get('defaultGroup') === '-1' ? (int) Options::get('activeGroup') : (int) Options::get('defaultGroup');
|
||||||
@ -103,10 +120,17 @@ class TwoFAccountController extends Controller
|
|||||||
// The request data is the Id of the account
|
// The request data is the Id of the account
|
||||||
$twofaccount = TwoFAccount::FindOrFail($request->id);
|
$twofaccount = TwoFAccount::FindOrFail($request->id);
|
||||||
}
|
}
|
||||||
else {
|
else if( $request->otp['uri'] ) {
|
||||||
// The request data is supposed to be a valid uri
|
// The request data contain an uri
|
||||||
$twofaccount = new TwoFAccount;
|
$twofaccount = new TwoFAccount;
|
||||||
$twofaccount->populateFromUri($request->uri);
|
$twofaccount->populateFromUri($request->otp['uri']);
|
||||||
|
|
||||||
|
$isPreview = true; // HOTP generated for preview (in the Create form) will not have its counter updated
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// The request data should contain all otp parameter
|
||||||
|
$twofaccount = new TwoFAccount;
|
||||||
|
$twofaccount->populate($request->otp);
|
||||||
|
|
||||||
$isPreview = true; // HOTP generated for preview (in the Create form) will not have its counter updated
|
$isPreview = true; // HOTP generated for preview (in the Create form) will not have its counter updated
|
||||||
}
|
}
|
||||||
|
@ -286,7 +286,7 @@ class TwoFAccount extends Model implements Sortable
|
|||||||
{
|
{
|
||||||
// The Type and Secret attributes are mandatory
|
// The Type and Secret attributes are mandatory
|
||||||
// All other attributes have default value set by OTPHP
|
// All other attributes have default value set by OTPHP
|
||||||
|
|
||||||
if( strcasecmp($attrib['otpType'], 'totp') == 0 && strcasecmp($attrib['otpType'], 'hotp') == 0 ) {
|
if( strcasecmp($attrib['otpType'], 'totp') == 0 && strcasecmp($attrib['otpType'], 'hotp') == 0 ) {
|
||||||
throw \Illuminate\Validation\ValidationException::withMessages([
|
throw \Illuminate\Validation\ValidationException::withMessages([
|
||||||
'otpType' => __('errors.not_a_supported_otp_type')
|
'otpType' => __('errors.not_a_supported_otp_type')
|
||||||
@ -306,20 +306,33 @@ class TwoFAccount extends Model implements Sortable
|
|||||||
$this->otp = strtolower($attrib['otpType']) === 'totp' ? TOTP::create($secret) : HOTP::create($secret);
|
$this->otp = strtolower($attrib['otpType']) === 'totp' ? TOTP::create($secret) : HOTP::create($secret);
|
||||||
|
|
||||||
// and we change parameters if needed
|
// and we change parameters if needed
|
||||||
if ($attrib['service']) {
|
if (array_key_exists('service', $attrib) && $attrib['service']) {
|
||||||
$this->service = $attrib['service'];
|
$this->service = $attrib['service'];
|
||||||
$this->otp->setIssuer( $attrib['service'] );
|
$this->otp->setIssuer( $attrib['service'] );
|
||||||
}
|
}
|
||||||
if ($attrib['account']) {
|
|
||||||
|
if (array_key_exists('account', $attrib) && $attrib['account']) {
|
||||||
$this->account = $attrib['account'];
|
$this->account = $attrib['account'];
|
||||||
$this->otp->setLabel( $attrib['account'] );
|
$this->otp->setLabel( $attrib['account'] );
|
||||||
}
|
}
|
||||||
if ($attrib['icon']) { $this->account = $attrib['icon']; }
|
|
||||||
if ($attrib['digits'] > 0) { $this->otp->setParameter( 'digits', (int) $attrib['digits'] ); }
|
if (array_key_exists('icon', $attrib) && $attrib['icon'])
|
||||||
if ($attrib['algorithm']) { $this->otp->setParameter( 'digest', $attrib['algorithm'] ); }
|
{ $this->icon = $attrib['icon']; }
|
||||||
if ($attrib['totpPeriod'] && $attrib['otpType'] !== 'totp') { $this->otp->setParameter( 'period', (int) $attrib['totpPeriod'] ); }
|
|
||||||
if ($attrib['hotpCounter'] && $attrib['otpType'] !== 'hotp') { $this->otp->setParameter( 'counter', (int) $attrib['hotpCounter'] ); }
|
if (array_key_exists('digits', $attrib) && $attrib['digits'] > 0)
|
||||||
if ($attrib['imageLink']) { $this->otp->setParameter( 'image', $attrib['imageLink'] ); }
|
{ $this->otp->setParameter( 'digits', (int) $attrib['digits'] ); }
|
||||||
|
|
||||||
|
if (array_key_exists('digest', $attrib) && $attrib['algorithm'])
|
||||||
|
{ $this->otp->setParameter( 'digest', $attrib['algorithm'] ); }
|
||||||
|
|
||||||
|
if (array_key_exists('totpPeriod', $attrib) && $attrib['totpPeriod'] && $attrib['otpType'] !== 'totp')
|
||||||
|
{ $this->otp->setParameter( 'period', (int) $attrib['totpPeriod'] ); }
|
||||||
|
|
||||||
|
if (array_key_exists('hotpCounter', $attrib) && $attrib['hotpCounter'] && $attrib['otpType'] !== 'hotp')
|
||||||
|
{ $this->otp->setParameter( 'counter', (int) $attrib['hotpCounter'] ); }
|
||||||
|
|
||||||
|
if (array_key_exists('imageLink', $attrib) && $attrib['imageLink'])
|
||||||
|
{ $this->otp->setParameter( 'image', $attrib['imageLink'] ); }
|
||||||
|
|
||||||
// We can now generate a fresh URI
|
// We can now generate a fresh URI
|
||||||
$this->uri = $this->otp->getProvisioningUri();
|
$this->uri = $this->otp->getProvisioningUri();
|
||||||
@ -327,7 +340,7 @@ class TwoFAccount extends Model implements Sortable
|
|||||||
}
|
}
|
||||||
catch (\Exception $e) {
|
catch (\Exception $e) {
|
||||||
throw \Illuminate\Validation\ValidationException::withMessages([
|
throw \Illuminate\Validation\ValidationException::withMessages([
|
||||||
'qrcode' => __('errors.cannot_create_otp_without_parameters')
|
'qrcode' => __('errors.cannot_create_otp_with_those_parameters')
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -5,84 +5,107 @@
|
|||||||
</figure>
|
</figure>
|
||||||
<p class="is-size-4 has-text-grey-light has-ellipsis">{{ internal_service }}</p>
|
<p class="is-size-4 has-text-grey-light has-ellipsis">{{ internal_service }}</p>
|
||||||
<p class="is-size-6 has-text-grey has-ellipsis">{{ internal_account }}</p>
|
<p class="is-size-6 has-text-grey has-ellipsis">{{ internal_account }}</p>
|
||||||
<p id="otp" class="is-size-1 has-text-white" :title="$t('commons.copy_to_clipboard')" v-clipboard="() => otp.replace(/ /g, '')" v-clipboard:success="clipboardSuccessHandler">{{ displayedOtp }}</p>
|
<p class="is-size-1 has-text-white is-clickable" :title="$t('commons.copy_to_clipboard')" v-clipboard="() => token.replace(/ /g, '')" v-clipboard:success="clipboardSuccessHandler">{{ displayedToken }}</p>
|
||||||
<ul class="dots" v-if="otpType === 'totp'">
|
<ul class="dots" v-if="internal_otpType === 'totp'">
|
||||||
<li v-for="n in 30"></li>
|
<li v-for="n in 30"></li>
|
||||||
</ul>
|
</ul>
|
||||||
<ul v-else-if="otpType === 'hotp'">
|
<ul v-else-if="internal_otpType === 'hotp'">
|
||||||
<li>counter: {{ counter }}</li>
|
<li>counter: {{ internal_hotpCounter }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
|
name: 'TokenDisplayer',
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
id: null,
|
id: null,
|
||||||
internal_service: '',
|
|
||||||
internal_account: '',
|
|
||||||
internal_uri: '',
|
|
||||||
next_uri: '',
|
next_uri: '',
|
||||||
internal_icon: '',
|
nextHotpCounter: null,
|
||||||
otpType: '',
|
token : '',
|
||||||
otp : '',
|
|
||||||
timerID: null,
|
timerID: null,
|
||||||
position: null,
|
position: null,
|
||||||
counter: null,
|
internal_otpType: '',
|
||||||
|
internal_account: '',
|
||||||
|
internal_service: '',
|
||||||
|
internal_icon: '',
|
||||||
|
internal_hotpCounter: null,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
props: {
|
props: {
|
||||||
service: '',
|
account : String,
|
||||||
account: '',
|
algorithm : String,
|
||||||
uri : '',
|
digits : Number,
|
||||||
icon: ''
|
hotpCounter : Number,
|
||||||
|
icon : String,
|
||||||
|
imageLink : String,
|
||||||
|
otpType : String,
|
||||||
|
qrcode : null,
|
||||||
|
secret : String,
|
||||||
|
secretIsBase32Encoded : Number,
|
||||||
|
service : String,
|
||||||
|
totpPeriod : Number,
|
||||||
|
uri : String
|
||||||
},
|
},
|
||||||
|
|
||||||
computed: {
|
computed: {
|
||||||
displayedOtp() {
|
displayedToken() {
|
||||||
return this.$root.appSettings.showTokenAsDot ? this.otp.replace(/[0-9]/g, '●') : this.otp
|
return this.$root.appSettings.showTokenAsDot ? this.token.replace(/[0-9]/g, '●') : this.token
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
mounted: function() {
|
mounted: function() {
|
||||||
this.showAccount()
|
this.getToken()
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
|
|
||||||
async showAccount(id) {
|
async getToken(id) {
|
||||||
|
|
||||||
// 2 possible cases :
|
// 3 possible cases :
|
||||||
// - ID is provided so we fetch the account data from db but without the uri.
|
// - Trigger when user ask for a token of an existing account: the ID is provided so we fetch the account data
|
||||||
|
// from db but without the uri.
|
||||||
// This prevent the uri (a sensitive data) to transit via http request unnecessarily. In this
|
// This prevent the uri (a sensitive data) to transit via http request unnecessarily. In this
|
||||||
// case this.otpType is sent by the backend.
|
// case this.otpType is sent by the backend.
|
||||||
// - the URI prop has been set via the create form, we need to preview some OTP before storing the account.
|
// - Trigger when user use the Quick Uploader and preview the account: No ID but we have an URI.
|
||||||
// So this.otpType is set on client side from the provided URI
|
// - Trigger when user use the Advanced form and preview the account: We should have all OTP parameter
|
||||||
|
// to obtain a token, including Secret and otpType which are required
|
||||||
this.id = id
|
|
||||||
|
|
||||||
if( this.id || this.uri ) {
|
try {
|
||||||
if( this.id ) {
|
this.internal_otpType = this.otpType.toLowerCase()
|
||||||
|
}
|
||||||
|
catch(e) {
|
||||||
|
//do nothing
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
this.internal_account = this.account
|
||||||
|
this.internal_service = this.service
|
||||||
|
this.internal_icon = this.icon
|
||||||
|
this.internal_hotpCounter = this.hotpCounter
|
||||||
|
}
|
||||||
|
|
||||||
const { data } = await this.axios.get('api/twofaccounts/' + this.id)
|
if( id ) {
|
||||||
|
|
||||||
this.internal_service = data.service
|
this.id = id
|
||||||
this.internal_account = data.account
|
const { data } = await this.axios.get('api/twofaccounts/' + this.id)
|
||||||
this.internal_icon = data.icon
|
|
||||||
this.otpType = data.otpType
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
|
|
||||||
this.internal_service = this.service
|
this.internal_service = data.service
|
||||||
this.internal_account = this.account
|
this.internal_account = data.account
|
||||||
this.internal_icon = this.icon
|
this.internal_icon = data.icon
|
||||||
this.internal_uri = this.uri
|
this.internal_otpType = data.otpType
|
||||||
this.otpType = this.internal_uri.slice(0, 15 ) === "otpauth://totp/" ? 'totp' : 'hotp';
|
}
|
||||||
}
|
|
||||||
|
|
||||||
switch(this.otpType) {
|
// We force the otpType to be based on the uri
|
||||||
|
if( this.uri ) {
|
||||||
|
this.internal_otpType = this.uri.slice(0, 15 ).toLowerCase() === "otpauth://totp/" ? 'totp' : 'hotp';
|
||||||
|
}
|
||||||
|
|
||||||
|
if( this.id || this.uri || this.secret ) { // minimun required vars to get a token from the backend
|
||||||
|
|
||||||
|
switch(this.internal_otpType) {
|
||||||
case 'totp':
|
case 'totp':
|
||||||
await this.getTOTP()
|
await this.getTOTP()
|
||||||
break;
|
break;
|
||||||
@ -97,12 +120,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
getTOTP: function() {
|
getTOTP: function() {
|
||||||
|
|
||||||
this.axios.post('/api/twofaccounts/otp', { id: this.id, uri: this.internal_uri }).then(response => {
|
this.axios.post('/api/twofaccounts/otp', { id: this.id, otp: this.$props }).then(response => {
|
||||||
let spacePosition = Math.ceil(response.data.otp.length / 2);
|
let spacePosition = Math.ceil(response.data.token.length / 2);
|
||||||
|
|
||||||
this.otp = response.data.otp.substr(0, spacePosition) + " " + response.data.otp.substr(spacePosition);
|
this.token = response.data.token.substr(0, spacePosition) + " " + response.data.token.substr(spacePosition);
|
||||||
this.position = response.data.position;
|
this.position = response.data.position;
|
||||||
|
|
||||||
let dots = this.$el.querySelector('.dots');
|
let dots = this.$el.querySelector('.dots');
|
||||||
@ -141,26 +165,31 @@
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
getHOTP: function() {
|
getHOTP: function() {
|
||||||
|
|
||||||
this.axios.post('/api/twofaccounts/otp', { id: this.id, uri: this.internal_uri }).then(response => {
|
this.axios.post('/api/twofaccounts/otp', { id: this.id, otp: this.$props }).then(response => {
|
||||||
let spacePosition = Math.ceil(response.data.otp.length / 2);
|
let spacePosition = Math.ceil(response.data.token.length / 2);
|
||||||
|
|
||||||
this.otp = response.data.otp.substr(0, spacePosition) + " " + response.data.otp.substr(spacePosition)
|
this.token = response.data.token.substr(0, spacePosition) + " " + response.data.token.substr(spacePosition)
|
||||||
this.counter = response.data.counter
|
this.internal_hotpCounter = response.data.hotpCounter
|
||||||
|
this.nextHotpCounter = response.data.nextHotpCounter
|
||||||
this.next_uri = response.data.nextUri
|
this.next_uri = response.data.nextUri
|
||||||
|
|
||||||
|
this.$emit('update-hotp-counter', { nextHotpCounter: this.nextHotpCounter })
|
||||||
|
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
this.$router.push({ name: 'genericError', params: { err: error.response } });
|
this.$router.push({ name: 'genericError', params: { err: error.response } });
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
clearOTP: function() {
|
clearOTP: function() {
|
||||||
this.stopLoop()
|
this.stopLoop()
|
||||||
this.id = this.timerID = this.position = this.counter = null
|
this.id = this.timerID = this.position = this.internal_hotpCounter = null
|
||||||
this.internal_service = this.internal_account = this.internal_icon = this.internal_uri = ''
|
this.internal_service = this.internal_account = this.internal_icon = this.internal_otpType = ''
|
||||||
this.otp = '... ...'
|
this.token = '... ...'
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.$el.querySelector('[data-is-active]').removeAttribute('data-is-active');
|
this.$el.querySelector('[data-is-active]').removeAttribute('data-is-active');
|
||||||
@ -171,12 +200,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
stopLoop: function() {
|
stopLoop: function() {
|
||||||
if( this.otpType === 'totp' ) {
|
if( this.internal_otpType === 'totp' ) {
|
||||||
clearInterval(this.timerID)
|
clearInterval(this.timerID)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
clipboardSuccessHandler ({ value, event }) {
|
clipboardSuccessHandler ({ value, event }) {
|
||||||
|
|
||||||
if(this.$root.appSettings.kickUserAfter == -1) {
|
if(this.$root.appSettings.kickUserAfter == -1) {
|
||||||
@ -190,6 +221,7 @@
|
|||||||
this.$notify({ type: 'is-success', text: this.$t('commons.copied_to_clipboard') })
|
this.$notify({ type: 'is-success', text: this.$t('commons.copied_to_clipboard') })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
clipboardErrorHandler ({ value, event }) {
|
clipboardErrorHandler ({ value, event }) {
|
||||||
console.log('error', value)
|
console.log('error', value)
|
||||||
}
|
}
|
@ -132,7 +132,7 @@
|
|||||||
<quick-uploader v-if="showUploader" :directStreaming="accounts.length > 0" :showTrailer="accounts.length === 0" ref="QuickUploader"></quick-uploader>
|
<quick-uploader v-if="showUploader" :directStreaming="accounts.length > 0" :showTrailer="accounts.length === 0" ref="QuickUploader"></quick-uploader>
|
||||||
<!-- modal -->
|
<!-- modal -->
|
||||||
<modal v-model="showTwofaccountInModal">
|
<modal v-model="showTwofaccountInModal">
|
||||||
<twofaccount-show ref="TwofaccountShow" ></twofaccount-show>
|
<token-displayer ref="TokenDisplayer" ></token-displayer>
|
||||||
</modal>
|
</modal>
|
||||||
<!-- footer -->
|
<!-- footer -->
|
||||||
<vue-footer v-if="showFooter && !showGroupSwitch" :showButtons="accounts.length > 0">
|
<vue-footer v-if="showFooter && !showGroupSwitch" :showButtons="accounts.length > 0">
|
||||||
@ -188,7 +188,7 @@
|
|||||||
<script>
|
<script>
|
||||||
|
|
||||||
import Modal from '../components/Modal'
|
import Modal from '../components/Modal'
|
||||||
import TwofaccountShow from '../components/TwofaccountShow'
|
import TokenDisplayer from '../components/TokenDisplayer'
|
||||||
import QuickUploader from './../components/QuickUploader'
|
import QuickUploader from './../components/QuickUploader'
|
||||||
// import vuePullRefresh from 'vue-pull-refresh';
|
// import vuePullRefresh from 'vue-pull-refresh';
|
||||||
import draggable from 'vuedraggable'
|
import draggable from 'vuedraggable'
|
||||||
@ -257,7 +257,7 @@
|
|||||||
// stop OTP generation on modal close
|
// stop OTP generation on modal close
|
||||||
this.$on('modalClose', function() {
|
this.$on('modalClose', function() {
|
||||||
console.log('modalClose triggered')
|
console.log('modalClose triggered')
|
||||||
this.$refs.TwofaccountShow.clearOTP()
|
this.$refs.TokenDisplayer.clearOTP()
|
||||||
});
|
});
|
||||||
|
|
||||||
// hide Footer when stream is on
|
// hide Footer when stream is on
|
||||||
@ -281,7 +281,7 @@
|
|||||||
|
|
||||||
components: {
|
components: {
|
||||||
Modal,
|
Modal,
|
||||||
TwofaccountShow,
|
TokenDisplayer,
|
||||||
// 'vue-pull-refresh': vuePullRefresh,
|
// 'vue-pull-refresh': vuePullRefresh,
|
||||||
QuickUploader,
|
QuickUploader,
|
||||||
draggable,
|
draggable,
|
||||||
@ -321,7 +321,7 @@
|
|||||||
this.selectedAccounts.push(account.id)
|
this.selectedAccounts.push(account.id)
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
this.$refs.TwofaccountShow.showAccount(account.id)
|
this.$refs.TokenDisplayer.showAccount(account.id)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -9,11 +9,8 @@
|
|||||||
<font-awesome-icon :icon="['fas', 'image']" size="2x" />
|
<font-awesome-icon :icon="['fas', 'image']" size="2x" />
|
||||||
</label>
|
</label>
|
||||||
<button class="delete delete-icon-button is-medium" v-if="tempIcon" @click.prevent="deleteIcon"></button>
|
<button class="delete delete-icon-button is-medium" v-if="tempIcon" @click.prevent="deleteIcon"></button>
|
||||||
<twofaccount-show ref="TwofaccountShow"
|
<token-displayer ref="QuickFormTokenDisplayer" v-bind="form.data()">
|
||||||
:service="form.service"
|
</token-displayer>
|
||||||
:account="form.account"
|
|
||||||
:uri="form.uri">
|
|
||||||
</twofaccount-show>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="columns is-mobile">
|
<div class="columns is-mobile">
|
||||||
@ -33,45 +30,26 @@
|
|||||||
<!-- Full form -->
|
<!-- Full form -->
|
||||||
<form-wrapper :title="$t('twofaccounts.forms.new_account')" v-else>
|
<form-wrapper :title="$t('twofaccounts.forms.new_account')" v-else>
|
||||||
<form @submit.prevent="createAccount" @keydown="form.onKeydown($event)">
|
<form @submit.prevent="createAccount" @keydown="form.onKeydown($event)">
|
||||||
|
<!-- qcode fileupload -->
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<div class="file is-dark is-boxed">
|
<div class="file is-black is-small">
|
||||||
<label class="file-label" :title="$t('twofaccounts.forms.use_qrcode.title')">
|
<label class="file-label" :title="$t('twofaccounts.forms.use_qrcode.title')">
|
||||||
<input class="file-input" type="file" accept="image/*" v-on:change="uploadQrcode" ref="qrcodeInput">
|
<input class="file-input" type="file" accept="image/*" v-on:change="uploadQrcode" ref="qrcodeInput">
|
||||||
<span class="file-cta">
|
<span class="file-cta">
|
||||||
<span class="file-icon">
|
<span class="file-icon">
|
||||||
<font-awesome-icon :icon="['fas', 'qrcode']" size="lg" />
|
<font-awesome-icon :icon="['fas', 'qrcode']" size="lg" />
|
||||||
</span>
|
</span>
|
||||||
<span class="file-label">{{ $t('twofaccounts.forms.use_qrcode.val') }}</span>
|
<span class="file-label">{{ $t('twofaccounts.forms.prefill_using_qrcode') }}</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<field-error :form="form" field="qrcode" class="help-for-file" />
|
<field-error :form="form" field="qrcode" class="help-for-file" />
|
||||||
|
<!-- service -->
|
||||||
<form-field :form="form" fieldName="service" inputType="text" :label="$t('twofaccounts.service')" :placeholder="$t('twofaccounts.forms.service.placeholder')" autofocus />
|
<form-field :form="form" fieldName="service" inputType="text" :label="$t('twofaccounts.service')" :placeholder="$t('twofaccounts.forms.service.placeholder')" autofocus />
|
||||||
|
<!-- account -->
|
||||||
<form-field :form="form" fieldName="account" inputType="text" :label="$t('twofaccounts.account')" :placeholder="$t('twofaccounts.forms.account.placeholder')" />
|
<form-field :form="form" fieldName="account" inputType="text" :label="$t('twofaccounts.account')" :placeholder="$t('twofaccounts.forms.account.placeholder')" />
|
||||||
<div class="field" style="margin-bottom: 0.5rem;">
|
<!-- icon upload -->
|
||||||
<label class="label">{{ $t('twofaccounts.forms.otp_uri') }}</label>
|
|
||||||
</div>
|
|
||||||
<div class="field has-addons">
|
|
||||||
<div class="control is-expanded">
|
|
||||||
<input class="input" type="text" placeholder="otpauth://totp/..." v-model="form.uri" :disabled="uriIsLocked" />
|
|
||||||
</div>
|
|
||||||
<div class="control" v-if="uriIsLocked">
|
|
||||||
<a class="button is-dark field-lock" @click="uriIsLocked = false" :title="$t('twofaccounts.forms.unlock.title')">
|
|
||||||
<span class="icon">
|
|
||||||
<font-awesome-icon :icon="['fas', 'lock']" />
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="control" v-else>
|
|
||||||
<a class="button is-dark field-unlock" @click="uriIsLocked = true" :title="$t('twofaccounts.forms.lock.title')">
|
|
||||||
<span class="icon has-text-danger">
|
|
||||||
<font-awesome-icon :icon="['fas', 'lock-open']" />
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<field-error :form="form" field="uri" class="help-for-file" />
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">{{ $t('twofaccounts.icon') }}</label>
|
<label class="label">{{ $t('twofaccounts.icon') }}</label>
|
||||||
<div class="file is-dark">
|
<div class="file is-dark">
|
||||||
@ -91,26 +69,58 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<field-error :form="form" field="icon" class="help-for-file" />
|
<field-error :form="form" field="icon" class="help-for-file" />
|
||||||
<div class="field is-grouped">
|
<!-- otp type -->
|
||||||
<div class="control">
|
<form-toggle :form="form" :choices="otpTypes" fieldName="otpType" :label="$t('twofaccounts.forms.otp_type.label')" :help="$t('twofaccounts.forms.otp_type.help')" :hasOffset="true" />
|
||||||
<v-button :isLoading="form.isBusy" >{{ $t('commons.create') }}</v-button>
|
<div v-if="form.otpType">
|
||||||
|
<!-- secret -->
|
||||||
|
<label class="label" v-html="$t('twofaccounts.forms.secret.label')"></label>
|
||||||
|
<div class="field has-addons">
|
||||||
|
<p class="control">
|
||||||
|
<span class="select">
|
||||||
|
<select v-model="form.secretIsBase32Encoded">
|
||||||
|
<option v-for="format in secretFormats" :value="format.value">{{ format.text }}</option>
|
||||||
|
</select>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p class="control is-expanded">
|
||||||
|
<input class="input" type="text" v-model="form.secret">
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="control" v-if="form.uri">
|
<div class="field">
|
||||||
<button type="button" class="button is-success" @click="previewAccount">{{ $t('twofaccounts.forms.test') }}</button>
|
<field-error :form="form" field="secret" class="help-for-file" />
|
||||||
</div>
|
<p class="help" v-html="$t('twofaccounts.forms.secret.help')"></p>
|
||||||
<div class="control">
|
|
||||||
<button type="button" class="button is-text" @click="cancelCreation">{{ $t('commons.cancel') }}</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<h2 class="title is-4 mt-5 mb-2">{{ $t('commons.options') }}</h2>
|
||||||
|
<p class="help mb-4">
|
||||||
|
{{ $t('twofaccounts.forms.options_help') }}
|
||||||
|
</p>
|
||||||
|
<!-- digits -->
|
||||||
|
<form-toggle :form="form" :choices="digitsChoices" fieldName="digits" :label="$t('twofaccounts.forms.digits.label')" :help="$t('twofaccounts.forms.digits.help')" />
|
||||||
|
<!-- algorithm -->
|
||||||
|
<form-toggle :form="form" :choices="algorithms" fieldName="algorithm" :label="$t('twofaccounts.forms.algorithm.label')" :help="$t('twofaccounts.forms.algorithm.help')" />
|
||||||
|
<!-- TOTP period -->
|
||||||
|
<form-field v-if="form.otpType === 'TOTP'" :form="form" fieldName="totpPeriod" inputType="text" :label="$t('twofaccounts.forms.totpPeriod.label')" :placeholder="$t('twofaccounts.forms.totpPeriod.placeholder')" :help="$t('twofaccounts.forms.totpPeriod.help')" />
|
||||||
|
<!-- HOTP counter -->
|
||||||
|
<form-field v-if="form.otpType === 'HOTP'" :form="form" fieldName="hotpCounter" inputType="text" :label="$t('twofaccounts.forms.hotpCounter.label')" :placeholder="$t('twofaccounts.forms.hotpCounter.placeholder')" :help="$t('twofaccounts.forms.hotpCounter.help')" />
|
||||||
|
<!-- image link -->
|
||||||
|
<form-field :form="form" fieldName="imageLink" inputType="text" :label="$t('twofaccounts.forms.image_link.label')" :placeholder="$t('twofaccounts.forms.image_link.placeholder')" :help="$t('twofaccounts.forms.image_link.help')" />
|
||||||
</div>
|
</div>
|
||||||
|
<vue-footer :showButtons="true">
|
||||||
|
<p class="control">
|
||||||
|
<v-button :isLoading="form.isBusy" class="is-rounded" >{{ $t('commons.create') }}</v-button>
|
||||||
|
</p>
|
||||||
|
<p class="control" v-if="form.otpType && form.secret">
|
||||||
|
<button type="button" class="button is-success is-rounded" @click="previewAccount">{{ $t('twofaccounts.forms.test') }}</button>
|
||||||
|
</p>
|
||||||
|
<p class="control">
|
||||||
|
<button type="button" class="button is-text is-rounded" @click="cancelCreation">{{ $t('commons.cancel') }}</button>
|
||||||
|
</p>
|
||||||
|
</vue-footer>
|
||||||
</form>
|
</form>
|
||||||
<!-- modal -->
|
<!-- modal -->
|
||||||
<modal v-model="ShowTwofaccountInModal">
|
<modal v-model="ShowTwofaccountInModal">
|
||||||
<twofaccount-show ref="TwofaccountPreview"
|
<token-displayer ref="AdvancedFormTokenDisplayer" v-bind="form.data()" @update-hotp-counter="updateHotpCounter">
|
||||||
:service="form.service"
|
</token-displayer>
|
||||||
:account="form.account"
|
|
||||||
:uri="form.uri"
|
|
||||||
:icon="tempIcon">
|
|
||||||
</twofaccount-show>
|
|
||||||
</modal>
|
</modal>
|
||||||
</form-wrapper>
|
</form-wrapper>
|
||||||
</template>
|
</template>
|
||||||
@ -119,29 +129,43 @@
|
|||||||
|
|
||||||
import Modal from '../../components/Modal'
|
import Modal from '../../components/Modal'
|
||||||
import Form from './../../components/Form'
|
import Form from './../../components/Form'
|
||||||
import TwofaccountShow from '../../components/TwofaccountShow'
|
import TokenDisplayer from '../../components/TokenDisplayer'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
isQuickForm: false,
|
isQuickForm: false,
|
||||||
ShowTwofaccountInModal : false,
|
ShowTwofaccountInModal : false,
|
||||||
uriIsLocked: true,
|
|
||||||
tempIcon: '',
|
tempIcon: '',
|
||||||
form: new Form({
|
form: new Form({
|
||||||
service: '',
|
service: '',
|
||||||
account: '',
|
account: '',
|
||||||
|
otpType: '',
|
||||||
uri: '',
|
uri: '',
|
||||||
icon: '',
|
icon: '',
|
||||||
qrcode: null
|
secret: '',
|
||||||
})
|
secretIsBase32Encoded: 0,
|
||||||
|
algorithm: '',
|
||||||
|
digits: null,
|
||||||
|
hotpCounter: null,
|
||||||
|
totpPeriod: null,
|
||||||
|
imageLink: '',
|
||||||
|
qrcode: null,
|
||||||
|
}),
|
||||||
|
otpTypes: ['TOTP', 'HOTP'],
|
||||||
|
digitsChoices: [6,7,8,9,10],
|
||||||
|
secretFormats: [
|
||||||
|
{ text: this.$t('twofaccounts.forms.plain_text'), value: 0 },
|
||||||
|
{ text: 'Base32', value: 1 }
|
||||||
|
],
|
||||||
|
algorithms: ['sha1', 'sha256', 'sha512', 'md5'],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
watch: {
|
watch: {
|
||||||
tempIcon: function(val) {
|
tempIcon: function(val) {
|
||||||
if( this.isQuickForm ) {
|
if( this.isQuickForm ) {
|
||||||
this.$refs.TwofaccountShow.internal_icon = val
|
this.$refs.QuickFormTokenDisplayer.internal_icon = val
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -149,20 +173,21 @@
|
|||||||
mounted: function () {
|
mounted: function () {
|
||||||
if( this.$route.params.qrAccount ) {
|
if( this.$route.params.qrAccount ) {
|
||||||
|
|
||||||
this.isQuickForm = true
|
|
||||||
this.form.fill(this.$route.params.qrAccount)
|
this.form.fill(this.$route.params.qrAccount)
|
||||||
|
this.isQuickForm = true
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// stop TOTP generation on modal close
|
// stop TOTP generation on modal close
|
||||||
this.$on('modalClose', function() {
|
this.$on('modalClose', function() {
|
||||||
this.$refs.TwofaccountPreview.stopLoop()
|
|
||||||
|
this.$refs.AdvancedFormTokenDisplayer.stopLoop()
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
components: {
|
components: {
|
||||||
Modal,
|
Modal,
|
||||||
TwofaccountShow,
|
TokenDisplayer,
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
@ -171,15 +196,15 @@
|
|||||||
// set current temp icon as account icon
|
// set current temp icon as account icon
|
||||||
this.form.icon = this.tempIcon
|
this.form.icon = this.tempIcon
|
||||||
|
|
||||||
// The quick form (possibly the preview feature too) has incremented the HOTP counter so the next_uri property
|
// The quick form or the preview feature has incremented the HOTP counter so the next_uri property
|
||||||
// must be used as the uri to store
|
// must be used as the uri to store.
|
||||||
// This could desynchronized the HOTP verification server and our local counter if the user never verified the HOTP but this
|
// This could desynchronized the HOTP verification server and our local counter if the user never verified the HOTP but this
|
||||||
// is acceptable (and HOTP counter can be edited by the way)
|
// is acceptable (and HOTP counter can be edited by the way)
|
||||||
if( this.isQuickForm && this.$refs.TwofaccountShow.next_uri ) {
|
if( this.isQuickForm && this.$refs.QuickFormTokenDisplayer.next_uri ) {
|
||||||
this.form.uri = this.$refs.TwofaccountShow.next_uri
|
this.form.uri = this.$refs.QuickFormTokenDisplayer.next_uri
|
||||||
}
|
}
|
||||||
else if( this.$refs.TwofaccountPreview && this.$refs.TwofaccountPreview.next_uri ) {
|
else if( this.$refs.AdvancedFormTokenDisplayer && this.$refs.AdvancedFormTokenDisplayer.next_uri ) {
|
||||||
this.form.uri = this.$refs.TwofaccountPreview.next_uri
|
this.form.uri = this.$refs.AdvancedFormTokenDisplayer.next_uri
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.form.post('/api/twofaccounts')
|
await this.form.post('/api/twofaccounts')
|
||||||
@ -191,10 +216,7 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
previewAccount() {
|
previewAccount() {
|
||||||
// preview is possible only if we have an uri
|
this.$refs.AdvancedFormTokenDisplayer.getToken()
|
||||||
if( this.form.uri ) {
|
|
||||||
this.$refs.TwofaccountPreview.showAccount()
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
cancelCreation: function() {
|
cancelCreation: function() {
|
||||||
@ -220,6 +242,9 @@
|
|||||||
const { data } = await this.form.upload('/api/qrcode/decode', imgdata)
|
const { data } = await this.form.upload('/api/qrcode/decode', imgdata)
|
||||||
|
|
||||||
this.form.fill(data)
|
this.form.fill(data)
|
||||||
|
this.form.otpType = this.form.otpType.toUpperCase()
|
||||||
|
this.form.secretIsBase32Encoded = 1
|
||||||
|
this.form.uri = '' // we don't want an uri now because the user can change any otp parameter in the form
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -243,6 +268,10 @@
|
|||||||
this.tempIcon = ''
|
this.tempIcon = ''
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateHotpCounter(payload) {
|
||||||
|
this.form.hotpCounter = payload.nextHotpCounter
|
||||||
|
},
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -33,4 +33,5 @@ return [
|
|||||||
'move' => 'Move',
|
'move' => 'Move',
|
||||||
'all' => 'All',
|
'all' => 'All',
|
||||||
'rename' => 'Rename',
|
'rename' => 'Rename',
|
||||||
|
'options' => 'Options',
|
||||||
];
|
];
|
@ -26,7 +26,7 @@ return [
|
|||||||
'Unable_to_decrypt_uri' => 'Unable to decrypt uri',
|
'Unable_to_decrypt_uri' => 'Unable to decrypt uri',
|
||||||
'not_a_supported_otp_type' => 'This OTP format is not currently supported',
|
'not_a_supported_otp_type' => 'This OTP format is not currently supported',
|
||||||
'cannot_create_otp_without_secret' => 'Cannot create an OTP without a secret',
|
'cannot_create_otp_without_secret' => 'Cannot create an OTP without a secret',
|
||||||
'cannot_create_otp_without_parameters' => 'Cannot create an OTP with those parameters',
|
'cannot_create_otp_with_those_parameters' => 'Cannot create an OTP with those parameters',
|
||||||
'wrong_current_password' => 'Wrong current password, nothing has changed',
|
'wrong_current_password' => 'Wrong current password, nothing has changed',
|
||||||
'error_during_encryption' => 'Encryption failed, your database remains unprotected.',
|
'error_during_encryption' => 'Encryption failed, your database remains unprotected.',
|
||||||
'error_during_decryption' => 'Decryption failed, your database is still protected. This is mainly caused by an integrity issue of encrypted data for one or more accounts.',
|
'error_during_decryption' => 'Decryption failed, your database is still protected. This is mainly caused by an integrity issue of encrypted data for one or more accounts.',
|
||||||
|
@ -34,6 +34,7 @@ return [
|
|||||||
'otp_uri' => 'OTP Uri',
|
'otp_uri' => 'OTP Uri',
|
||||||
'hotp_counter' => 'HOTP Counter',
|
'hotp_counter' => 'HOTP Counter',
|
||||||
'scan_qrcode' => 'Scan a qrcode',
|
'scan_qrcode' => 'Scan a qrcode',
|
||||||
|
'prefill_using_qrcode' => 'Prefill using a QR Code',
|
||||||
'use_qrcode' => [
|
'use_qrcode' => [
|
||||||
'val' => 'Use a qrcode',
|
'val' => 'Use a qrcode',
|
||||||
'title' => 'Use a QR code to fill the form magically',
|
'title' => 'Use a QR code to fill the form magically',
|
||||||
@ -48,6 +49,39 @@ return [
|
|||||||
],
|
],
|
||||||
'choose_image' => 'Choose an image…',
|
'choose_image' => 'Choose an image…',
|
||||||
'test' => 'Test',
|
'test' => 'Test',
|
||||||
|
'secret' => [
|
||||||
|
'label' => 'Secret',
|
||||||
|
'help' => 'The key used to generate your security codes'
|
||||||
|
],
|
||||||
|
'plain_text' => 'Plain text',
|
||||||
|
'otp_type' => [
|
||||||
|
'label' => 'Choose the type of OTP to create',
|
||||||
|
'help' => 'Time-based OTP or HMAC-based OTP'
|
||||||
|
],
|
||||||
|
'digits' => [
|
||||||
|
'label' => 'Digits',
|
||||||
|
'help' => 'The number of digits of the generated security codes'
|
||||||
|
],
|
||||||
|
'algorithm' => [
|
||||||
|
'label' => 'Algorithm',
|
||||||
|
'help' => 'The algorithm used to secure your security codes'
|
||||||
|
],
|
||||||
|
'totpPeriod' => [
|
||||||
|
'label' => 'Period',
|
||||||
|
'placeholder' => 'Default is 30',
|
||||||
|
'help' => 'The period of validity of the generated security codes in second'
|
||||||
|
],
|
||||||
|
'hotpCounter' => [
|
||||||
|
'label' => 'Counter',
|
||||||
|
'placeholder' => 'Default is 0',
|
||||||
|
'help' => 'The initial counter value'
|
||||||
|
],
|
||||||
|
'image_link' => [
|
||||||
|
'label' => 'Image',
|
||||||
|
'placeholder' => 'http://...',
|
||||||
|
'help' => 'The url of an external image to use as the account icon'
|
||||||
|
],
|
||||||
|
'options_help' => 'You can leave the following options blank if you don\'t know how to set them. The most commonly used values will be applied.',
|
||||||
],
|
],
|
||||||
'stream' => [
|
'stream' => [
|
||||||
'need_grant_permission' => 'You need to grant camera access permission',
|
'need_grant_permission' => 'You need to grant camera access permission',
|
||||||
|
@ -115,7 +115,7 @@ return [
|
|||||||
'timezone' => 'The :attribute must be a valid zone.',
|
'timezone' => 'The :attribute must be a valid zone.',
|
||||||
'unique' => 'The :attribute has already been taken.',
|
'unique' => 'The :attribute has already been taken.',
|
||||||
'uploaded' => 'The :attribute failed to upload.',
|
'uploaded' => 'The :attribute failed to upload.',
|
||||||
'url' => 'The :attribute format is invalid.',
|
'url' => 'The :attribute must be a valid url.',
|
||||||
'uuid' => 'The :attribute must be a valid UUID.',
|
'uuid' => 'The :attribute must be a valid UUID.',
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@ -141,7 +141,13 @@ return [
|
|||||||
],
|
],
|
||||||
'email' => [
|
'email' => [
|
||||||
'exists' => 'No account found using this email',
|
'exists' => 'No account found using this email',
|
||||||
]
|
],
|
||||||
|
'otpType' => [
|
||||||
|
'required_without' => 'The :attribute field is required.',
|
||||||
|
],
|
||||||
|
'secret' => [
|
||||||
|
'required_without' => 'The :attribute field is required.',
|
||||||
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
Loading…
x
Reference in New Issue
Block a user