Add custom defaults & locking to user preferences - Closes #413

This commit is contained in:
Bubka 2024-11-27 11:20:26 +01:00
parent f6a595807d
commit 764b687904
23 changed files with 271 additions and 100 deletions

View File

@ -23,6 +23,18 @@ LOG_CHANNEL=stack
DB_CONNECTION=testing DB_CONNECTION=testing
DB_DATABASE=:memory: DB_DATABASE=:memory:
# valid overriden default
USERPREF_DEFAULT__THEME=light
# valid locked user preference
USERPREF_LOCKED__THEME=true
# invalid locked user preference
USERPREF_LOCKED__DISPLAY_MODE=
# malformed locked user preference
USERPREF_LOCKED___FORMAT_PASSWORD=false
# DB_CONNECTION=mysql # DB_CONNECTION=mysql
# DB_HOST=localhost # DB_HOST=localhost
# DB_PORT=3306 # DB_PORT=3306

View File

@ -35,6 +35,7 @@ class UserController extends Controller
$jsonPrefs->push([ $jsonPrefs->push([
'key' => $key, 'key' => $key,
'value' => $item, 'value' => $item,
'locked' => in_array($key, config('2fauth.lockedPreferences')),
]); ]);
}); });
@ -55,6 +56,7 @@ class UserController extends Controller
return response()->json([ return response()->json([
'key' => $preferenceName, 'key' => $preferenceName,
'value' => $request->user()->preferences[$preferenceName], 'value' => $request->user()->preferences[$preferenceName],
'locked' => in_array($preferenceName, config('2fauth.lockedPreferences')),
], 200); ], 200);
} }
@ -69,6 +71,10 @@ class UserController extends Controller
abort(404); abort(404);
} }
if (in_array($preferenceName, config('2fauth.lockedPreferences'))) {
abort(403);
}
$validated = $request->validated(); $validated = $request->validated();
$request->user()['preferences->' . $preferenceName] = $validated['value']; $request->user()['preferences->' . $preferenceName] = $validated['value'];

View File

@ -49,4 +49,22 @@ class Helpers
{ {
return Str::random($length) . '.' . $extension; return Str::random($length) . '.' . $extension;
} }
/**
* Defines preferences locked for change.
* This helper is only intended to be called from the 2FAuth config file.
*/
public static function lockedPreferences(array $preferences) : array
{
foreach ($preferences as $key => $value) {
$_key = $key === 'revealDottedOTP' ? 'revealDottedOtp' : $key;
$isLocked = envUnlessEmpty(Str::of($_key)->snake('_')->upper()->prepend('USERPREF_LOCKED__')->toString(), false);
if ($isLocked) {
$lockedPreferences[] = $key;
}
}
return $lockedPreferences ?? [];
}
} }

View File

@ -38,6 +38,7 @@ class SinglePageController extends Controller
$proxyLogoutUrl = config('2fauth.config.proxyLogoutUrl') ? config('2fauth.config.proxyLogoutUrl') : false; $proxyLogoutUrl = config('2fauth.config.proxyLogoutUrl') ? config('2fauth.config.proxyLogoutUrl') : false;
$subdir = config('2fauth.config.appSubdirectory') ? '/' . config('2fauth.config.appSubdirectory') : ''; $subdir = config('2fauth.config.appSubdirectory') ? '/' . config('2fauth.config.appSubdirectory') : '';
$defaultPreferences = collect(config('2fauth.preferences')); /** @phpstan-ignore-line */ $defaultPreferences = collect(config('2fauth.preferences')); /** @phpstan-ignore-line */
$lockedPreferences = collect(config('2fauth.lockedPreferences')); /** @phpstan-ignore-line */
$isDemoApp = config('2fauth.config.isDemoApp') ? 'true' : 'false'; $isDemoApp = config('2fauth.config.isDemoApp') ? 'true' : 'false';
$isTestingApp = config('2fauth.config.isTestingApp') ? 'true' : 'false'; $isTestingApp = config('2fauth.config.isTestingApp') ? 'true' : 'false';
$lang = App::getLocale(); $lang = App::getLocale();
@ -69,6 +70,7 @@ class SinglePageController extends Controller
'exportSchemaUrl' => $exportSchemaUrl, 'exportSchemaUrl' => $exportSchemaUrl,
]), ]),
'defaultPreferences' => $defaultPreferences, 'defaultPreferences' => $defaultPreferences,
'lockedPreferences' => $lockedPreferences,
'subdirectory' => $subdir, 'subdirectory' => $subdir,
'isDemoApp' => $isDemoApp, 'isDemoApp' => $isDemoApp,
'isTestingApp' => $isTestingApp, 'isTestingApp' => $isTestingApp,

View File

@ -13,15 +13,12 @@
if (! function_exists('envUnlessEmpty')) { if (! function_exists('envUnlessEmpty')) {
/** /**
* @param string $key
* @param null $default
*
* @return mixed|null * @return mixed|null
*/ */
function envUnlessEmpty(string $key, $default = null) function envUnlessEmpty(string $key, string|int|bool|float|null $default = null)
{ {
$result = env($key, $default); $result = env($key, $default);
if (is_string($result) && '' === $result) { if ('' === $result) {
$result = $default; $result = $default;
} }

View File

@ -1,5 +1,47 @@
<?php <?php
use App\Helpers\Helpers;
use Illuminate\Support\Arr;
$preferences = [
'showOtpAsDot' => envUnlessEmpty('USERPREF_DEFAULT__SHOW_OTP_AS_DOT', false),
'showNextOtp' => envUnlessEmpty('USERPREF_DEFAULT__SHOW_NEXT_OTP', false),
'revealDottedOTP' => envUnlessEmpty('USERPREF_DEFAULT__REVEAL_DOTTED_OTP', false),
'closeOtpOnCopy' => envUnlessEmpty('USERPREF_DEFAULT__CLOSE_OTP_ON_COPY', false),
'copyOtpOnDisplay' => envUnlessEmpty('USERPREF_DEFAULT__COPY_OTP_ON_DISPLAY', false),
'clearSearchOnCopy' => envUnlessEmpty('USERPREF_DEFAULT__CLEAR_SEARCH_ON_COPY', false),
'useBasicQrcodeReader' => envUnlessEmpty('USERPREF_DEFAULT__USE_BASIC_QRCODE_READER', false),
'displayMode' => envUnlessEmpty('USERPREF_DEFAULT__DISPLAY_MODE', 'list'),
'showAccountsIcons' => envUnlessEmpty('USERPREF_DEFAULT__SHOW_ACCOUNTS_ICONS', true),
'kickUserAfter' => envUnlessEmpty('USERPREF_DEFAULT__KICK_USER_AFTER', 15),
'activeGroup' => 0,
'rememberActiveGroup' => envUnlessEmpty('USERPREF_DEFAULT__REMEMBER_ACTIVE_GROUP', true),
'viewDefaultGroupOnCopy' => envUnlessEmpty('USERPREF_DEFAULT__VIEW_DEFAULT_GROUP_ON_COPY', false),
'defaultGroup' => 0,
'defaultCaptureMode' => envUnlessEmpty('USERPREF_DEFAULT__DEFAULT_CAPTURE_MODE', 'livescan'),
'useDirectCapture' => envUnlessEmpty('USERPREF_DEFAULT__USE_DIRECT_CAPTURE', false),
'useWebauthnOnly' => envUnlessEmpty('USERPREF_DEFAULT__USE_WEBAUTHN_ONLY', false),
'getOfficialIcons' => envUnlessEmpty('USERPREF_DEFAULT__GET_OFFICIAL_ICONS', true),
'theme' => envUnlessEmpty('USERPREF_DEFAULT__THEME', 'system'),
'formatPassword' => envUnlessEmpty('USERPREF_DEFAULT__FORMAT_PASSWORD', true),
'formatPasswordBy' => envUnlessEmpty('USERPREF_DEFAULT__FORMAT_PASSWORD_BY', 0.5),
'lang' => envUnlessEmpty('USERPREF_DEFAULT__LANG', 'browser'),
'getOtpOnRequest' => envUnlessEmpty('USERPREF_DEFAULT__GET_OTP_ON_REQUEST', true),
'notifyOnNewAuthDevice' => envUnlessEmpty('USERPREF_DEFAULT__NOTIFY_ON_NEW_AUTH_DEVICE', false),
'notifyOnFailedLogin' => envUnlessEmpty('USERPREF_DEFAULT__NOTIFY_ON_FAILED_LOGIN', false),
'timezone' => envUnlessEmpty('USERPREF_DEFAULT__TIMEZONE', 'UTC'),
'sortCaseSensitive' => envUnlessEmpty('USERPREF_DEFAULT__SORT_CASE_SENSITIVE', false),
'autoCloseTimeout' => envUnlessEmpty('USERPREF_DEFAULT__AUTO_CLOSE_TIMEOUT', 2),
'AutoSaveQrcodedAccount' => envUnlessEmpty('USERPREF_DEFAULT__AUTO_SAVE_QRCODED_ACCOUNT', false),
'showEmailInFooter' => envUnlessEmpty('USERPREF_DEFAULT__SHOW_EMAIL_IN_FOOTER', true),
];
$nonLockablePreferences = [
'activeGroup',
'defaultGroup',
'useWebauthnOnly',
];
return [ return [
/* /*
@ -107,37 +149,17 @@ return [
| |
*/ */
'preferences' => [ 'preferences' => $preferences,
'showOtpAsDot' => false,
'showNextOtp' => false,
'revealDottedOTP' => false, /*
'closeOtpOnCopy' => false, |--------------------------------------------------------------------------
'copyOtpOnDisplay' => false, | List of user preferences locked against user customization
'clearSearchOnCopy' => false, | These settings cannot be overloaded and persisted by each user
'useBasicQrcodeReader' => false, |--------------------------------------------------------------------------
'displayMode' => 'list', |
'showAccountsIcons' => true, */
'kickUserAfter' => 15,
'activeGroup' => 0, 'lockedPreferences' => Helpers::lockedPreferences(Arr::except($preferences, $nonLockablePreferences)),
'rememberActiveGroup' => true,
'viewDefaultGroupOnCopy' => false,
'defaultGroup' => 0,
'defaultCaptureMode' => 'livescan',
'useDirectCapture' => false,
'useWebauthnOnly' => false,
'getOfficialIcons' => true,
'theme' => 'system',
'formatPassword' => true,
'formatPasswordBy' => 0.5,
'lang' => 'browser',
'getOtpOnRequest' => true,
'notifyOnNewAuthDevice' => false,
'notifyOnFailedLogin' => false,
'timezone' => env('APP_TIMEZONE', 'UTC'),
'sortCaseSensitive' => false,
'autoCloseTimeout' => 2,
'AutoSaveQrcodedAccount' => false,
'showEmailInFooter' => true,
],
]; ];

View File

@ -26,6 +26,7 @@
}, },
isIndented: Boolean, isIndented: Boolean,
isDisabled: Boolean, isDisabled: Boolean,
isLocked: Boolean,
}) })
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
@ -49,12 +50,14 @@
<template> <template>
<div class="field is-flex"> <div class="field is-flex">
<div v-if="isIndented" class="mx-2 pr-1" :style="{ 'opacity': isDisabled ? '0.5' : '1' }"> <div v-if="isIndented" class="mx-2 pr-1" :class="{ 'is-opacity-5' : isDisabled || isLocked }">
<FontAwesomeIcon class="has-text-grey" :icon="['fas', 'chevron-right']" transform="rotate-135"/> <FontAwesomeIcon class="has-text-grey" :icon="['fas', 'chevron-right']" transform="rotate-135"/>
</div> </div>
<div> <div>
<input :id="fieldName" type="checkbox" :name="fieldName" class="is-checkradio is-info" v-model="model" :disabled="isDisabled" :aria-describedby="help ? legendId : undefined" /> <input :id="fieldName" type="checkbox" :name="fieldName" class="is-checkradio is-info" v-model="model" :disabled="isDisabled || isLocked" :aria-describedby="help ? legendId : undefined" />
<label tabindex="0" :for="fieldName" class="label" :class="labelClass" v-html="$t(label)" v-on:keypress.space.prevent="toggleModel" /> <label tabindex="0" :for="fieldName" class="label" :class="labelClass" v-on:keypress.space.prevent="toggleModel">
{{ $t(label) }}<FontAwesomeIcon v-if="isLocked" :icon="['fas', 'lock']" class="ml-2" size="xs" />
</label>
<p :id="legendId" class="help" v-html="$t(help)" v-if="help" /> <p :id="legendId" class="help" v-html="$t(help)" v-if="help" />
</div> </div>
</div> </div>

View File

@ -42,6 +42,7 @@
default: null default: null
}, },
isIndented: Boolean, isIndented: Boolean,
isLocked: Boolean,
leftIcon: '', leftIcon: '',
rightIcon: '', rightIcon: '',
idSuffix: { idSuffix: {
@ -57,14 +58,16 @@
<template> <template>
<div class="mb-3" :class="{ 'pt-3' : hasOffset, 'is-flex' : isIndented }"> <div class="mb-3" :class="{ 'pt-3' : hasOffset, 'is-flex' : isIndented }">
<div v-if="isIndented" class="mx-2 pr-1" :style="{ 'opacity': isDisabled ? '0.5' : '1' }"> <div v-if="isIndented" class="mx-2 pr-1" :class="{ 'is-opacity-5' : isDisabled || isLocked }">
<FontAwesomeIcon class="has-text-grey" :icon="['fas', 'chevron-right']" transform="rotate-135"/> <FontAwesomeIcon class="has-text-grey" :icon="['fas', 'chevron-right']" transform="rotate-135"/>
</div> </div>
<div class="field" :class="{ 'is-flex-grow-5' : isIndented }"> <div class="field" :class="{ 'is-flex-grow-5' : isIndented }">
<label :for="inputId" class="label" :style="{ 'opacity': isDisabled ? '0.5' : '1' }" v-html="$t(label)"></label> <label :for="inputId" class="label" :class="{ 'is-opacity-5' : isDisabled || isLocked }">
{{ $t(label) }}<FontAwesomeIcon v-if="isLocked" :icon="['fas', 'lock']" class="ml-2" size="xs" />
</label>
<div class="control" :class="{ 'has-icons-left' : leftIcon, 'has-icons-right': rightIcon }"> <div class="control" :class="{ 'has-icons-left' : leftIcon, 'has-icons-right': rightIcon }">
<input <input
:disabled="isDisabled" :disabled="isDisabled || isLocked"
:id="inputId" :id="inputId"
:type="inputType" :type="inputType"
class="input" class="input"

View File

@ -54,14 +54,15 @@
idSuffix: { idSuffix: {
type: String, type: String,
default: '' default: ''
} },
isLocked: Boolean,
}) })
const { inputId } = useIdGenerator(props.inputType, props.fieldName + props.idSuffix) const { inputId } = useIdGenerator(props.inputType, props.fieldName + props.idSuffix)
const { valErrorId } = useValidationErrorIdGenerator(props.fieldName) const { valErrorId } = useValidationErrorIdGenerator(props.fieldName)
const legendId = useIdGenerator('legend', props.fieldName).inputId const legendId = useIdGenerator('legend', props.fieldName).inputId
const fieldIsLocked = ref(props.isDisabled || props.isEditMode) const fieldIsLocked = ref(props.isDisabled || props.isEditMode || props.isLocked)
const hasBeenTrimmed = ref(false) const hasBeenTrimmed = ref(false)
const componentKey = ref(0); const componentKey = ref(0);
@ -96,7 +97,9 @@
</script> </script>
<template> <template>
<label :for="inputId" class="label" v-html="$t(label)" /> <label :for="inputId" class="label">
{{ $t(label) }}<FontAwesomeIcon v-if="isLocked" :icon="['fas', 'lock']" class="ml-2" size="xs" />
</label>
<div class="field has-addons mb-0" :class="{ 'pt-3' : hasOffset }"> <div class="field has-addons mb-0" :class="{ 'pt-3' : hasOffset }">
<div class="control" :class="{ 'is-expanded': isExpanded }"> <div class="control" :class="{ 'is-expanded': isExpanded }">
<input <input

View File

@ -45,6 +45,7 @@
type: String, type: String,
default: '' default: ''
}, },
isLocked: Boolean,
}) })
const { inputId } = useIdGenerator(props.inputType, props.fieldName + props.idSuffix) const { inputId } = useIdGenerator(props.inputType, props.fieldName + props.idSuffix)
@ -84,10 +85,12 @@
<template> <template>
<div class="field" :class="{ 'pt-3' : hasOffset }"> <div class="field" :class="{ 'pt-3' : hasOffset }">
<label :for="inputId" class="label" v-html="$t(label)" /> <label :for="inputId" class="label">
{{ $t(label) }}<FontAwesomeIcon v-if="isLocked" :icon="['fas', 'lock']" class="ml-2" size="xs" />
</label>
<div class="control has-icons-right"> <div class="control has-icons-right">
<input <input
:disabled="isDisabled" :disabled="isDisabled || isLocked"
:id="inputId" :id="inputId"
:type="currentType" :type="currentType"
class="input" class="input"

View File

@ -23,6 +23,7 @@
}, },
isIndented: Boolean, isIndented: Boolean,
isDisabled: Boolean, isDisabled: Boolean,
isLocked: Boolean,
idSuffix: { idSuffix: {
type: String, type: String,
default: '' default: ''
@ -37,18 +38,20 @@
<template> <template>
<div class="field is-flex"> <div class="field is-flex">
<div v-if="isIndented" class="mx-2 pr-1" :style="{ 'opacity': isDisabled ? '0.5' : '1' }"> <div v-if="isIndented" class="mx-2 pr-1" :class="{ 'is-opacity-5' : isDisabled || isLocked }">
<FontAwesomeIcon class="has-text-grey" :icon="['fas', 'chevron-right']" transform="rotate-135"/> <FontAwesomeIcon class="has-text-grey" :icon="['fas', 'chevron-right']" transform="rotate-135"/>
</div> </div>
<div> <div>
<label :for="inputId" class="label" v-html="$t(label)" :style="{ 'opacity': isDisabled ? '0.5' : '1' }"></label> <label :for="inputId" class="label" :class="{ 'is-opacity-5' : isDisabled || isLocked }">
{{ $t(label) }}<FontAwesomeIcon v-if="isLocked" :icon="['fas', 'lock']" class="ml-2" size="xs" />
</label>
<div class="control"> <div class="control">
<div class="select"> <div class="select">
<select <select
:id="inputId" :id="inputId"
v-model="selected" v-model="selected"
v-on:change="$emit('update:modelValue', $event.target.value)" v-on:change="$emit('update:modelValue', $event.target.value)"
:disabled="isDisabled" :disabled="isDisabled || isLocked"
:aria-describedby="help ? legendId : undefined" :aria-describedby="help ? legendId : undefined"
:aria-invalid="fieldError != undefined" :aria-invalid="fieldError != undefined"
:aria-errormessage="fieldError != undefined ? valErrorId : undefined" :aria-errormessage="fieldError != undefined ? valErrorId : undefined"

View File

@ -42,6 +42,7 @@
default: null default: null
}, },
isIndented: Boolean, isIndented: Boolean,
isLocked: Boolean,
leftIcon: '', leftIcon: '',
rightIcon: '', rightIcon: '',
idSuffix: { idSuffix: {
@ -57,14 +58,16 @@
<template> <template>
<div class="mb-3" :class="{ 'pt-3' : hasOffset, 'is-flex' : isIndented }"> <div class="mb-3" :class="{ 'pt-3' : hasOffset, 'is-flex' : isIndented }">
<div v-if="isIndented" class="mx-2 pr-1" :style="{ 'opacity': isDisabled ? '0.5' : '1' }"> <div v-if="isIndented" class="mx-2 pr-1" :class="{ 'is-opacity-5' : isDisabled || isLocked }">
<FontAwesomeIcon class="has-text-grey" :icon="['fas', 'chevron-right']" transform="rotate-135"/> <FontAwesomeIcon class="has-text-grey" :icon="['fas', 'chevron-right']" transform="rotate-135"/>
</div> </div>
<div class="field" :class="{ 'is-flex-grow-5' : isIndented }"> <div class="field" :class="{ 'is-flex-grow-5' : isIndented }">
<label v-if="label" :for="inputId" class="label" v-html="$t(label)"></label> <label v-if="label" :for="inputId" class="label">
{{ $t(label) }}<FontAwesomeIcon v-if="isLocked" :icon="['fas', 'lock']" class="ml-2" size="xs" />
</label>
<div class="control" :class="{ 'has-icons-left' : leftIcon, 'has-icons-right': rightIcon }"> <div class="control" :class="{ 'has-icons-left' : leftIcon, 'has-icons-right': rightIcon }">
<textarea <textarea
:disabled="isDisabled" :disabled="isDisabled || isLocked"
:id="inputId" :id="inputId"
class="textarea" class="textarea"
:class="size" :class="size"

View File

@ -15,6 +15,7 @@
fieldError: [String], fieldError: [String],
hasOffset: Boolean, hasOffset: Boolean,
isDisabled: Boolean, isDisabled: Boolean,
isLocked: Boolean,
label: { label: {
type: String, type: String,
default: '' default: ''
@ -38,7 +39,9 @@
<template> <template>
<div class="field" :class="{ 'pt-3': hasOffset }"> <div class="field" :class="{ 'pt-3': hasOffset }">
<span v-if="label" class="label" v-html="$t(label)" /> <span v-if="label" class="label" :class="{ 'is-opacity-5' : isDisabled || isLocked }">
{{ $t(label) }}<FontAwesomeIcon v-if="isLocked" :icon="['fas', 'lock']" class="ml-2" size="xs" />
</span>
<div <div
id="rdoGroup" id="rdoGroup"
role="radiogroup" role="radiogroup"
@ -56,7 +59,7 @@
type="button" type="button"
class="button" class="button"
:aria-checked="modelValue===choice.value" :aria-checked="modelValue===choice.value"
:disabled="isDisabled" :disabled="isDisabled || isLocked"
:class="{ :class="{
'is-link': modelValue===choice.value, 'is-link': modelValue===choice.value,
'is-dark': mode==='dark', 'is-dark': mode==='dark',
@ -69,7 +72,7 @@
class="is-hidden" class="is-hidden"
:checked="modelValue===choice.value" :checked="modelValue===choice.value"
:value="choice.value" :value="choice.value"
:disabled="isDisabled" :disabled="isDisabled || isLocked"
/> />
<span v-if="choice.legend" v-html="$t(choice.legend)" class="is-block is-size-7" /> <span v-if="choice.legend" v-html="$t(choice.legend)" class="is-block is-size-7" />
<FontAwesomeIcon :icon="['fas',choice.icon]" v-if="choice.icon" class="mr-2" /> <FontAwesomeIcon :icon="['fas',choice.icon]" v-if="choice.icon" class="mr-2" />

View File

@ -6,7 +6,10 @@ export const useAppSettingsStore = defineStore({
id: 'appSettings', id: 'appSettings',
state: () => { state: () => {
return { ...window.appSettings } return {
lockedPreferences: window.lockedPreferences,
...window.appSettings
}
}, },
getters: { getters: {

View File

@ -137,10 +137,20 @@ export const useUserStore = defineStore({
* Refresh user preferences with backend state * Refresh user preferences with backend state
*/ */
refreshPreferences() { refreshPreferences() {
const appSettings = useAppSettingsStore()
userService.getPreferences({returnError: true}) userService.getPreferences({returnError: true})
.then(response => { .then(response => {
response.data.forEach(preference => { response.data.forEach(preference => {
this.preferences[preference.key] = preference.value this.preferences[preference.key] = preference.value
let index = appSettings.lockedPreferences.indexOf(preference.key)
if (preference.locked == true && index === -1) {
appSettings.lockedPreferences.push(preference.key)
}
else if (preference.locked == false && index > 0) {
appSettings.lockedPreferences.splice(index, 1)
}
}) })
}) })
.catch(error => { .catch(error => {

View File

@ -4,12 +4,14 @@
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { useGroups } from '@/stores/groups' import { useGroups } from '@/stores/groups'
import { useNotifyStore } from '@/stores/notify' import { useNotifyStore } from '@/stores/notify'
import { useAppSettingsStore } from '@/stores/appSettings'
import { timezones } from './timezones' import { timezones } from './timezones'
const $2fauth = inject('2fauth') const $2fauth = inject('2fauth')
const user = useUserStore() const user = useUserStore()
const groups = useGroups() const groups = useGroups()
const notify = useNotifyStore() const notify = useNotifyStore()
const appSettings = useAppSettingsStore()
const returnTo = useStorage($2fauth.prefix + 'returnTo', 'accounts') const returnTo = useStorage($2fauth.prefix + 'returnTo', 'accounts')
const layouts = [ const layouts = [
@ -121,8 +123,11 @@
<!-- user preferences --> <!-- user preferences -->
<div class="block"> <div class="block">
<h4 class="title is-4 has-text-grey-light">{{ $t('settings.general') }}</h4> <h4 class="title is-4 has-text-grey-light">{{ $t('settings.general') }}</h4>
<div v-if="appSettings.lockedPreferences.length > 0" class="notification is-warning">
{{ $t('settings.settings_managed_by_administrator') }}
</div>
<!-- Language --> <!-- Language -->
<FormSelect v-model="user.preferences.lang" @update:model-value="val => savePreference('lang', val)" :options="langs" fieldName="lang" label="settings.forms.language.label" help="settings.forms.language.help" /> <FormSelect v-model="user.preferences.lang" @update:model-value="val => savePreference('lang', val)" :options="langs" fieldName="lang" :isLocked="appSettings.lockedPreferences.includes('lang')" label="settings.forms.language.label" help="settings.forms.language.help" />
<div class="field help"> <div class="field help">
{{ $t('settings.forms.some_translation_are_missing') }} {{ $t('settings.forms.some_translation_are_missing') }}
<a class="ml-2" href="https://crowdin.com/project/2fauth"> <a class="ml-2" href="https://crowdin.com/project/2fauth">
@ -131,66 +136,66 @@
</a> </a>
</div> </div>
<!-- timezone --> <!-- timezone -->
<FormSelect v-model="user.preferences.timezone" @update:model-value="val => savePreference('timezone', val)" :options="timezones" fieldName="timezone" label="settings.forms.timezone.label" help="settings.forms.timezone.help" /> <FormSelect v-model="user.preferences.timezone" @update:model-value="val => savePreference('timezone', val)" :options="timezones" fieldName="timezone" :isLocked="appSettings.lockedPreferences.includes('timezone')" label="settings.forms.timezone.label" help="settings.forms.timezone.help" />
<!-- display mode --> <!-- display mode -->
<FormToggle v-model="user.preferences.displayMode" @update:model-value="val => savePreference('displayMode', val)" :choices="layouts" fieldName="displayMode" label="settings.forms.display_mode.label" help="settings.forms.display_mode.help"/> <FormToggle v-model="user.preferences.displayMode" @update:model-value="val => savePreference('displayMode', val)" :choices="layouts" fieldName="displayMode" :isLocked="appSettings.lockedPreferences.includes('displayMode')" label="settings.forms.display_mode.label" help="settings.forms.display_mode.help" />
<!-- theme --> <!-- theme -->
<FormToggle v-model="user.preferences.theme" @update:model-value="val => savePreference('theme', val)" :choices="themes" fieldName="theme" label="settings.forms.theme.label" help="settings.forms.theme.help"/> <FormToggle v-model="user.preferences.theme" @update:model-value="val => savePreference('theme', val)" :choices="themes" fieldName="theme" :isLocked="appSettings.lockedPreferences.includes('theme')" label="settings.forms.theme.label" help="settings.forms.theme.help" />
<!-- show icon --> <!-- show icon -->
<FormCheckbox v-model="user.preferences.showAccountsIcons" @update:model-value="val => savePreference('showAccountsIcons', val)" fieldName="showAccountsIcons" label="settings.forms.show_accounts_icons.label" help="settings.forms.show_accounts_icons.help" /> <FormCheckbox v-model="user.preferences.showAccountsIcons" @update:model-value="val => savePreference('showAccountsIcons', val)" fieldName="showAccountsIcons" :isLocked="appSettings.lockedPreferences.includes('showAccountsIcons')" label="settings.forms.show_accounts_icons.label" help="settings.forms.show_accounts_icons.help" />
<!-- Official icons --> <!-- Official icons -->
<FormCheckbox v-model="user.preferences.getOfficialIcons" @update:model-value="val => savePreference('getOfficialIcons', val)" fieldName="getOfficialIcons" label="settings.forms.get_official_icons.label" help="settings.forms.get_official_icons.help" /> <FormCheckbox v-model="user.preferences.getOfficialIcons" @update:model-value="val => savePreference('getOfficialIcons', val)" fieldName="getOfficialIcons" :isLocked="appSettings.lockedPreferences.includes('getOfficialIcons')" label="settings.forms.get_official_icons.label" help="settings.forms.get_official_icons.help" />
<!-- password format --> <!-- password format -->
<FormCheckbox v-model="user.preferences.formatPassword" @update:model-value="val => savePreference('formatPassword', val)" fieldName="formatPassword" label="settings.forms.password_format.label" help="settings.forms.password_format.help" /> <FormCheckbox v-model="user.preferences.formatPassword" @update:model-value="val => savePreference('formatPassword', val)" fieldName="formatPassword" :isLocked="appSettings.lockedPreferences.includes('formatPassword')" label="settings.forms.password_format.label" help="settings.forms.password_format.help" />
<FormToggle v-model="user.preferences.formatPasswordBy" @update:model-value="val => savePreference('formatPasswordBy', val)" :choices="passwordFormats" fieldName="formatPasswordBy" :isDisabled="!user.preferences.formatPassword" /> <FormToggle v-model="user.preferences.formatPasswordBy" @update:model-value="val => savePreference('formatPasswordBy', val)" :choices="passwordFormats" fieldName="formatPasswordBy" :isLocked="appSettings.lockedPreferences.includes('formatPasswordBy')" :isDisabled="!user.preferences.formatPassword" />
<!-- clear search on copy --> <!-- clear search on copy -->
<FormCheckbox v-model="user.preferences.clearSearchOnCopy" @update:model-value="val => savePreference('clearSearchOnCopy', val)" fieldName="clearSearchOnCopy" label="settings.forms.clear_search_on_copy.label" help="settings.forms.clear_search_on_copy.help" /> <FormCheckbox v-model="user.preferences.clearSearchOnCopy" @update:model-value="val => savePreference('clearSearchOnCopy', val)" fieldName="clearSearchOnCopy" :isLocked="appSettings.lockedPreferences.includes('clearSearchOnCopy')" label="settings.forms.clear_search_on_copy.label" help="settings.forms.clear_search_on_copy.help" />
<!-- sort case sensitive --> <!-- sort case sensitive -->
<FormCheckbox v-model="user.preferences.sortCaseSensitive" @update:model-value="val => savePreference('sortCaseSensitive', val)" fieldName="sortCaseSensitive" label="settings.forms.sort_case_sensitive.label" help="settings.forms.sort_case_sensitive.help" /> <FormCheckbox v-model="user.preferences.sortCaseSensitive" @update:model-value="val => savePreference('sortCaseSensitive', val)" fieldName="sortCaseSensitive" :isLocked="appSettings.lockedPreferences.includes('sortCaseSensitive')" label="settings.forms.sort_case_sensitive.label" help="settings.forms.sort_case_sensitive.help" />
<!-- show email in footer --> <!-- show email in footer -->
<FormCheckbox v-model="user.preferences.showEmailInFooter" @update:model-value="val => savePreference('showEmailInFooter', val)" fieldName="showEmailInFooter" label="settings.forms.show_email_in_footer.label" help="settings.forms.show_email_in_footer.help" /> <FormCheckbox v-model="user.preferences.showEmailInFooter" @update:model-value="val => savePreference('showEmailInFooter', val)" fieldName="showEmailInFooter" :isLocked="appSettings.lockedPreferences.includes('showEmailInFooter')" label="settings.forms.show_email_in_footer.label" help="settings.forms.show_email_in_footer.help" />
<h4 class="title is-4 pt-4 has-text-grey-light">{{ $t('groups.groups') }}</h4> <h4 class="title is-4 pt-4 has-text-grey-light">{{ $t('groups.groups') }}</h4>
<!-- default group --> <!-- default group -->
<FormSelect v-model="user.preferences.defaultGroup" @update:model-value="val => savePreference('defaultGroup', val)" :options="groupsList" fieldName="defaultGroup" label="settings.forms.default_group.label" help="settings.forms.default_group.help" /> <FormSelect v-model="user.preferences.defaultGroup" @update:model-value="val => savePreference('defaultGroup', val)" :options="groupsList" fieldName="defaultGroup" label="settings.forms.default_group.label" help="settings.forms.default_group.help" />
<!-- retain active group --> <!-- retain active group -->
<FormCheckbox v-model="user.preferences.rememberActiveGroup" @update:model-value="val => savePreference('rememberActiveGroup', val)" fieldName="rememberActiveGroup" label="settings.forms.remember_active_group.label" help="settings.forms.remember_active_group.help" /> <FormCheckbox v-model="user.preferences.rememberActiveGroup" @update:model-value="val => savePreference('rememberActiveGroup', val)" fieldName="rememberActiveGroup" :isLocked="appSettings.lockedPreferences.includes('rememberActiveGroup')" label="settings.forms.remember_active_group.label" help="settings.forms.remember_active_group.help" />
<!-- always return to default group after copying --> <!-- always return to default group after copying -->
<FormCheckbox v-model="user.preferences.viewDefaultGroupOnCopy" @update:model-value="val => savePreference('viewDefaultGroupOnCopy', val)" fieldName="viewDefaultGroupOnCopy" label="settings.forms.view_default_group_on_copy.label" help="settings.forms.view_default_group_on_copy.help" /> <FormCheckbox v-model="user.preferences.viewDefaultGroupOnCopy" @update:model-value="val => savePreference('viewDefaultGroupOnCopy', val)" fieldName="viewDefaultGroupOnCopy" :isLocked="appSettings.lockedPreferences.includes('viewDefaultGroupOnCopy')" label="settings.forms.view_default_group_on_copy.label" help="settings.forms.view_default_group_on_copy.help" />
<h4 class="title is-4 pt-4 has-text-grey-light">{{ $t('settings.security') }}</h4> <h4 class="title is-4 pt-4 has-text-grey-light">{{ $t('settings.security') }}</h4>
<!-- auto lock --> <!-- auto lock -->
<FormSelect v-model="user.preferences.kickUserAfter" @update:model-value="val => savePreference('kickUserAfter', val)" :options="kickUserAfters" fieldName="kickUserAfter" label="settings.forms.auto_lock.label" help="settings.forms.auto_lock.help" /> <FormSelect v-model="user.preferences.kickUserAfter" @update:model-value="val => savePreference('kickUserAfter', val)" :options="kickUserAfters" fieldName="kickUserAfter" :isLocked="appSettings.lockedPreferences.includes('kickUserAfter')" label="settings.forms.auto_lock.label" help="settings.forms.auto_lock.help" />
<!-- get OTP on request --> <!-- get OTP on request -->
<FormToggle v-model="user.preferences.getOtpOnRequest" @update:model-value="val => savePreference('getOtpOnRequest', val)" :choices="getOtpTriggers" fieldName="getOtpOnRequest" label="settings.forms.otp_generation.label" help="settings.forms.otp_generation.help"/> <FormToggle v-model="user.preferences.getOtpOnRequest" @update:model-value="val => savePreference('getOtpOnRequest', val)" :choices="getOtpTriggers" fieldName="getOtpOnRequest" :isLocked="appSettings.lockedPreferences.includes('getOtpOnRequest')" label="settings.forms.otp_generation.label" help="settings.forms.otp_generation.help"/>
<!-- close otp on copy --> <!-- close otp on copy -->
<FormCheckbox v-model="user.preferences.closeOtpOnCopy" @update:model-value="val => savePreference('closeOtpOnCopy', val)" fieldName="closeOtpOnCopy" label="settings.forms.close_otp_on_copy.label" help="settings.forms.close_otp_on_copy.help" :isDisabled="!user.preferences.getOtpOnRequest" :isIndented="true" /> <FormCheckbox v-model="user.preferences.closeOtpOnCopy" @update:model-value="val => savePreference('closeOtpOnCopy', val)" fieldName="closeOtpOnCopy" :isLocked="appSettings.lockedPreferences.includes('closeOtpOnCopy')" :isDisabled="!user.preferences.getOtpOnRequest" label="settings.forms.close_otp_on_copy.label" help="settings.forms.close_otp_on_copy.help" :isIndented="true" />
<!-- auto-close timeout --> <!-- auto-close timeout -->
<FormSelect v-model="user.preferences.autoCloseTimeout" @update:model-value="val => savePreference('autoCloseTimeout', val)" :options="autoCloseTimeout" fieldName="autoCloseTimeout" label="settings.forms.auto_close_timeout.label" help="settings.forms.auto_close_timeout.help" :isDisabled="!user.preferences.getOtpOnRequest" :isIndented="true" /> <FormSelect v-model="user.preferences.autoCloseTimeout" @update:model-value="val => savePreference('autoCloseTimeout', val)" :options="autoCloseTimeout" fieldName="autoCloseTimeout" :isLocked="appSettings.lockedPreferences.includes('autoCloseTimeout')" :isDisabled="!user.preferences.getOtpOnRequest" label="settings.forms.auto_close_timeout.label" help="settings.forms.auto_close_timeout.help" :isIndented="true" />
<!-- clear search on copy --> <!-- clear search on copy -->
<FormCheckbox v-model="user.preferences.copyOtpOnDisplay" @update:model-value="val => savePreference('copyOtpOnDisplay', val)" fieldName="copyOtpOnDisplay" label="settings.forms.copy_otp_on_display.label" help="settings.forms.copy_otp_on_display.help" :isDisabled="!user.preferences.getOtpOnRequest" :isIndented="true" /> <FormCheckbox v-model="user.preferences.copyOtpOnDisplay" @update:model-value="val => savePreference('copyOtpOnDisplay', val)" fieldName="copyOtpOnDisplay" :isLocked="appSettings.lockedPreferences.includes('copyOtpOnDisplay')" :isDisabled="!user.preferences.getOtpOnRequest" label="settings.forms.copy_otp_on_display.label" help="settings.forms.copy_otp_on_display.help" :isIndented="true" />
<!-- otp as dot --> <!-- otp as dot -->
<FormCheckbox v-model="user.preferences.showOtpAsDot" @update:model-value="val => savePreference('showOtpAsDot', val)" fieldName="showOtpAsDot" label="settings.forms.show_otp_as_dot.label" help="settings.forms.show_otp_as_dot.help" /> <FormCheckbox v-model="user.preferences.showOtpAsDot" @update:model-value="val => savePreference('showOtpAsDot', val)" fieldName="showOtpAsDot" :isLocked="appSettings.lockedPreferences.includes('showOtpAsDot')" label="settings.forms.show_otp_as_dot.label" help="settings.forms.show_otp_as_dot.help" />
<!-- reveal dotted OTPs --> <!-- reveal dotted OTPs -->
<FormCheckbox v-model="user.preferences.revealDottedOTP" @update:model-value="val => savePreference('revealDottedOTP', val)" fieldName="revealDottedOTP" label="settings.forms.reveal_dotted_otp.label" help="settings.forms.reveal_dotted_otp.help" :isDisabled="!user.preferences.showOtpAsDot" :isIndented="true" /> <FormCheckbox v-model="user.preferences.revealDottedOTP" @update:model-value="val => savePreference('revealDottedOTP', val)" fieldName="revealDottedOTP" :isLocked="appSettings.lockedPreferences.includes('revealDottedOTP')" :isDisabled="!user.preferences.showOtpAsDot" label="settings.forms.reveal_dotted_otp.label" help="settings.forms.reveal_dotted_otp.help" :isIndented="true" />
<!-- show next OTP --> <!-- show next OTP -->
<FormCheckbox v-model="user.preferences.showNextOtp" @update:model-value="val => savePreference('showNextOtp', val)" fieldName="showNextOtp" label="settings.forms.show_next_otp.label" help="settings.forms.show_next_otp.help" /> <FormCheckbox v-model="user.preferences.showNextOtp" @update:model-value="val => savePreference('showNextOtp', val)" fieldName="showNextOtp" :isLocked="appSettings.lockedPreferences.includes('showNextOtp')" label="settings.forms.show_next_otp.label" help="settings.forms.show_next_otp.help" />
<h4 class="title is-4 pt-4 has-text-grey-light">{{ $t('settings.notifications') }}</h4> <h4 class="title is-4 pt-4 has-text-grey-light">{{ $t('settings.notifications') }}</h4>
<!-- on new device --> <!-- on new device -->
<FormCheckbox v-model="user.preferences.notifyOnNewAuthDevice" @update:model-value="val => savePreference('notifyOnNewAuthDevice', val)" fieldName="notifyOnNewAuthDevice" label="settings.forms.notify_on_new_auth_device.label" help="settings.forms.notify_on_new_auth_device.help" /> <FormCheckbox v-model="user.preferences.notifyOnNewAuthDevice" @update:model-value="val => savePreference('notifyOnNewAuthDevice', val)" fieldName="notifyOnNewAuthDevice" :isLocked="appSettings.lockedPreferences.includes('notifyOnNewAuthDevice')" label="settings.forms.notify_on_new_auth_device.label" help="settings.forms.notify_on_new_auth_device.help" />
<!-- on failed login --> <!-- on failed login -->
<FormCheckbox v-model="user.preferences.notifyOnFailedLogin" @update:model-value="val => savePreference('notifyOnFailedLogin', val)" fieldName="notifyOnFailedLogin" label="settings.forms.notify_on_failed_login.label" help="settings.forms.notify_on_failed_login.help" /> <FormCheckbox v-model="user.preferences.notifyOnFailedLogin" @update:model-value="val => savePreference('notifyOnFailedLogin', val)" fieldName="notifyOnFailedLogin" :isLocked="appSettings.lockedPreferences.includes('notifyOnFailedLogin')" label="settings.forms.notify_on_failed_login.label" help="settings.forms.notify_on_failed_login.help" />
<h4 class="title is-4 pt-4 has-text-grey-light">{{ $t('settings.data_input') }}</h4> <h4 class="title is-4 pt-4 has-text-grey-light">{{ $t('settings.data_input') }}</h4>
<!-- auto-save QrCoded account --> <!-- auto-save QrCoded account -->
<FormCheckbox v-model="user.preferences.AutoSaveQrcodedAccount" @update:model-value="val => savePreference('AutoSaveQrcodedAccount', val)" fieldName="AutoSaveQrcodedAccount" label="settings.forms.auto_save_qrcoded_account.label" help="settings.forms.auto_save_qrcoded_account.help" /> <FormCheckbox v-model="user.preferences.AutoSaveQrcodedAccount" @update:model-value="val => savePreference('AutoSaveQrcodedAccount', val)" fieldName="AutoSaveQrcodedAccount" :isLocked="appSettings.lockedPreferences.includes('AutoSaveQrcodedAccount')" label="settings.forms.auto_save_qrcoded_account.label" help="settings.forms.auto_save_qrcoded_account.help" />
<!-- basic qrcode --> <!-- basic qrcode -->
<FormCheckbox v-model="user.preferences.useBasicQrcodeReader" @update:model-value="val => savePreference('useBasicQrcodeReader', val)" fieldName="useBasicQrcodeReader" label="settings.forms.use_basic_qrcode_reader.label" help="settings.forms.use_basic_qrcode_reader.help" /> <FormCheckbox v-model="user.preferences.useBasicQrcodeReader" @update:model-value="val => savePreference('useBasicQrcodeReader', val)" fieldName="useBasicQrcodeReader" :isLocked="appSettings.lockedPreferences.includes('useBasicQrcodeReader')" label="settings.forms.use_basic_qrcode_reader.label" help="settings.forms.use_basic_qrcode_reader.help" />
<!-- direct capture --> <!-- direct capture -->
<FormCheckbox v-model="user.preferences.useDirectCapture" @update:model-value="val => savePreference('useDirectCapture', val)" fieldName="useDirectCapture" label="settings.forms.useDirectCapture.label" help="settings.forms.useDirectCapture.help" /> <FormCheckbox v-model="user.preferences.useDirectCapture" @update:model-value="val => savePreference('useDirectCapture', val)" fieldName="useDirectCapture" :isLocked="appSettings.lockedPreferences.includes('useDirectCapture')" label="settings.forms.useDirectCapture.label" help="settings.forms.useDirectCapture.help" />
<!-- default capture mode --> <!-- default capture mode -->
<FormSelect v-model="user.preferences.defaultCaptureMode" @update:model-value="val => savePreference('defaultCaptureMode', val)" :options="captureModes" fieldName="defaultCaptureMode" label="settings.forms.defaultCaptureMode.label" help="settings.forms.defaultCaptureMode.help" :isDisabled="!user.preferences.useDirectCapture" :isIndented="true" /> <FormSelect v-model="user.preferences.defaultCaptureMode" @update:model-value="val => savePreference('defaultCaptureMode', val)" :options="captureModes" fieldName="defaultCaptureMode" :isLocked="appSettings.lockedPreferences.includes('defaultCaptureMode')" :isDisabled="!user.preferences.useDirectCapture" label="settings.forms.defaultCaptureMode.label" help="settings.forms.defaultCaptureMode.help" :isIndented="true" />
</div> </div>
</form> </form>
</FormWrapper> </FormWrapper>

View File

@ -43,6 +43,7 @@ return [
], ],
'make_sure_copy_token' => 'Make sure to copy your personal access token now. You wont be able to see it again!', 'make_sure_copy_token' => 'Make sure to copy your personal access token now. You wont be able to see it again!',
'data_input' => 'Data input', 'data_input' => 'Data input',
'settings_managed_by_administrator' => 'Some settings are being managed by your administrator',
'forms' => [ 'forms' => [
'edit_settings' => 'Edit settings', 'edit_settings' => 'Edit settings',
'setting_saved' => 'Settings saved', 'setting_saved' => 'Settings saved',
@ -58,23 +59,23 @@ return [
'help' => 'The time zone applied to all dates and times displayed in the application' 'help' => 'The time zone applied to all dates and times displayed in the application'
], ],
'show_otp_as_dot' => [ 'show_otp_as_dot' => [
'label' => 'Show generated <abbr title="One-Time Password">OTP</abbr> as dot', 'label' => 'Show generated OTP as dot',
'help' => 'Replace generated password characters with *** to ensure confidentiality. Does not affect the copy/paste feature' 'help' => 'Replace generated password characters with *** to ensure confidentiality. Does not affect the copy/paste feature'
], ],
'reveal_dotted_otp' => [ 'reveal_dotted_otp' => [
'label' => 'Reveal obscured <abbr title="One-Time Password">OTP</abbr>', 'label' => 'Reveal obscured OTP',
'help' => 'Let the ability to temporarily reveal Dot-Obscured passwords' 'help' => 'Let the ability to temporarily reveal Dot-Obscured passwords'
], ],
'close_otp_on_copy' => [ 'close_otp_on_copy' => [
'label' => 'Close <abbr title="One-Time Password">OTP</abbr> after copy', 'label' => 'Close OTP after copy',
'help' => 'Click on a generated password to copy it automatically hides it from the screen' 'help' => 'Click on a generated password to copy it automatically hides it from the screen'
], ],
'show_next_otp' => [ 'show_next_otp' => [
'label' => 'Show next <abbr title="One-Time Password">OTP</abbr>', 'label' => 'Show next OTP',
'help' => 'Preview the next password, i.e. the password that will replace the current password when it expires. Preferences set for the current OTP also apply to the next one (formatting, show as dot)' 'help' => 'Preview the next password, i.e. the password that will replace the current password when it expires. Preferences set for the current OTP also apply to the next one (formatting, show as dot)'
], ],
'auto_close_timeout' => [ 'auto_close_timeout' => [
'label' => 'Auto close <abbr title="One-Time Password">OTP</abbr>', 'label' => 'Auto close OTP',
'help' => 'Automatically hide on-screen password after a timeout. This avoids unnecessary requests for fresh passwords if you forget to close the password view.' 'help' => 'Automatically hide on-screen password after a timeout. This avoids unnecessary requests for fresh passwords if you forget to close the password view.'
], ],
'clear_search_on_copy' => [ 'clear_search_on_copy' => [
@ -86,7 +87,7 @@ return [
'help' => 'When invoked, force the Sort function to sort accounts on a case-sensitive basis' 'help' => 'When invoked, force the Sort function to sort accounts on a case-sensitive basis'
], ],
'copy_otp_on_display' => [ 'copy_otp_on_display' => [
'label' => 'Copy <abbr title="One-Time Password">OTP</abbr> on display', 'label' => 'Copy OTP on display',
'help' => 'Automatically copy a generated password right after it appears on screen. Due to browsers limitations, only the first <abbr title="Time-based One-Time Password">TOTP</abbr> password will be copied, not the rotating ones' 'help' => 'Automatically copy a generated password right after it appears on screen. Due to browsers limitations, only the first <abbr title="Time-based One-Time Password">TOTP</abbr> password will be copied, not the rotating ones'
], ],
'use_basic_qrcode_reader' => [ 'use_basic_qrcode_reader' => [

View File

@ -78,7 +78,7 @@ return [
], ],
'plain_text' => 'Plain text', 'plain_text' => 'Plain text',
'otp_type' => [ 'otp_type' => [
'label' => 'Choose the type of <abbr title="One-Time Password">OTP</abbr> to create', 'label' => 'Choose the type of OTP to create',
'help' => 'Time-based OTP or HMAC-based OTP or Steam OTP' 'help' => 'Time-based OTP or HMAC-based OTP or Steam OTP'
], ],
'digits' => [ 'digits' => [

View File

@ -27,6 +27,7 @@
var appConfig = {!! $appConfig !!}; var appConfig = {!! $appConfig !!};
var urls = {!! $urls !!}; var urls = {!! $urls !!};
var defaultPreferences = {!! $defaultPreferences->toJson() !!}; var defaultPreferences = {!! $defaultPreferences->toJson() !!};
var lockedPreferences = {!! $lockedPreferences->toJson() !!};
var appVersion = '{{ config("2fauth.version") }}'; var appVersion = '{{ config("2fauth.version") }}';
var isDemoApp = {!! $isDemoApp !!}; var isDemoApp = {!! $isDemoApp !!};
var isTestingApp = {!! $isTestingApp !!}; var isTestingApp = {!! $isTestingApp !!};

View File

@ -24,6 +24,7 @@ class UserControllerTest extends FeatureTestCase
private const PREFERENCE_JSON_STRUCTURE = [ private const PREFERENCE_JSON_STRUCTURE = [
'key', 'key',
'value', 'value',
'locked',
]; ];
public function setUp() : void public function setUp() : void
@ -62,7 +63,7 @@ class UserControllerTest extends FeatureTestCase
} }
#[Test] #[Test]
public function test_allPreferences_returns_preferences_with_default_values() public function test_allPreferences_returns_preferences_with_default_config_values()
{ {
$response = $this->actingAs($this->user, 'api-guard') $response = $this->actingAs($this->user, 'api-guard')
->json('GET', '/api/v1/user/preferences') ->json('GET', '/api/v1/user/preferences')
@ -108,7 +109,7 @@ class UserControllerTest extends FeatureTestCase
} }
#[Test] #[Test]
public function test_showPreference_returns_preference_with_default_value() public function test_showPreference_returns_preference_with_default_config_value()
{ {
/** /**
* @var \App\Models\User|\Illuminate\Contracts\Auth\Authenticatable * @var \App\Models\User|\Illuminate\Contracts\Auth\Authenticatable
@ -121,6 +122,28 @@ class UserControllerTest extends FeatureTestCase
->assertExactJson([ ->assertExactJson([
'key' => 'showOtpAsDot', 'key' => 'showOtpAsDot',
'value' => config('2fauth.preferences.showOtpAsDot'), 'value' => config('2fauth.preferences.showOtpAsDot'),
'locked' => false,
]);
}
#[Test]
public function test_showPreference_returns_preference_with_locked_default_env_value()
{
// See .env.testing which sets USERPREF_DEFAULT__THEME=light
// while config/2fauth.php sets the default value to 'system'
/**
* @var \App\Models\User|\Illuminate\Contracts\Auth\Authenticatable
*/
$this->user = User::factory()->create();
$response = $this->actingAs($this->user, 'api-guard')
->json('GET', '/api/v1/user/preferences/theme')
->assertOk()
->assertExactJson([
'key' => 'theme',
'value' => 'light',
'locked' => true,
]); ]);
} }
@ -190,4 +213,16 @@ class UserControllerTest extends FeatureTestCase
]) ])
->assertStatus(422); ->assertStatus(422);
} }
#[Test]
public function test_setPreference_on_locked_preference_returns_forbidden()
{
// See .env.testing which sets USERPREF_LOCKED__THEME=true
$response = $this->actingAs($this->user, 'api-guard')
->json('PUT', '/api/v1/user/preferences/theme', [
'key' => 'theme',
'value' => 'system',
])
->assertStatus(403);
}
} }

View File

@ -218,4 +218,42 @@ class HelpersTest extends TestCase
], ],
]; ];
} }
#[Test]
public function test_lockedPreferences_returns_locked_preferences()
{
// See .env.testing which sets USERPREF_DEFAULT__THEME=light
// while config/2fauth.php sets the default value to 'system'
$lockedPreferences = Helpers::lockedPreferences(config('2fauth.preferences'));
$this->assertContains('theme', $lockedPreferences);
}
#[Test]
public function test_lockedPreferences_returns_empty_array_when_empty_array_is_provided()
{
$param = [];
$lockedPreferences = Helpers::lockedPreferences($param);
$this->assertEquals([], $lockedPreferences);
}
#[Test]
public function test_lockedPreferences_excludes_preference_when_env_var_is_empty()
{
// See .env.testing which sets USERPREF_LOCKED__DISPLAY_MODE=
$lockedPreferences = Helpers::lockedPreferences(config('2fauth.preferences'));
$this->assertNotContains('displayMode', $lockedPreferences);
}
#[Test]
public function test_lockedPreferences_excludes_preference_when_env_var_is_malformed()
{
// See .env.testing which sets USERPREF_LOCKED___FORMAT_PASSWORD=false
$lockedPreferences = Helpers::lockedPreferences(config('2fauth.preferences'));
$this->assertNotContains('formatPassword', $lockedPreferences);
}
} }

2
vite.version.js vendored
View File

@ -12,6 +12,6 @@ const parser = new Engine({
const phpFile = fs.readFileSync("./config/2fauth.php") const phpFile = fs.readFileSync("./config/2fauth.php")
const phpContent = parser.parseCode(phpFile) const phpContent = parser.parseCode(phpFile)
const version = phpContent.children[0].expr.items[0].value.value const version = phpContent.children[4].expr.items[0].value.value
export default version export default version