Merge branch 'release/1.3.0' into master

This commit is contained in:
Bubka 2020-10-09 18:34:26 +02:00
commit 5355309c8e
53 changed files with 734 additions and 1009 deletions

View File

@ -4,7 +4,7 @@
# 2FAuth
A web app to manage your Two-factors Auth (2FA) accounts and generate their OTP tokens
A web app to manage your Two-Factor Authentication (2FA) accounts and generate their security codes
![screens](https://user-images.githubusercontent.com/858858/74479269-267a1600-4eaf-11ea-9281-415e5a54bd9f.png)
@ -13,21 +13,25 @@ #### [2FAuth Demo](https://demo.2fauth.app/)
Credentials (login - password) : *demo@2fauth.app* - *demo*
## Purpose
2FAuth is a web based self-hosted alternative to One Time Passcode (OTP) generators like Google Authenticator that you can use both on mobile or desktop.
2FAuth is a web based self-hosted alternative to One Time Passcode (OTP) generators like Google Authenticator, designed for both mobile and desktop.
It aims to ease you perform your 2FA authentication steps whatever the device you handle, with a clean and suitable interface.
I created it because :
* Most of the UIs for this kind of apps show tokens for all accounts in the same time with stressful countdowns (in my opinion)
* I wanted my 2FA accounts to be stored in a database I can easily backup and restore.
* I hate taking out my smartphone to get an OTP when I use a desktop computer.
* I love coding and I love self-hosted solution
* I wanted my 2FA accounts to be stored in a standalone database I can easily backup and restore (did you already encountered a smartphone loss with all your 2FA accounts in Google Auth? I did...)
* I hate taking out my smartphone to get an OTP when I use a desktop computer
* I love coding and I love self-hosted solutions
## Features
* Manage 2FA accounts with QR code scanning and decoding
* Generate TOTP and HOTP tokens
* User authentication to protect access to 2FA accounts
## Main features
* Manage 2FA accounts with QR code flashing/scanning and decoding
* Generate TOTP and HOTP security codes
* User authentication to protect 2FA data stored in 2FAuth
2FAuth is currently localized in English and in French.
#### Single user app
2FA are sensitives data so an authentication is needed to use the app. And because they are usually owned by the same person, it is not possible to create more than one account.
2FA are sensitives data so you have to create an account and authenticate yourself to use the app. It is not possible to create more than one user account, the app is thought for personal use.
#### RFC compliance
2FAuth generates OTP according to RFC 4226 (HOTP Algorithm) and RFC 6238 (TOTP Algorithm) thanks to [Spomky-Labs/OTPHP](https://github.com/Spomky-Labs/otphp) php library.
@ -66,18 +70,17 @@ #### Prepare some stuff
php artisan passport:install
php artisan storage:link
php artisan config:cache
php artisan vue-i18n:generate
```
You are ready to go.
#### For development only
Install and build js dependencies
Checkout the 'dev' branch then install and build js dependencies
```
npm install
npm run dev
```
## Update your installation
## Upgrading
First, **backup your database**.
Then, using command line :

View File

@ -115,10 +115,6 @@ private function customApiResponse($exception, $debug)
$response['message'] = 'Unauthorized';
break;
case 403:
$response['message'] = 'Forbidden';
break;
case 404:
$response['message'] = 'Not Found';
break;

View File

@ -9,6 +9,7 @@
use Illuminate\Support\Facades\Lang;
use Illuminate\Validation\ValidationException;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Carbon\Carbon;
class LoginController extends Controller
@ -73,6 +74,8 @@ protected function sendLoginResponse(Request $request)
$success['token'] = $this->guard()->user()->createToken('2FAuth')->accessToken;
$success['name'] = $this->guard()->user()->name;
$this->authenticated($request, $this->guard()->user());
return response()->json(['message' => $success], Response::HTTP_OK);
}
@ -119,6 +122,18 @@ protected function validateLogin(Request $request)
]);
}
/**
* The user has been authenticated.
*
* @param \Illuminate\Http\Request $request
* @param mixed $user
* @return mixed
*/
protected function authenticated(Request $request, $user)
{
$user->last_seen_at = Carbon::now()->format('Y-m-d H:i:s');
$user->save();
}
/**
* log out current user

View File

@ -41,6 +41,8 @@ class Kernel extends HttpKernel
'api' => [
'throttle:60,1',
'bindings',
\App\Http\Middleware\LogoutInactiveUser::class,
\App\Http\Middleware\LogUserLastSeen::class,
],
];

View File

@ -11,6 +11,7 @@ class Authenticate extends Middleware
*
* @param \Illuminate\Http\Request $request
* @return string
* @codeCoverageIgnore
*/
protected function redirectTo($request)
{

View File

@ -0,0 +1,28 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Carbon\Carbon;
use Illuminate\Support\Facades\Auth;
class LogUserLastSeen
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle($request, Closure $next)
{
if( Auth::guard('api')->check() ) {
Auth::guard('api')->user()->last_seen_at = Carbon::now()->format('Y-m-d H:i:s');
Auth::guard('api')->user()->save();
}
return $next($request);
}
}

View File

@ -0,0 +1,59 @@
<?php
namespace App\Http\Middleware;
use Closure;
use App\User;
use Carbon\Carbon;
use App\Classes\Options;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Auth;
class LogoutInactiveUser
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle($request, Closure $next)
{
// Not a logged in user
if (!Auth::guard('api')->check()) {
return $next($request);
}
$user = Auth::guard('api')->user();
$now = Carbon::now();
$inactiveFor = $now->diffInSeconds(Carbon::parse($user->last_seen_at));
// Fetch all setting values
$settings = Options::get();
$kickUserAfterXSecond = intval($settings['kickUserAfter']) * 60;
// If user has been inactive longer than the allowed inactivity period
if ($kickUserAfterXSecond > 0 && $inactiveFor > $kickUserAfterXSecond) {
$user->last_seen_at = $now->format('Y-m-d H:i:s');
$user->save();
$accessToken = $user->token();
// phpunit does not generate token during tests, so we revoke it only if it exists
// @codeCoverageIgnoreStart
if( $accessToken ) {
$accessToken->revoke();
}
// @codeCoverageIgnoreEnd
return response()->json(['message' => 'unauthorised'], Response::HTTP_UNAUTHORIZED);
}
return $next($request);
}
}

View File

@ -1,5 +1,24 @@
# Change log
## [1.3.0] - 2020-10-09
### Added
- Application lock on security code copy or after a fixed period of inactivity
- Notify user that https is required in order to use camera streaming to flash QR code
- Notify user that the security code has been copied to clipboard when user click it
- Show selected accounts count in Manage view
- New option to show/hide icons in accounts list
### Changed
- More mobile friendly Close button for modal
- More advanced notification component
- Fixed header to keep Search field and Delete button always visible
- Switches replaced by checkboxes in Settings
### Fixed
- Hide context around iPhone X+ notch
- Unwanted access to restricted pages as guest
## [1.2.0] - 2020-09-18
### Added

View File

@ -23,7 +23,6 @@
"beyondcode/laravel-dump-server": "^1.0",
"filp/whoops": "^2.0",
"fzaninotto/faker": "^1.4",
"martinlindhe/laravel-vue-i18n-generator": "^0.1.42",
"mockery/mockery": "^1.0",
"nunomaduro/collision": "^3.0",
"phpunit/phpunit": "^7.5"

58
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "76b4fd00e8ddf636583ffb0a3dcc30bb",
"content-hash": "f01d597b2ba10db6dcab71530946be5a",
"packages": [
{
"name": "appstract/laravel-options",
@ -5219,61 +5219,6 @@
],
"time": "2020-07-09T08:09:16+00:00"
},
{
"name": "martinlindhe/laravel-vue-i18n-generator",
"version": "0.1.46",
"source": {
"type": "git",
"url": "https://github.com/martinlindhe/laravel-vue-i18n-generator.git",
"reference": "ddc52890f0204dff64d25e30c3473332904c6138"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/martinlindhe/laravel-vue-i18n-generator/zipball/ddc52890f0204dff64d25e30c3473332904c6138",
"reference": "ddc52890f0204dff64d25e30c3473332904c6138",
"shasum": ""
},
"require": {
"ext-json": "*",
"ext-mbstring": "*",
"illuminate/console": "~5.1.0|~5.2.0|~5.3.0|~5.4.0|~5.5.0|~5.6.0|~5.7.0|~5.8.0|~6.0|~7.0",
"illuminate/support": "~5.1.0|~5.2.0|~5.3.0|~5.4.0|~5.5.0|~5.6.0|~5.7.0|~5.8.0|~6.0|~7.0",
"php": ">=5.5.0"
},
"require-dev": {
"phpunit/phpunit": "~4.7"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"MartinLindhe\\VueInternationalizationGenerator\\GeneratorProvider"
]
}
},
"autoload": {
"psr-4": {
"MartinLindhe\\VueInternationalizationGenerator\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Martin Lindhe",
"email": "martin@ubique.se"
}
],
"description": "Generates a vue-i18n compatible include file from your Laravel translations.",
"homepage": "http://github.com/martinlindhe/laravel-vue-i18n-generator",
"keywords": [
"laravel",
"vue-i18n"
],
"time": "2020-03-10T18:55:52+00:00"
},
{
"name": "mockery/mockery",
"version": "1.3.3",
@ -6012,6 +5957,7 @@
"keywords": [
"tokenizer"
],
"abandoned": true,
"time": "2019-09-17T06:23:10+00:00"
},
{

View File

@ -22,7 +22,7 @@
|
*/
'version' => '1.2.0',
'version' => '1.3.0',
/*
|--------------------------------------------------------------------------
@ -37,6 +37,8 @@
'closeTokenOnCopy' => false,
'useBasicQrcodeReader' => false,
'displayMode' => 'list',
'showAccountsIcons' => true,
'kickUserAfter' => '15'
],
/*
@ -197,7 +199,7 @@
App\Providers\AuthServiceProvider::class,
// App\Providers\BroadcastServiceProvider::class,
App\Providers\EventServiceProvider::class,
App\Providers\RouteServiceProvider::class,
App\Providers\RouteServiceProvider::class
],

View File

@ -1,93 +0,0 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Laravel translations path
|--------------------------------------------------------------------------
|
| The default path where the translations are stored by Laravel.
| Note: the path will be prepended to point to the App directory.
|
*/
'langPath' => '/resources/lang',
/*
|--------------------------------------------------------------------------
| Laravel translation files
|--------------------------------------------------------------------------
|
| You can choose which translation files to be generated.
| Note: leave this empty for all the translation files to be generated.
|
*/
'langFiles' => [
/*
'pagination',
'passwords'
*/
],
/*
|--------------------------------------------------------------------------
| Excluded files & folders
|--------------------------------------------------------------------------
|
| Exclude translation files, generic files or folders you don't need.
|
*/
'excludes' => [
/*
'validation',
'example.file',
'example-folder',
*/
],
/*
|--------------------------------------------------------------------------
| Output file
|--------------------------------------------------------------------------
|
| The javascript path where I will place the generated file.
| Note: the path will be prepended to point to the App directory.
|
*/
'jsPath' => '/resources/js/langs/',
'jsFile' => '/resources/js/langs/locales.js',
/*
|--------------------------------------------------------------------------
| i18n library
|--------------------------------------------------------------------------
|
| Specify the library you use for localization.
| Options are vue-i18n or vuex-i18n.
|
*/
'i18nLib' => 'vue-i18n',
/*
|--------------------------------------------------------------------------
| Output messages
|--------------------------------------------------------------------------
|
| Specify if the library should show "written to" messages
| after generating json files.
|
*/
'showOutputMessages' => false,
/*
|--------------------------------------------------------------------------
| Escape character
|--------------------------------------------------------------------------
|
| Allows to escape translations strings that should not be treated as a
| variable
|
*/
'escape_char' => '!',
];

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class AddLastSeenToUsersTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('users', function (Blueprint $table) {
$table->timestamp('last_seen_at')->nullable();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('last_seen_at');
});
}
}

41
package-lock.json generated
View File

@ -1076,6 +1076,18 @@
"resolved": "https://registry.npmjs.org/@fortawesome/vue-fontawesome/-/vue-fontawesome-0.1.10.tgz",
"integrity": "sha512-b2+SLF31h32LSepVcXe+BQ63yvbq5qmTCy4KfFogCYm2bn68H5sDWUnX+U7MBqnM2aeEk9M7xSoqGnu+wSdY6w=="
},
"@kirschbaum-development/laravel-translations-loader": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@kirschbaum-development/laravel-translations-loader/-/laravel-translations-loader-1.0.2.tgz",
"integrity": "sha512-85FhK4AXZJSgWyjE1wg0+qaT0kL+VYUTY/gk9TmCxPL6qYFW3BXxjQi0pPDr7D6F2X+23s7At7ogf4xK0AEUWw==",
"dev": true,
"requires": {
"klaw-sync": "^6.0.0",
"loader-utils": "^1.1.0",
"lodash": "^4.17.11",
"php-array-parser": "^1.0.1"
}
},
"@mrmlnc/readdir-enhanced": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz",
@ -5360,6 +5372,15 @@
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
"dev": true
},
"klaw-sync": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz",
"integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==",
"dev": true,
"requires": {
"graceful-fs": "^4.1.11"
}
},
"laravel-mix": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/laravel-mix/-/laravel-mix-5.0.5.tgz",
@ -6550,6 +6571,21 @@
"sha.js": "^2.4.8"
}
},
"php-array-parser": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/php-array-parser/-/php-array-parser-1.0.1.tgz",
"integrity": "sha512-elnanzvSd/+U2eWD9Hp2MIab6LzWlnp9J+sTrQasDoR6VsWMDa3fDYBS8pojvu3RwPbpgDUHUxV5WGhfJ1NewQ==",
"dev": true,
"requires": {
"php-parser": "^2.0.3"
}
},
"php-parser": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/php-parser/-/php-parser-2.2.0.tgz",
"integrity": "sha1-ZzhPClkz2770C+qwqzHQuMWC/4g=",
"dev": true
},
"picomatch": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz",
@ -9315,6 +9351,11 @@
"vue-style-loader": "^4.1.0"
}
},
"vue-notification": {
"version": "1.3.20",
"resolved": "https://registry.npmjs.org/vue-notification/-/vue-notification-1.3.20.tgz",
"integrity": "sha512-vPj67Ah72p8xvtyVE8emfadqVWguOScAjt6OJDEUdcW5hW189NsqvfkOrctxHUUO9UYl9cTbIkzAEcPnHu+zBQ=="
},
"vue-pull-refresh": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/vue-pull-refresh/-/vue-pull-refresh-0.2.7.tgz",

View File

@ -10,6 +10,7 @@
"production": "cross-env NODE_ENV=production node_modules/webpack/bin/webpack.js --no-progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js"
},
"devDependencies": {
"@kirschbaum-development/laravel-translations-loader": "^1.0.2",
"cross-env": "^5.2.1",
"laravel-mix": "^5.0.5",
"lodash": "^4.17.20",
@ -33,6 +34,7 @@
"vue": "^2.6.12",
"vue-axios": "^2.1.5",
"vue-i18n": "^8.21.1",
"vue-notification": "^1.3.20",
"vue-pull-refresh": "^0.2.7",
"vue-qrcode-reader": "^2.3.13",
"vue-router": "^3.4.3",

2
public/css/app.css vendored

File diff suppressed because one or more lines are too long

2
public/js/app.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
!function(e){function r(r){for(var n,l,f=r[0],i=r[1],a=r[2],c=0,s=[];c<f.length;c++)l=f[c],Object.prototype.hasOwnProperty.call(o,l)&&o[l]&&s.push(o[l][0]),o[l]=0;for(n in i)Object.prototype.hasOwnProperty.call(i,n)&&(e[n]=i[n]);for(p&&p(r);s.length;)s.shift()();return u.push.apply(u,a||[]),t()}function t(){for(var e,r=0;r<u.length;r++){for(var t=u[r],n=!0,f=1;f<t.length;f++){var i=t[f];0!==o[i]&&(n=!1)}n&&(u.splice(r--,1),e=l(l.s=t[0]))}return e}var n={},o={0:0},u=[];function l(r){if(n[r])return n[r].exports;var t=n[r]={i:r,l:!1,exports:{}};return e[r].call(t.exports,t,t.exports,l),t.l=!0,t.exports}l.m=e,l.c=n,l.d=function(e,r,t){l.o(e,r)||Object.defineProperty(e,r,{enumerable:!0,get:t})},l.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},l.t=function(e,r){if(1&r&&(e=l(e)),8&r)return e;if(4&r&&"object"==typeof e&&e&&e.__esModule)return e;var t=Object.create(null);if(l.r(t),Object.defineProperty(t,"default",{enumerable:!0,value:e}),2&r&&"string"!=typeof e)for(var n in e)l.d(t,n,function(r){return e[r]}.bind(null,n));return t},l.n=function(e){var r=e&&e.__esModule?function(){return e.default}:function(){return e};return l.d(r,"a",r),r},l.o=function(e,r){return Object.prototype.hasOwnProperty.call(e,r)},l.p="/";var f=window.webpackJsonp=window.webpackJsonp||[],i=f.push.bind(f);f.push=r,f=f.slice();for(var a=0;a<f.length;a++)r(f[a]);var p=i;t()}([]);
!function(e){function r(r){for(var n,l,f=r[0],i=r[1],a=r[2],c=0,s=[];c<f.length;c++)l=f[c],Object.prototype.hasOwnProperty.call(o,l)&&o[l]&&s.push(o[l][0]),o[l]=0;for(n in i)Object.prototype.hasOwnProperty.call(i,n)&&(e[n]=i[n]);for(p&&p(r);s.length;)s.shift()();return u.push.apply(u,a||[]),t()}function t(){for(var e,r=0;r<u.length;r++){for(var t=u[r],n=!0,f=1;f<t.length;f++){var i=t[f];0!==o[i]&&(n=!1)}n&&(u.splice(r--,1),e=l(l.s=t[0]))}return e}var n={},o={1:0},u=[];function l(r){if(n[r])return n[r].exports;var t=n[r]={i:r,l:!1,exports:{}};return e[r].call(t.exports,t,t.exports,l),t.l=!0,t.exports}l.m=e,l.c=n,l.d=function(e,r,t){l.o(e,r)||Object.defineProperty(e,r,{enumerable:!0,get:t})},l.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},l.t=function(e,r){if(1&r&&(e=l(e)),8&r)return e;if(4&r&&"object"==typeof e&&e&&e.__esModule)return e;var t=Object.create(null);if(l.r(t),Object.defineProperty(t,"default",{enumerable:!0,value:e}),2&r&&"string"!=typeof e)for(var n in e)l.d(t,n,function(r){return e[r]}.bind(null,n));return t},l.n=function(e){var r=e&&e.__esModule?function(){return e.default}:function(){return e};return l.d(r,"a",r),r},l.o=function(e,r){return Object.prototype.hasOwnProperty.call(e,r)},l.p="/";var f=window.webpackJsonp=window.webpackJsonp||[],i=f.push.bind(f);f.push=r,f=f.slice();for(var a=0;a<f.length;a++)r(f[a]);var p=i;t()}([]);

2
public/js/vendor.js vendored

File diff suppressed because one or more lines are too long

View File

@ -1,7 +1,6 @@
{
"/js/manifest.js": "/js/manifest.js?id=7db827d654313dce4250",
"/js/app.js": "/js/app.js?id=06dde914944b58fdffb5",
"/css/app.css": "/css/app.css?id=6454f3aa078ad9bd4e25",
"/js/locales.js": "/js/locales.js?id=79bb717b28f53a8b3b72",
"/js/vendor.js": "/js/vendor.js?id=5ba3d19fe9d922bf8630"
"/js/app.js": "/js/app.js?id=bfb57d72d7a233d94944",
"/css/app.css": "/css/app.css?id=808a0baa932d7d3b6ba9",
"/js/manifest.js": "/js/manifest.js?id=3c768977c2574a34506e",
"/js/vendor.js": "/js/vendor.js?id=695918adf494620245ad"
}

23
resources/js/app.js vendored
View File

@ -1,21 +1,22 @@
import Vue from 'vue'
import router from './routes'
import api from './api'
import i18n from './langs/i18n'
import FontAwesome from './packages/fontawesome'
import Clipboard from './packages/clipboard'
import QrcodeReader from './packages/qrcodeReader'
import App from './components/App'
import Vue from 'vue'
import mixins from './mixins'
import router from './routes'
import api from './api'
import i18n from './langs/i18n'
import FontAwesome from './packages/fontawesome'
import Clipboard from './packages/clipboard'
import QrcodeReader from './packages/qrcodeReader'
import Notifications from 'vue-notification'
import './components'
Vue.use(Notifications)
const app = new Vue({
el: '#app',
data: {
appSettings: window.appSettings,
appVersion: window.appVersion
appSettings: window.appSettings
},
components: { App },
i18n,
router,
});

View File

@ -1,8 +1,10 @@
<template>
<div>
<kicker v-if="kickInactiveUser"></kicker>
<div v-if="$root.appSettings.isDemoApp" class="demo has-background-warning has-text-centered is-size-7-mobile">
{{ $t('commons.demo_do_not_post_sensitive_data') }}
</div>
<notifications width="100%" position="top" :duration="4000" :speed="0" :max="1" classes="notification" />
<main class="main-section">
<router-view></router-view>
</main>
@ -16,6 +18,14 @@
data(){
return {
}
},
computed: {
kickInactiveUser: function () {
return parseInt(this.$root.appSettings.kickUserAfter) > 0 && this.$route.meta.requiresAuth
}
}
}
</script>

View File

@ -7,7 +7,10 @@
</div>
</div>
</div>
<div class="content has-text-centered">
<div v-if="$route.name === 'settings'" class="content has-text-centered is-size-6">
<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>
</div>
</footer>
@ -27,21 +30,12 @@
},
methods: {
async logout(evt) {
logout() {
if(confirm(this.$t('auth.confirm.logout'))) {
await this.axios.get('api/logout')
localStorage.removeItem('jwt')
localStorage.removeItem('user')
delete this.axios.defaults.headers.common['Authorization']
this.$router.push({ name: 'login' })
this.appLogout()
}
},
}
}
};
</script>

View File

@ -0,0 +1,42 @@
<template>
<div class="field">
<input :id="fieldName" type="checkbox" :name="fieldName" class="is-checkradio is-info" v-model="form[fieldName]">
<label :for="fieldName" class="label" v-html="label"></label>
<p class="help" v-html="help" v-if="help"></p>
</div>
</template>
<script>
export default {
name: 'FormCheckbox',
data() {
return {
}
},
props: {
label: {
type: String,
default: ''
},
fieldName: {
type: String,
default: '',
required: true
},
form: {
type: Object,
required: true
},
help: {
type: String,
default: ''
},
}
}
</script>

View File

@ -3,10 +3,6 @@
<div class="form-column column is-two-thirds-tablet is-half-desktop is-one-third-widescreen is-one-quarter-fullhd">
<h1 class="title" v-html="title" v-if="title"></h1>
<slot />
<p v-if="showTag">
<notification :message="fail" type="is-danger" :isFixed="hasFixedNotification" v-if="fail" />
<notification :message="success" type="is-success" :isFixed="hasFixedNotification" v-if="success" />
</p>
</div>
</div>
</template>
@ -21,32 +17,11 @@
}
},
computed: {
showTag: function() {
return (this.fail || this.success) ? true : false
}
},
props: {
title: {
type: String,
default: ''
},
fail: {
type: String,
default: ''
},
success: {
type: String,
default: ''
},
hasFixedNotification: {
type: Boolean,
default: false
},
}
}
</script>

View File

@ -0,0 +1,58 @@
<template>
</template>
<script>
export default {
name: 'Kicker',
data: function () {
return {
events: ['click', 'mousedown', 'scroll', 'keypress', 'load'],
logoutTimer: null
}
},
mounted() {
this.events.forEach(function (event) {
window.addEventListener(event, this.resetTimer)
}, this);
this.setTimer()
},
destroyed() {
this.events.forEach(function (event) {
window.removeEventListener(event, this.resetTimer)
}, this);
clearTimeout(this.logoutTimer)
},
methods: {
setTimer: function() {
this.logoutTimer = setTimeout(this.logoutUser, this.$root.appSettings.kickUserAfter * 60 * 1000)
},
logoutUser: function() {
clearTimeout(this.logoutTimer)
this.appLogout()
},
resetTimer: function() {
clearTimeout(this.logoutTimer)
this.setTimer()
}
}
}
</script>

View File

@ -1,27 +1,34 @@
<template>
<div class="modal modal-otp" v-bind:class="{ 'is-active': isActive }">
<div class="modal-background" @click.stop="closeModal"></div>
<div class="modal-content">
<section class="section">
<div class="columns is-centered">
<div class="column is-three-quarters">
<div class="box has-text-centered has-background-black-ter is-shadowless">
<slot></slot>
<div class="modal-background" @click.stop="closeModal"></div>
<div class="modal-content">
<section class="section">
<div class="columns is-centered">
<div class="column is-three-quarters">
<div class="box has-text-centered has-background-black-ter is-shadowless">
<slot></slot>
</div>
</div>
</div>
</div>
</section>
</div>
<button class="modal-close is-large" aria-label="close" @click.stop="closeModal"></button>
</section>
</div>
<div class="fullscreen-footer">
<!-- Close button -->
<label class="button is-dark is-rounded" @click.stop="closeModal">
{{ $t('commons.close') }}
</label>
</div>
</div>
</template>
<script>
export default {
name: 'Modal',
props: {
value: Boolean,
},
computed: {
isActive: {
get () {
@ -32,10 +39,12 @@ export default {
}
}
},
methods: {
closeModal: function(event) {
if (event) {
this.isActive = false
this.$notify({ clean: true })
this.$parent.$emit('modalClose')
}
}

View File

@ -1,50 +0,0 @@
<template>
<div class="notification" :class="[type, isFixed ? 'is-fixed' : '']" v-if="show">
<button class="delete" v-if="isDeletable" @click="close"></button>
{{ message }}
</div>
</template>
<script>
export default {
name: 'Notification',
data() {
return {
show: true
}
},
props: {
type: {
type: String,
default: 'is-primary'
},
message: {
type: String,
default: '',
},
isDeletable: {
type: Boolean,
default: true,
},
isFixed: {
type: Boolean,
default: false
}
},
methods: {
close (event) {
if (event) {
this.show = false
}
}
}
}
</script>

View File

@ -31,9 +31,6 @@
<div class="column is-full quick-uploader-footer">
<router-link :to="{ name: 'create' }" class="is-link">{{ $t('twofaccounts.use_full_form') }}</router-link>
</div>
<div v-if="showError" class="column is-full quick-uploader-footer">
<notification :message="errorText" :isDeletable="false" type="is-danger" />
</div>
</div>
</div>
<!-- camera stream fullscreen scanner -->
@ -77,17 +74,6 @@
}
},
computed: {
debugMode: function() {
return process.env.NODE_ENV
},
showError: function() {
return this.debugMode == 'development' && this.errorName == 'NotAllowedError'
},
},
props: {
showTrailer: {
type: Boolean,
@ -180,6 +166,14 @@
this.$parent.$emit('cannotStream')
console.log('fail to stream : ' + this.errorText)
if (this.errorName === 'NotAllowedError') {
this.$notify({ type: 'is-danger', text: this.errorText })
}
if (this.errorName === 'InsecureContextError') {
this.$notify({ type: 'is-warning', text: "HTTPS required for camera streaming" })
}
}
if( !this.errorName && !this.showStream ) {

View File

@ -162,12 +162,16 @@
},
clipboardSuccessHandler ({ value, event }) {
console.log('success', value)
if(this.$root.appSettings.closeTokenOnCopy) {
if(this.$root.appSettings.kickUserAfter == -1) {
this.appLogout()
}
else if(this.$root.appSettings.closeTokenOnCopy) {
this.$parent.isActive = false
this.clearOTP()
}
this.$notify({ type: 'is-success', text: this.$t('commons.copied_to_clipboard') })
},
clipboardErrorHandler ({ value, event }) {

View File

@ -1,25 +1,29 @@
import Vue from 'vue'
import Button from './Button'
import FieldError from './FieldError'
import FormWrapper from './FormWrapper'
import FormField from './FormField'
import FormSelect from './FormSelect'
import FormSwitch from './FormSwitch'
import FormButtons from './FormButtons'
import Notification from './Notification'
import VueFooter from './Footer'
import Vue from 'vue'
import App from './App'
import Button from './Button'
import FieldError from './FieldError'
import FormWrapper from './FormWrapper'
import FormField from './FormField'
import FormSelect from './FormSelect'
import FormSwitch from './FormSwitch'
import FormCheckbox from './FormCheckbox'
import FormButtons from './FormButtons'
import VueFooter from './Footer'
import Kicker from './Kicker'
// Components that are registered globaly.
[
App,
Button,
FieldError,
FormWrapper,
FormField,
FormSelect,
FormSwitch,
FormCheckbox,
FormButtons,
Notification,
VueFooter,
Kicker
].forEach(Component => {
Vue.component(Component.name, Component)
})

View File

@ -1,6 +1,6 @@
import Vue from 'vue'
import VueInternationalization from 'vue-i18n';
import Locale from './locales';
import Locale from '@kirschbaum-development/laravel-translations-loader/php!@kirschbaum-development/laravel-translations-loader';
Vue.use(VueInternationalization);

View File

@ -1,579 +0,0 @@
export default {
"en": {
"auth": {
"sign_out": "Sign out",
"sign_in": "Sign in",
"register": "Register",
"hello": "Hi {username} !",
"throttle": "Too many login attempts. Please try again in {seconds} seconds.",
"already_authenticated": "Already authenticated",
"confirm": {
"logout": "Are you sure you want to log out?"
},
"forms": {
"name": "Name",
"login": "Login",
"email": "Email",
"password": "Password",
"confirm_password": "Confirm password",
"confirm_new_password": "Confirm new password",
"dont_have_account_yet": "Don't have your account yet?",
"already_register": "Already registered?",
"password_do_not_match": "Password do not match",
"forgot_your_password": "Forgot your password?",
"request_password_reset": "Request a password reset",
"reset_password": "Reset password",
"new_password": "New password",
"current_password": {
"label": "Current password",
"help": "Fill in your current password to confirm that it's you"
},
"change_password": "Change password",
"send_password_reset_link": "Send password reset link",
"change_your_password": "Change your password",
"password_successfully_changed": "Password successfully changed ",
"edit_account": "Edit account",
"profile_saved": "Profile successfully updated!",
"welcome_to_demo_app_use_those_credentials": "Welcome to the 2FAuth demo.<br><br>You can connect using the email address <strong>demo@2fauth.app</strong> and the password <strong>demo</demo>"
}
},
"commons": {
"cancel": "Cancel",
"update": "Update",
"copy_to_clipboard": "Copy to clipboard",
"profile": "Profile",
"edit": "Edit",
"delete": "Delete",
"save": "Save",
"close": "Close",
"demo_do_not_post_sensitive_data": "This is a demo app, do not post any sensitive data"
},
"errors": {
"resource_not_found": "Resource not found",
"error_occured": "An error occured:",
"already_one_user_registered": "There is already a registered user.",
"cannot_register_more_user": "You cannot register more than one user.",
"refresh": "refresh",
"please": "Please ",
"response": {
"no_valid_otp": "No valid OTP resource in this QR code"
},
"something_wrong_with_server": "Something is wrong with your server",
"Unable_to_decrypt_uri": "Unable to decrypt uri",
"wrong_current_password": "Wrong current password, nothing has changed"
},
"languages": {
"en": "English",
"fr": "French"
},
"pagination": {
"previous": "&laquo; Previous",
"next": "Next &raquo;"
},
"passwords": {
"password": "Passwords must be at least eight characters and match the confirmation.",
"reset": "Your password has been reset!",
"sent": "We have e-mailed your password reset link!",
"token": "This password reset token is invalid.",
"user": "We can't find a user with that e-mail address."
},
"settings": {
"settings": "Settings",
"account": "Account",
"password": "Password",
"options": "Options",
"confirm": [],
"forms": {
"edit_settings": "Edit settings",
"setting_saved": "Settings saved",
"language": {
"label": "Language",
"help": "Change the language used to translate the app interface."
},
"show_token_as_dot": {
"label": "Show generated tokens as dot",
"help": "Replace generated token caracters with *** to ensure confidentiality. Do not affect the copy/paste feature."
},
"close_token_on_copy": {
"label": "Close token after copy",
"help": "Automatically close the popup showing the generated token after it has been copied"
},
"use_basic_qrcode_reader": {
"label": "Use basic qrcode reader",
"help": "If you experiences issues when capturing qrCodes enables this option to switch to a more basic but more reliable qrcode reader"
},
"display_mode": {
"label": "Display mode",
"help": "Choose whether you want accounts to be displayed as a list or as a grid"
},
"grid": "Grid",
"list": "List"
}
},
"twofaccounts": {
"service": "Service",
"account": "Account",
"icon": "Icon",
"new": "New",
"no_account_here": "No 2FA here!",
"add_first_account": "Add your first account",
"use_full_form": "Or use the full form",
"add_one": "Add one",
"manage": "Manage",
"done": "Done",
"forms": {
"service": {
"placeholder": "example.com"
},
"account": {
"placeholder": "John DOE"
},
"new_account": "New account",
"edit_account": "Edit account",
"otp_uri": "OTP Uri",
"hotp_counter": "HOTP Counter",
"scan_qrcode": "Scan a qrcode",
"use_qrcode": {
"val": "Use a qrcode",
"title": "Use a QR code to fill the form magically"
},
"unlock": {
"val": "Unlock",
"title": "Unlock it (at your own risk)"
},
"lock": {
"val": "Lock",
"title": "Lock it"
},
"choose_image": "Choose an image…",
"create": "Create",
"save": "Save",
"test": "Test"
},
"stream": {
"need_grant_permission": "You need to grant camera access permission",
"not_readable": "Fail to load scanner. Is the camera already in use?",
"no_cam_on_device": "No camera on this device",
"secured_context_required": "Secure context required (HTTPS or localhost)",
"camera_not_suitable": "Installed cameras are not suitable",
"stream_api_not_supported": "Stream API is not supported in this browser"
},
"confirm": {
"delete": "Are you sure you want to delete this account?",
"cancel": "The account will be lost. Are you sure?"
}
},
"validation": {
"accepted": "The {attribute} must be accepted.",
"active_url": "The {attribute} is not a valid URL.",
"after": "The {attribute} must be a date after {date}.",
"after_or_equal": "The {attribute} must be a date after or equal to {date}.",
"alpha": "The {attribute} may only contain letters.",
"alpha_dash": "The {attribute} may only contain letters, numbers, dashes and underscores.",
"alpha_num": "The {attribute} may only contain letters and numbers.",
"array": "The {attribute} must be an array.",
"before": "The {attribute} must be a date before {date}.",
"before_or_equal": "The {attribute} must be a date before or equal to {date}.",
"between": {
"numeric": "The {attribute} must be between {min} and {max}.",
"file": "The {attribute} must be between {min} and {max} kilobytes.",
"string": "The {attribute} must be between {min} and {max} characters.",
"array": "The {attribute} must have between {min} and {max} items."
},
"boolean": "The {attribute} field must be true or false.",
"confirmed": "The {attribute} confirmation does not match.",
"date": "The {attribute} is not a valid date.",
"date_equals": "The {attribute} must be a date equal to {date}.",
"date_format": "The {attribute} does not match the format {format}.",
"different": "The {attribute} and {other} must be different.",
"digits": "The {attribute} must be {digits} digits.",
"digits_between": "The {attribute} must be between {min} and {max} digits.",
"dimensions": "The {attribute} has invalid image dimensions.",
"distinct": "The {attribute} field has a duplicate value.",
"email": "The {attribute} must be a valid email address.",
"ends_with": "The {attribute} must end with one of the following: {values}",
"exists": "The selected {attribute} is invalid.",
"file": "The {attribute} must be a file.",
"filled": "The {attribute} field must have a value.",
"gt": {
"numeric": "The {attribute} must be greater than {value}.",
"file": "The {attribute} must be greater than {value} kilobytes.",
"string": "The {attribute} must be greater than {value} characters.",
"array": "The {attribute} must have more than {value} items."
},
"gte": {
"numeric": "The {attribute} must be greater than or equal {value}.",
"file": "The {attribute} must be greater than or equal {value} kilobytes.",
"string": "The {attribute} must be greater than or equal {value} characters.",
"array": "The {attribute} must have {value} items or more."
},
"image": "The {attribute} must be an image.",
"in": "The selected {attribute} is invalid.",
"in_array": "The {attribute} field does not exist in {other}.",
"integer": "The {attribute} must be an integer.",
"ip": "The {attribute} must be a valid IP address.",
"ipv4": "The {attribute} must be a valid IPv4 address.",
"ipv6": "The {attribute} must be a valid IPv6 address.",
"json": "The {attribute} must be a valid JSON string.",
"lt": {
"numeric": "The {attribute} must be less than {value}.",
"file": "The {attribute} must be less than {value} kilobytes.",
"string": "The {attribute} must be less than {value} characters.",
"array": "The {attribute} must have less than {value} items."
},
"lte": {
"numeric": "The {attribute} must be less than or equal {value}.",
"file": "The {attribute} must be less than or equal {value} kilobytes.",
"string": "The {attribute} must be less than or equal {value} characters.",
"array": "The {attribute} must not have more than {value} items."
},
"max": {
"numeric": "The {attribute} may not be greater than {max}.",
"file": "The {attribute} may not be greater than {max} kilobytes.",
"string": "The {attribute} may not be greater than {max} characters.",
"array": "The {attribute} may not have more than {max} items."
},
"mimes": "The {attribute} must be a file of type: {values}.",
"mimetypes": "The {attribute} must be a file of type: {values}.",
"min": {
"numeric": "The {attribute} must be at least {min}.",
"file": "The {attribute} must be at least {min} kilobytes.",
"string": "The {attribute} must be at least {min} characters.",
"array": "The {attribute} must have at least {min} items."
},
"not_in": "The selected {attribute} is invalid.",
"not_regex": "The {attribute} format is invalid.",
"numeric": "The {attribute} must be a number.",
"present": "The {attribute} field must be present.",
"regex": "The {attribute} format is invalid.",
"required": "The {attribute} field is required.",
"required_if": "The {attribute} field is required when {other} is {value}.",
"required_unless": "The {attribute} field is required unless {other} is in {values}.",
"required_with": "The {attribute} field is required when {values} is present.",
"required_with_all": "The {attribute} field is required when {values} are present.",
"required_without": "The {attribute} field is required when {values} is not present.",
"required_without_all": "The {attribute} field is required when none of {values} are present.",
"same": "The {attribute} and {other} must match.",
"size": {
"numeric": "The {attribute} must be {size}.",
"file": "The {attribute} must be {size} kilobytes.",
"string": "The {attribute} must be {size} characters.",
"array": "The {attribute} must contain {size} items."
},
"starts_with": "The {attribute} must start with one of the following: {values}",
"string": "The {attribute} must be a string.",
"timezone": "The {attribute} must be a valid zone.",
"unique": "The {attribute} has already been taken.",
"uploaded": "The {attribute} failed to upload.",
"url": "The {attribute} format is invalid.",
"uuid": "The {attribute} must be a valid UUID.",
"custom": {
"attribute-name": {
"rule-name": "custom-message"
},
"icon": {
"image": "Supported format are jpeg, png, bmp, gif, svg, or webp"
},
"qrcode": {
"image": "Supported format are jpeg, png, bmp, gif, svg, or webp"
},
"uri": {
"starts_with": "Only valid OTP uri are supported"
},
"email": {
"exists": "No account found using this email"
}
},
"attributes": []
}
},
"fr": {
"auth": {
"sign_out": "Déconnexion",
"sign_in": "Se connecter",
"register": "Créer un compte",
"hello": "Hi {username} !",
"throttle": "Trop de tentatives de connexion. Veuillez réessayer dans {seconds} secondes.",
"already_authenticated": "Déjà authentifié",
"confirm": {
"logout": "Etes-vous sûrs de vouloir vous déconnecter ?"
},
"forms": {
"name": "Nom",
"login": "Connexion",
"email": "Email",
"password": "Mot de passe",
"confirm_password": "Confirmez le mot de passe",
"confirm_new_password": "Confirmez le nouveau mot de passe",
"dont_have_account_yet": "Pas encore de compte ?",
"already_register": "Déjà enregistré ?",
"password_do_not_match": "Le mot de passe ne correspond pas",
"forgot_your_password": "Mot de passe oublié ?",
"request_password_reset": "Réinitialiser le mot de passe",
"reset_password": "Mot de passe oublié",
"new_password": "Nouveau mot de passe",
"current_password": {
"label": "Mot de passe actuel",
"help": "Indiquez votre mot de passe actuel pour confirmer qu'il s'agit bien de vous"
},
"change_password": "Modifier le mot de passe",
"send_password_reset_link": "Envoyer",
"change_your_password": "Modifier votre mot de passe",
"password_successfully_changed": "Mot de passe modifié avec succès",
"edit_account": "Mis à jour du profil",
"profile_saved": "Profil mis à jour avec succès !",
"welcome_to_demo_app_use_those_credentials": "bienvenue sur la démo de 2FAuth.<br><br>Vous pouvez vous connecter en utilisant l'adresse email <strong>demo@2fauth.app</strong> et le mot de passe <strong>demo</demo>"
}
},
"commons": {
"cancel": "Annuler",
"update": "Mettre à jour",
"copy_to_clipboard": "Copier",
"profile": "Profil",
"edit": "Modifier",
"delete": "Supprimer",
"save": "Enregistrer",
"close": "Fermer",
"demo_do_not_post_sensitive_data": "Site de démonstration, ne postez aucune donnée sensible"
},
"errors": {
"resource_not_found": "Ressource introuvable",
"error_occured": "Une erreur est survenue :",
"already_one_user_registered": "Un compte utilisateur existe déjà.",
"cannot_register_more_user": "Vous ne pouvez pas enregistrer plus d'un utilisateur.",
"refresh": "Actualiser",
"please": "",
"response": {
"no_valid_otp": "Aucune donnée OTP valide dans ce QR code"
},
"something_wrong_with_server": "Il y a un problème avec votre serveur",
"Unable_to_decrypt_uri": "uri impossible à décoder",
"wrong_current_password": "Mot de passe actuel érroné, rien n\\a été modifié"
},
"languages": {
"en": "Anglais",
"fr": "Français"
},
"pagination": {
"previous": "&laquo; Précédent",
"next": "Suivant &raquo;"
},
"passwords": {
"password": "Les mots de passe doivent contenir au moins huit caractères et être identiques.",
"reset": "Votre mot de passe a été réinitialisé !",
"sent": "Le lien pour réinitialiser votre mot de passe vient d'être envoyé !",
"token": "Ce jeton de réinitialisation n'est pas valide.",
"user": "Cette adresse email n'est pas celle de votre compte."
},
"settings": {
"settings": "Réglages",
"account": "Compte",
"password": "Mot de passe",
"options": "Options",
"confirm": [],
"forms": {
"edit_settings": "Modifier les réglages",
"setting_saved": "Réglages sauvegardés",
"language": {
"label": "Langue",
"help": "Traduit l'application dans la langue choisie"
},
"show_token_as_dot": {
"label": "Rendre illisibles les codes générés",
"help": "Remplace les caractères des codes générés par des ●●● pour garantir leur confidentialité. N'affecte pas la fonction de copier/coller qui reste utilisable."
},
"close_token_on_copy": {
"label": "Ne plus afficher les codes copiés",
"help": "Ferme automatiquement le popup affichant le code généré dès que ce dernier a été copié."
},
"use_basic_qrcode_reader": {
"label": "Utiliser le lecteur de qrcode basique",
"help": "Si vous rencontrez des problèmes lors de la lecture des qrCodes activez cette option pour utiliser un lecteur de qrcode moins évolué mais plus largement compatible"
},
"display_mode": {
"label": "Mode d'affichage",
"help": "Change le mode d'affichage des comptes, soit sous forme de liste, soit sous forme de grille"
},
"grid": "Grille",
"list": "Liste"
}
},
"twofaccounts": {
"service": "Service",
"account": "Compte",
"icon": "Icône",
"new": "Nouveau",
"no_account_here": "Aucun compte 2FA !",
"add_first_account": "Ajouter votre premier compte",
"use_full_form": "Ou utiliser le formulaire détaillé",
"add_one": "Add one",
"manage": "Gérer",
"done": "Terminé",
"forms": {
"service": {
"placeholder": "example.com"
},
"account": {
"placeholder": "Marc Dupont"
},
"new_account": "Nouveau compte",
"edit_account": "Modifier le compte",
"otp_uri": "OTP Uri",
"hotp_counter": "Compteur HOTP",
"scan_qrcode": "Scanner un QR code",
"use_qrcode": {
"val": "Utiliser un QR code",
"title": "Utiliser un QR code pour renseigner le formulaire d'un seul coup d'un seul"
},
"unlock": {
"val": "Déverouiller",
"title": "Déverouiller le champ (à vos risques et périls)"
},
"lock": {
"val": "Vérouiller",
"title": "Vérouiller le champ"
},
"choose_image": "Choisir une image…",
"create": "Créer",
"save": "Enregistrer",
"test": "Tester"
},
"stream": {
"need_grant_permission": "Vous devez autoriser l'utilisation de votre caméra",
"not_readable": "Le scanner ne se charge pas. La caméra est-elle déjà utilisée ?",
"no_cam_on_device": "Votre équipement ne dispose pas de caméra",
"secured_context_required": "Contexte sécurisé requis (HTTPS ou localhost)",
"camera_not_suitable": "Votre équipement ne dispose pas d'une caméra adaptée",
"stream_api_not_supported": "L'API Stream n'est pas supportée par votre navigateur"
},
"confirm": {
"delete": "Etes-vous sûrs de vouloir supprimer le compte ?",
"cancel": "Les données seront perdues, êtes-vous sûrs ?"
}
},
"validation": {
"accepted": "Le champ {attribute} doit être accepté.",
"active_url": "Le champ {attribute} n'est pas une URL valide.",
"after": "Le champ {attribute} doit être une date postérieure au {date}.",
"after_or_equal": "Le champ {attribute} doit être une date postérieure ou égale au {date}.",
"alpha": "Le champ {attribute} doit contenir uniquement des lettres.",
"alpha_dash": "Le champ {attribute} doit contenir uniquement des lettres, des chiffres et des tirets.",
"alpha_num": "Le champ {attribute} doit contenir uniquement des chiffres et des lettres.",
"array": "Le champ {attribute} doit être un tableau.",
"before": "Le champ {attribute} doit être une date antérieure au {date}.",
"before_or_equal": "Le champ {attribute} doit être une date antérieure ou égale au {date}.",
"between": {
"numeric": "La valeur de {attribute} doit être comprise entre {min} et {max}.",
"file": "La taille du fichier de {attribute} doit être comprise entre {min} et {max} kilo-octets.",
"string": "Le texte {attribute} doit contenir entre {min} et {max} caractères.",
"array": "Le tableau {attribute} doit contenir entre {min} et {max} éléments."
},
"boolean": "Le champ {attribute} doit être vrai ou faux.",
"confirmed": "Le champ de confirmation {attribute} ne correspond pas.",
"date": "Le champ {attribute} n'est pas une date valide.",
"date_equals": "Le champ {attribute} doit être une date égale à {date}.",
"date_format": "Le champ {attribute} ne correspond pas au format {format}.",
"different": "Les champs {attribute} et {other} doivent être différents.",
"digits": "Le champ {attribute} doit contenir {digits} chiffres.",
"digits_between": "Le champ {attribute} doit contenir entre {min} et {max} chiffres.",
"dimensions": "La taille de l'image {attribute} n'est pas conforme.",
"distinct": "Le champ {attribute} a une valeur en double.",
"email": "Le champ {attribute} doit être une adresse email valide.",
"ends_with": "Le champ {attribute} doit se terminer par une des valeurs suivantes : {values}",
"exists": "Le champ {attribute} sélectionné est invalide.",
"file": "Le champ {attribute} doit être un fichier.",
"filled": "Le champ {attribute} doit avoir une valeur.",
"gt": {
"numeric": "La valeur de {attribute} doit être supérieure à {value}.",
"file": "La taille du fichier de {attribute} doit être supérieure à {value} kilo-octets.",
"string": "Le texte {attribute} doit contenir plus de {value} caractères.",
"array": "Le tableau {attribute} doit contenir plus de {value} éléments."
},
"gte": {
"numeric": "La valeur de {attribute} doit être supérieure ou égale à {value}.",
"file": "La taille du fichier de {attribute} doit être supérieure ou égale à {value} kilo-octets.",
"string": "Le texte {attribute} doit contenir au moins {value} caractères.",
"array": "Le tableau {attribute} doit contenir au moins {value} éléments."
},
"image": "Le champ {attribute} doit être une image.",
"in": "Le champ {attribute} est invalide.",
"in_array": "Le champ {attribute} n'existe pas dans {other}.",
"integer": "Le champ {attribute} doit être un entier.",
"ip": "Le champ {attribute} doit être une adresse IP valide.",
"ipv4": "Le champ {attribute} doit être une adresse IPv4 valide.",
"ipv6": "Le champ {attribute} doit être une adresse IPv6 valide.",
"json": "Le champ {attribute} doit être un document JSON valide.",
"lt": {
"numeric": "La valeur de {attribute} doit être inférieure à {value}.",
"file": "La taille du fichier de {attribute} doit être inférieure à {value} kilo-octets.",
"string": "Le texte {attribute} doit contenir moins de {value} caractères.",
"array": "Le tableau {attribute} doit contenir moins de {value} éléments."
},
"lte": {
"numeric": "La valeur de {attribute} doit être inférieure ou égale à {value}.",
"file": "La taille du fichier de {attribute} doit être inférieure ou égale à {value} kilo-octets.",
"string": "Le texte {attribute} doit contenir au plus {value} caractères.",
"array": "Le tableau {attribute} doit contenir au plus {value} éléments."
},
"max": {
"numeric": "La valeur de {attribute} ne peut être supérieure à {max}.",
"file": "La taille du fichier de {attribute} ne peut pas dépasser {max} kilo-octets.",
"string": "Le texte de {attribute} ne peut contenir plus de {max} caractères.",
"array": "Le tableau {attribute} ne peut contenir plus de {max} éléments."
},
"mimes": "Le champ {attribute} doit être un fichier de type : {values}.",
"mimetypes": "Le champ {attribute} doit être un fichier de type : {values}.",
"min": {
"numeric": "La valeur de {attribute} doit être supérieure ou égale à {min}.",
"file": "La taille du fichier de {attribute} doit être supérieure à {min} kilo-octets.",
"string": "Le texte {attribute} doit contenir au moins {min} caractères.",
"array": "Le tableau {attribute} doit contenir au moins {min} éléments."
},
"not_in": "Le champ {attribute} sélectionné n'est pas valide.",
"not_regex": "Le format du champ {attribute} n'est pas valide.",
"numeric": "Le champ {attribute} doit contenir un nombre.",
"password": "Le mot de passe est incorrect",
"present": "Le champ {attribute} doit être présent.",
"regex": "Le format du champ {attribute} est invalide.",
"required": "Le champ {attribute} est obligatoire.",
"required_if": "Le champ {attribute} est obligatoire quand la valeur de {other} est {value}.",
"required_unless": "Le champ {attribute} est obligatoire sauf si {other} est {values}.",
"required_with": "Le champ {attribute} est obligatoire quand {values} est présent.",
"required_with_all": "Le champ {attribute} est obligatoire quand {values} sont présents.",
"required_without": "Le champ {attribute} est obligatoire quand {values} n'est pas présent.",
"required_without_all": "Le champ {attribute} est requis quand aucun de {values} n'est présent.",
"same": "Les champs {attribute} et {other} doivent être identiques.",
"size": {
"numeric": "La valeur de {attribute} doit être {size}.",
"file": "La taille du fichier de {attribute} doit être de {size} kilo-octets.",
"string": "Le texte de {attribute} doit contenir {size} caractères.",
"array": "Le tableau {attribute} doit contenir {size} éléments."
},
"starts_with": "Le champ {attribute} doit commencer avec une des valeurs suivantes : {values}",
"string": "Le champ {attribute} doit être une chaîne de caractères.",
"timezone": "Le champ {attribute} doit être un fuseau horaire valide.",
"unique": "La valeur du champ {attribute} est déjà utilisée.",
"uploaded": "Le fichier du champ {attribute} n'a pu être téléversé.",
"url": "Le format de l'URL de {attribute} n'est pas valide.",
"uuid": "Le champ {attribute} doit être un UUID valide",
"custom": {
"attribute-name": {
"rule-name": "custom-message"
},
"icon": {
"image": "Les formats acceptés sont jpeg, png, bmp, gif, svg, or webp"
},
"qrcode": {
"image": "Les formats acceptés sont jpeg, png, bmp, gif, svg, or webp"
},
"uri": {
"starts_with": "La valeur n'est pas une uri OTP valide"
},
"email": {
"exists": "Aucun compte utilisateur n'utilise cette email"
}
},
"attributes": []
}
}
}

26
resources/js/mixins.js vendored Normal file
View File

@ -0,0 +1,26 @@
import Vue from 'vue'
Vue.mixin({
data: function () {
return {
appVersion: window.appVersion
}
},
methods: {
async appLogout(evt) {
await this.axios.get('api/logout')
localStorage.removeItem('jwt')
localStorage.removeItem('user')
delete this.axios.defaults.headers.common['Authorization']
this.$router.push({ name: 'login' })
},
}
})

View File

@ -3,7 +3,6 @@ import Vue from 'vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import {
faPlus,
faQrcode,
@ -19,6 +18,10 @@ import {
faSpinner
} from '@fortawesome/free-solid-svg-icons'
import {
faGithubAlt
} from '@fortawesome/free-brands-svg-icons'
library.add(
faPlus,
faQrcode,
@ -31,7 +34,8 @@ library.add(
faSearch,
faEllipsisH,
faBars,
faSpinner
faSpinner,
faGithubAlt
);
Vue.component('font-awesome-icon', FontAwesomeIcon)

View File

@ -10,19 +10,19 @@ 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 Settings from './views/settings/Index'
import Errors from './views/Error'
const router = new Router({
mode: 'history',
routes: [
{ path: '/', name: 'accounts', component: Accounts, props: true },
{ path: '/login', name: 'login',component: Login },
{ path: '/register', name: 'register',component: Register },
{ path: '/settings', name: 'settings',component: Settings },
{ path: '/create', name: 'create',component: Create },
{ path: '/edit/:twofaccountId', name: 'edit',component: Edit },
{ path: '/accounts', name: 'accounts', component: Accounts, meta: { requiresAuth: true }, alias: '/', props: true },
{ path: '/settings', name: 'settings', component: Settings, meta: { requiresAuth: true } },
{ path: '/create', name: 'create', component: Create, meta: { requiresAuth: true } },
{ path: '/edit/:twofaccountId', name: 'edit', component: Edit, meta: { requiresAuth: true } },
{ path: '/login', name: 'login', component: Login },
{ path: '/register', name: 'register', component: Register },
{ path: '/password/request', name: 'password.request', component: PasswordRequest },
{ path: '/password/reset/:token', name: 'password.reset', component: PasswordReset },
@ -33,4 +33,22 @@ const router = new Router({
],
});
router.beforeEach((to, from, next) => {
if (to.matched.some(record => record.meta.requiresAuth)) {
// Accesses to restricted pages without a jwt token are routed to the login page
if ( !localStorage.getItem('jwt') ) {
next({
name: 'login'
})
}
// If the jwt token is invalid, a 401 unauthorized is send by the php backend
else {
next()
}
}
else {
next()
}
});
export default router

View File

@ -2,30 +2,6 @@
<div>
<!-- show accounts list -->
<div class="container" v-if="this.showAccounts">
<!-- header -->
<div class="columns is-gapless is-mobile is-centered">
<div class="column is-three-quarters-mobile is-one-third-tablet is-one-quarter-desktop is-one-quarter-widescreen is-one-quarter-fullhd">
<!-- toolbar -->
<div class="toolbar has-text-centered" v-if="editMode">
<a class="button" :class="{ 'is-dark': selectedAccounts.length === 0, 'is-danger': selectedAccounts.length > 0 }" :disabled="selectedAccounts.length == 0" @click="destroyAccounts">
<span class="icon is-small">
<font-awesome-icon :icon="['fas', 'trash']" />
</span>
<span>{{ $t('commons.delete') }}</span>
</a>
</div>
<!-- search -->
<div class="field" v-else>
<div class="control has-icons-right">
<input type="text" class="input is-rounded is-search" v-model="search">
<span class="icon is-small is-right">
<font-awesome-icon :icon="['fas', 'search']" v-if="!search" />
<a class="delete" v-if="search" @click="search = '' "></a>
</span>
</div>
</div>
</div>
</div>
<!-- accounts -->
<!-- <vue-pull-refresh :on-refresh="onRefresh" :config="{
errorLabel: 'error',
@ -47,7 +23,7 @@
</transition>
<div class="tfa-content is-size-3 is-size-4-mobile" @click.stop="showAccount(account)">
<div class="tfa-text has-ellipsis">
<img :src="'/storage/icons/' + account.icon" v-if="account.icon">
<img :src="'/storage/icons/' + account.icon" v-if="account.icon && $root.appSettings.showAccountsIcons">
{{ account.service }}
<span class="is-family-primary is-size-6 is-size-7-mobile has-text-grey ">{{ account.account }}</span>
</div>
@ -70,6 +46,32 @@
</draggable>
<!-- </vue-pull-refresh> -->
</div>
<!-- header -->
<div class="header has-background-black-ter" v-if="this.showAccounts">
<div class="columns is-gapless is-mobile is-centered">
<div class="column is-three-quarters-mobile is-one-third-tablet is-one-quarter-desktop is-one-quarter-widescreen is-one-quarter-fullhd">
<!-- toolbar -->
<div class="toolbar has-text-centered" v-if="editMode">
<div class="manage-buttons tags has-addons are-medium">
<span class="tag is-dark">{{ selectedAccounts.length }}&nbsp;{{ $t('commons.selected') }}</span>
<a class="tag is-danger" v-if="selectedAccounts.length > 0" @click="destroyAccounts">
{{ $t('commons.delete') }}&nbsp;<font-awesome-icon :icon="['fas', 'trash']" />
</a>
</div>
</div>
<!-- search -->
<div class="field" v-else>
<div class="control has-icons-right">
<input type="text" class="input is-rounded is-search" v-model="search">
<span class="icon is-small is-right">
<font-awesome-icon :icon="['fas', 'search']" v-if="!search" />
<a class="delete" v-if="search" @click="search = '' "></a>
</span>
</div>
</div>
</div>
</div>
</div>
<!-- Show uploader (because no account) -->
<quick-uploader v-if="showUploader" :directStreaming="accounts.length > 0" :showTrailer="accounts.length === 0" ref="QuickUploader"></quick-uploader>
<!-- modal -->
@ -269,16 +271,7 @@
this.editMode = state
this.$parent.showToolbar = state
},
},
beforeRouteEnter (to, from, next) {
if ( ! localStorage.getItem('jwt')) {
return next('login')
}
next()
}
};

View File

@ -1,5 +1,5 @@
<template>
<form-wrapper :fail="fail" :success="success">
<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')" />
@ -16,8 +16,6 @@
export default {
data(){
return {
success: '',
fail: '',
form: new Form({
name : '',
email : '',
@ -36,18 +34,15 @@
handleSubmit(e) {
e.preventDefault()
this.fail = ''
this.success = ''
this.form.patch('/api/settings/account', {returnError: true})
.then(response => {
this.success = response.data.message
this.$notify({ type: 'is-success', text: response.data.message })
})
.catch(error => {
if( error.response.status === 400 ) {
this.fail = error.response.data.message
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,23 +1,29 @@
<template>
<div>
<div class="columns is-centered">
<div class="form-column column is-two-thirds-tablet is-half-desktop is-one-third-widescreen is-one-quarter-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>
<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 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-quarter-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">
<router-link :to="{ name: 'accounts' }" class="button is-dark is-rounded">{{ $t('commons.close') }}</router-link>
<a class="button is-dark is-rounded" @click.stop="exitSettings">
{{ $t('commons.close') }}
</a>
</p>
</vue-footer>
</div>
@ -64,6 +70,13 @@
this.activeTab = selectedTab.name
}
});
},
exitSettings: function(event) {
if (event) {
this.$notify({ clean: true })
this.$router.push({ name: 'accounts' })
}
}
}
};

View File

@ -1,15 +1,16 @@
<template>
<form-wrapper :fail="fail" :success="success" :hasFixedNotification="true">
<div class="tags has-addons">
<span class="tag is-dark">2FAuth</span>
<span class="tag is-info">v{{ $root.appVersion }}</span>
</div>
<form-wrapper>
<form @submit.prevent="handleSubmit" @change="handleSubmit" @keydown="form.onKeydown($event)">
<h4 class="title is-4">{{ $t('settings.general') }}</h4>
<form-select :options="langs" :form="form" fieldName="lang" :label="$t('settings.forms.language.label')" :help="$t('settings.forms.language.help')" />
<form-select :options="layouts" :form="form" fieldName="displayMode" :label="$t('settings.forms.display_mode.label')" :help="$t('settings.forms.display_mode.help')" />
<form-switch :form="form" fieldName="showTokenAsDot" :label="$t('settings.forms.show_token_as_dot.label')" :help="$t('settings.forms.show_token_as_dot.help')" />
<form-switch :form="form" fieldName="closeTokenOnCopy" :label="$t('settings.forms.close_token_on_copy.label')" :help="$t('settings.forms.close_token_on_copy.help')" />
<form-switch :form="form" fieldName="useBasicQrcodeReader" :label="$t('settings.forms.use_basic_qrcode_reader.label')" :help="$t('settings.forms.use_basic_qrcode_reader.help')" />
<form-checkbox :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">{{ $t('settings.security') }}</h4>
<form-select :options="kickUserAfters" :form="form" fieldName="kickUserAfter" :label="$t('settings.forms.auto_lock.label')" :help="$t('settings.forms.auto_lock.help')" />
<form-checkbox :form="form" fieldName="showTokenAsDot" :label="$t('settings.forms.show_token_as_dot.label')" :help="$t('settings.forms.show_token_as_dot.help')" />
<form-checkbox :form="form" fieldName="closeTokenOnCopy" :label="$t('settings.forms.close_token_on_copy.label')" :help="$t('settings.forms.close_token_on_copy.help')" />
<h4 class="title is-4">{{ $t('settings.advanced') }}</h4>
<form-checkbox :form="form" fieldName="useBasicQrcodeReader" :label="$t('settings.forms.use_basic_qrcode_reader.label')" :help="$t('settings.forms.use_basic_qrcode_reader.help')" />
</form>
</form-wrapper>
</template>
@ -21,14 +22,14 @@
export default {
data(){
return {
success: '',
fail: '',
form: new Form({
lang: this.$root.$i18n.locale,
showTokenAsDot: this.$root.appSettings.showTokenAsDot,
closeTokenOnCopy: this.$root.appSettings.closeTokenOnCopy,
useBasicQrcodeReader: this.$root.appSettings.useBasicQrcodeReader,
showAccountsIcons: this.$root.appSettings.showAccountsIcons,
displayMode: this.$root.appSettings.displayMode,
kickUserAfter: this.$root.appSettings.kickUserAfter,
}),
langs: [
{ text: this.$t('languages.en'), value: 'en' },
@ -37,6 +38,17 @@
layouts: [
{ text: this.$t('settings.forms.grid'), value: 'grid' },
{ text: this.$t('settings.forms.list'), value: 'list' },
],
kickUserAfters: [
{ text: this.$t('settings.forms.never'), value: '0' },
{ text: this.$t('settings.forms.on_token_copy'), value: '-1' },
{ text: this.$t('settings.forms.1_minutes'), value: '1' },
{ text: this.$t('settings.forms.5_minutes'), value: '5' },
{ text: this.$t('settings.forms.10_minutes'), value: '10' },
{ text: this.$t('settings.forms.15_minutes'), value: '15' },
{ text: this.$t('settings.forms.30_minutes'), value: '30' },
{ text: this.$t('settings.forms.1_hour'), value: '60' },
{ text: this.$t('settings.forms.1_day'), value: '1440' },
]
}
},
@ -45,13 +57,10 @@
handleSubmit(e) {
e.preventDefault()
this.fail = ''
this.success = ''
this.form.post('/api/settings/options', {returnError: true})
.then(response => {
this.success = response.data.message
this.$notify({ type: 'is-success', text: response.data.message })
if(response.data.settings.lang !== this.$root.$i18n.locale) {
this.$router.go()
@ -62,7 +71,7 @@
})
.catch(error => {
this.fail = error.response.data.message
this.$notify({ type: 'is-danger', text: error.response.data.message })
});
}
},

View File

@ -1,5 +1,5 @@
<template>
<form-wrapper :fail="fail" :success="success">
<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')" />
@ -16,8 +16,6 @@
export default {
data(){
return {
success: '',
fail: '',
form: new Form({
currentPassword : '',
password : '',
@ -30,19 +28,18 @@
handleSubmit(e) {
e.preventDefault()
this.fail = ''
this.success = ''
this.form.patch('/api/settings/password', {returnError: true})
.then(response => {
this.success = response.data.message
this.$notify({ type: 'is-success', text: response.data.message })
})
.catch(error => {
if( error.response.status === 400 ) {
this.fail = error.response.data.message
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

@ -16,10 +16,12 @@
'cancel' => 'Cancel',
'update' => 'Update',
'copy_to_clipboard' => 'Copy to clipboard',
'copied_to_clipboard' => 'Copied to clipboard',
'profile' => 'Profile',
'edit' => 'Edit',
'delete' => 'Delete',
'save' => 'Save',
'close' => 'Close',
'demo_do_not_post_sensitive_data' => 'This is a demo app, do not post any sensitive data',
'selected' => 'selected',
];

View File

@ -20,6 +20,9 @@
'confirm' => [
],
'general' => 'General',
'security' => 'Security',
'advanced' => 'Advanced',
'forms' => [
'edit_settings' => 'Edit settings',
'setting_saved' => 'Settings saved',
@ -45,6 +48,23 @@
],
'grid' => 'Grid',
'list' => 'List',
'show_accounts_icons' => [
'label' => 'Show icons',
'help' => 'Show icons accounts in the main view'
],
'auto_lock' => [
'label' => 'Auto lock',
'help' => 'Log out the user automatically in case of inactivity'
],
'never' => 'Never',
'on_token_copy' => 'On security code copy',
'1_minutes' => 'After 1 minute',
'5_minutes' => 'After 5 minutes',
'10_minutes' => 'After 10 minutes',
'15_minutes' => 'After 15 minutes',
'30_minutes' => 'After 15 minutes',
'1_hour' => 'After 1 hour',
'1_day' => 'After 1 day',
],

View File

@ -57,6 +57,7 @@
'not_readable' => 'Fail to load scanner. Is the camera already in use?',
'no_cam_on_device' => 'No camera on this device',
'secured_context_required' => 'Secure context required (HTTPS or localhost)',
'https_required' => 'HTTPS required for camera streaming',
'camera_not_suitable' => 'Installed cameras are not suitable',
'stream_api_not_supported' => 'Stream API is not supported in this browser'
],

View File

@ -16,10 +16,12 @@
'cancel' => 'Annuler',
'update' => 'Mettre à jour',
'copy_to_clipboard' => 'Copier',
'copied_to_clipboard' => 'Copié dans le presse-papier',
'profile' => 'Profil',
'edit' => 'Modifier',
'delete' => 'Supprimer',
'save' => 'Enregistrer',
'close' => 'Fermer',
'demo_do_not_post_sensitive_data' => 'Site de démonstration, ne postez aucune donnée sensible',
'selected' => 'selectionné(s)',
];

View File

@ -20,6 +20,9 @@
'confirm' => [
],
'general' => 'General',
'security' => 'Sécurité',
'advanced' => 'Avancés',
'forms' => [
'edit_settings' => 'Modifier les réglages',
'setting_saved' => 'Réglages sauvegardés',
@ -45,7 +48,23 @@
],
'grid' => 'Grille',
'list' => 'Liste',
'show_accounts_icons' => [
'label' => 'Afficher les icônes',
'help' => 'Affiche les icônes des comptes dans la vue principale'
],
'auto_lock' => [
'label' => 'Verouillage automatique',
'help' => 'Déconnecter automatiquement l\'utilisateur en cas d\'inactivité'
],
'never' => 'Jamais',
'on_token_copy' => 'Après copie d\'un code de sécurité',
'1_minutes' => 'Après 1 minute',
'5_minutes' => 'Après 5 minutes',
'10_minutes' => 'Après 10 minutes',
'15_minutes' => 'Après 15 minutes',
'30_minutes' => 'Après 15 minutes',
'1_hour' => 'Après 1 heure',
'1_day' => 'Après 1 journée',
],

View File

@ -57,6 +57,7 @@
'not_readable' => 'Le scanner ne se charge pas. La caméra est-elle déjà utilisée ?',
'no_cam_on_device' => 'Votre équipement ne dispose pas de caméra',
'secured_context_required' => 'Contexte sécurisé requis (HTTPS ou localhost)',
'https_required' => 'HTTPS requis pour utiliser la caméra',
'camera_not_suitable' => 'Votre équipement ne dispose pas d\'une caméra adaptée',
'stream_api_not_supported' => 'L\'API Stream n\'est pas supportée par votre navigateur'
],

View File

@ -6,6 +6,18 @@ a:hover {
color: hsl(204, 86%, 53%);
}
@supports (padding-top: env(safe-area-inset-top)) {
#app {
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-left);
}
}
@supports (padding-top: constant(safe-area-inset-top)) {
#app {
padding: constant(safe-area-inset-top) constant(safe-area-inset-right) constant(safe-area-inset-left);
}
}
.main-section {
padding: 1.5rem 1rem 9rem 1rem;
}
@ -16,12 +28,45 @@ a:hover {
}
}
.header {
position: fixed;
top: 0;
left: 0;
padding-top: 1rem;
padding-bottom: 1rem;
width: 100%;
}
@supports (padding-top: env(safe-area-inset-top)) {
.header {
--safe-area-inset-top: env(safe-area-inset-top);
padding-top: calc(1rem + var(--safe-area-inset-top));
}
}
@supports (padding-top: constant(safe-area-inset-top)) {
.header {
--safe-area-inset-top: constant(safe-area-inset-top);
padding-top: calc(1rem + var(--safe-area-inset-top));
}
}
.accounts {
margin-top: 40px;
}
@media screen and (min-width: 769px) {
.accounts {
margin-top: 60px;
}
}
.search {
margin-bottom: 0 !important;
}
.accounts {
margin-top: 0.75rem;
.manage-buttons {
justify-content: center;
}
.form-column {
@ -165,15 +210,31 @@ a:hover {
.fullscreen-streamer {
position: fixed;
top: 0;
top: 10%;
left: 0;
height: 100vh;
width: 100%;
height: 65%;
}
/* Corner borders */
.overlay {
background:
linear-gradient(to right, white 1px, transparent 1px) 0 0,
linear-gradient(to right, white 1px, transparent 1px) 0 100%,
linear-gradient(to left, white 1px, transparent 1px) 100% 0,
linear-gradient(to left, white 1px, transparent 1px) 100% 100%,
linear-gradient(to bottom, white 1px, transparent 1px) 0 0,
linear-gradient(to bottom, white 1px, transparent 1px) 100% 0,
linear-gradient(to top, white 1px, transparent 1px) 0 100%,
linear-gradient(to top, white 1px, transparent 1px) 100% 100%;
background-repeat: no-repeat;
background-size: 20px 20px;
}
.fullscreen-alert {
position: fixed;
top: 25vh;
top: 37.5vh;
left: 0;
width: 100%;
padding: 0.75rem;
@ -314,14 +375,56 @@ footer .field.is-grouped {
justify-content: center;
}
.notification.is-fixed {
.notification {
border-radius: 0;
padding: 0.4rem 1.5rem;
}
@supports (padding-top: env(safe-area-inset-top)) {
.notification {
--safe-area-inset-top: env(safe-area-inset-top);
padding-top: calc(0.4rem + var(--safe-area-inset-top));
}
}
@supports (padding-top: constant(safe-area-inset-top)) {
.notification {
--safe-area-inset-top: constant(safe-area-inset-top);
padding-top: calc(0.4rem + var(--safe-area-inset-top));
}
}
.notification .notification-title {
// Style for title line
}
.notification .notification-content {
text-align: center;
}
.options-header {
position: fixed;
top: 0;
left: 0;
width: 100%;
border-radius: 0;
padding: 0.5rem 2.5rem 0.5rem 1.5rem;
text-align: center;
padding: 0 1rem 0.5rem;
z-index: 1000;
}
@supports (padding-top: env(safe-area-inset-top)) {
.options-header {
padding-top: env(safe-area-inset-top);
}
}
@supports (padding-top: constant(safe-area-inset-top)) {
.options-header {
padding-top: constant(safe-area-inset-top);
}
}
.options-tabs {
margin-top: 80px;
}
.file .tag {

View File

@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0, shrink-to-fit=no">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0, shrink-to-fit=no, viewport-fit=cover">
<meta name="csrf-token" content="{{csrf_token()}}">
<meta name="robots" content="noindex, nofollow">
<meta name="apple-mobile-web-app-capable" content="yes">
@ -29,6 +29,5 @@
<script src="{{ mix('js/manifest.js') }}"></script>
<script src="{{ mix('js/vendor.js') }}"></script>
<script src="{{ mix('js/app.js') }}"></script>
<script src="{{ mix('js/locales.js') }}"></script>
</body>
</html>

View File

@ -8,6 +8,7 @@
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Auth\RequestGuard;
use Illuminate\Support\Facades\Config;
class LoginTest extends TestCase
{
@ -173,4 +174,26 @@ public function testUserLogout()
]);
}
/**
* test User logout after inactivity via API
*
* @test
*/
public function testUserLogoutAfterInactivity()
{
// Set the autolock period to 1 minute
$response = $this->actingAs($this->user, 'api')
->json('POST', '/api/settings/options', [
'kickUserAfter' => '1'])
->assertStatus(200);
sleep(61);
// Ping a restricted endpoint to log last_seen_at time
$response = $this->actingAs($this->user, 'api')
->json('GET', '/api/settings/account')
->assertStatus(401);
}
}

View File

@ -44,17 +44,6 @@ public function test_HTTP_UNAUTHORIZED()
}
/**
* test Unauthorized
*
* @test
*/
public function test_HTTP_FORBIDDEN()
{
}
/**
* test Not Found
*

1
webpack.mix.js vendored
View File

@ -12,7 +12,6 @@ const mix = require('laravel-mix');
*/
mix.js('resources/js/app.js', 'public/js')
.js('resources/js/langs/locales.js', 'public/js')
.extract([
'vue',
'axios',