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_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_HOST=localhost
# DB_PORT=3306

View File

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

View File

@ -49,4 +49,22 @@ class Helpers
{
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;
$subdir = config('2fauth.config.appSubdirectory') ? '/' . config('2fauth.config.appSubdirectory') : '';
$defaultPreferences = collect(config('2fauth.preferences')); /** @phpstan-ignore-line */
$lockedPreferences = collect(config('2fauth.lockedPreferences')); /** @phpstan-ignore-line */
$isDemoApp = config('2fauth.config.isDemoApp') ? 'true' : 'false';
$isTestingApp = config('2fauth.config.isTestingApp') ? 'true' : 'false';
$lang = App::getLocale();
@ -69,6 +70,7 @@ class SinglePageController extends Controller
'exportSchemaUrl' => $exportSchemaUrl,
]),
'defaultPreferences' => $defaultPreferences,
'lockedPreferences' => $lockedPreferences,
'subdirectory' => $subdir,
'isDemoApp' => $isDemoApp,
'isTestingApp' => $isTestingApp,

View File

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

View File

@ -1,5 +1,47 @@
<?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 [
/*
@ -107,37 +149,17 @@ return [
|
*/
'preferences' => [
'showOtpAsDot' => false,
'showNextOtp' => false,
'revealDottedOTP' => false,
'closeOtpOnCopy' => false,
'copyOtpOnDisplay' => false,
'clearSearchOnCopy' => false,
'useBasicQrcodeReader' => false,
'displayMode' => 'list',
'showAccountsIcons' => true,
'kickUserAfter' => 15,
'activeGroup' => 0,
'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,
],
'preferences' => $preferences,
/*
|--------------------------------------------------------------------------
| List of user preferences locked against user customization
| These settings cannot be overloaded and persisted by each user
|--------------------------------------------------------------------------
|
*/
'lockedPreferences' => Helpers::lockedPreferences(Arr::except($preferences, $nonLockablePreferences)),
];

View File

@ -26,6 +26,7 @@
},
isIndented: Boolean,
isDisabled: Boolean,
isLocked: Boolean,
})
const emit = defineEmits(['update:modelValue'])
@ -49,12 +50,14 @@
<template>
<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"/>
</div>
<div>
<input :id="fieldName" type="checkbox" :name="fieldName" class="is-checkradio is-info" v-model="model" :disabled="isDisabled" :aria-describedby="help ? legendId : undefined" />
<label tabindex="0" :for="fieldName" class="label" :class="labelClass" v-html="$t(label)" v-on:keypress.space.prevent="toggleModel" />
<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-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" />
</div>
</div>

View File

@ -42,6 +42,7 @@
default: null
},
isIndented: Boolean,
isLocked: Boolean,
leftIcon: '',
rightIcon: '',
idSuffix: {
@ -57,14 +58,16 @@
<template>
<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"/>
</div>
<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 }">
<input
:disabled="isDisabled"
:disabled="isDisabled || isLocked"
:id="inputId"
:type="inputType"
class="input"

View File

@ -54,14 +54,15 @@
idSuffix: {
type: String,
default: ''
}
},
isLocked: Boolean,
})
const { inputId } = useIdGenerator(props.inputType, props.fieldName + props.idSuffix)
const { valErrorId } = useValidationErrorIdGenerator(props.fieldName)
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 componentKey = ref(0);
@ -96,7 +97,9 @@
</script>
<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="control" :class="{ 'is-expanded': isExpanded }">
<input

View File

@ -45,6 +45,7 @@
type: String,
default: ''
},
isLocked: Boolean,
})
const { inputId } = useIdGenerator(props.inputType, props.fieldName + props.idSuffix)
@ -84,10 +85,12 @@
<template>
<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">
<input
:disabled="isDisabled"
:disabled="isDisabled || isLocked"
:id="inputId"
:type="currentType"
class="input"

View File

@ -23,6 +23,7 @@
},
isIndented: Boolean,
isDisabled: Boolean,
isLocked: Boolean,
idSuffix: {
type: String,
default: ''
@ -37,18 +38,20 @@
<template>
<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"/>
</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="select">
<select
:id="inputId"
v-model="selected"
v-on:change="$emit('update:modelValue', $event.target.value)"
:disabled="isDisabled"
:disabled="isDisabled || isLocked"
:aria-describedby="help ? legendId : undefined"
:aria-invalid="fieldError != undefined"
:aria-errormessage="fieldError != undefined ? valErrorId : undefined"

View File

@ -42,6 +42,7 @@
default: null
},
isIndented: Boolean,
isLocked: Boolean,
leftIcon: '',
rightIcon: '',
idSuffix: {
@ -57,14 +58,16 @@
<template>
<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"/>
</div>
<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 }">
<textarea
:disabled="isDisabled"
:disabled="isDisabled || isLocked"
:id="inputId"
class="textarea"
:class="size"

View File

@ -15,6 +15,7 @@
fieldError: [String],
hasOffset: Boolean,
isDisabled: Boolean,
isLocked: Boolean,
label: {
type: String,
default: ''
@ -38,7 +39,9 @@
<template>
<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
id="rdoGroup"
role="radiogroup"
@ -56,7 +59,7 @@
type="button"
class="button"
:aria-checked="modelValue===choice.value"
:disabled="isDisabled"
:disabled="isDisabled || isLocked"
:class="{
'is-link': modelValue===choice.value,
'is-dark': mode==='dark',
@ -69,7 +72,7 @@
class="is-hidden"
:checked="modelValue===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" />
<FontAwesomeIcon :icon="['fas',choice.icon]" v-if="choice.icon" class="mr-2" />

View File

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

View File

@ -137,10 +137,20 @@ export const useUserStore = defineStore({
* Refresh user preferences with backend state
*/
refreshPreferences() {
const appSettings = useAppSettingsStore()
userService.getPreferences({returnError: true})
.then(response => {
response.data.forEach(preference => {
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 => {

View File

@ -4,12 +4,14 @@
import { useUserStore } from '@/stores/user'
import { useGroups } from '@/stores/groups'
import { useNotifyStore } from '@/stores/notify'
import { useAppSettingsStore } from '@/stores/appSettings'
import { timezones } from './timezones'
const $2fauth = inject('2fauth')
const user = useUserStore()
const groups = useGroups()
const notify = useNotifyStore()
const appSettings = useAppSettingsStore()
const returnTo = useStorage($2fauth.prefix + 'returnTo', 'accounts')
const layouts = [
@ -121,8 +123,11 @@
<!-- user preferences -->
<div class="block">
<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 -->
<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">
{{ $t('settings.forms.some_translation_are_missing') }}
<a class="ml-2" href="https://crowdin.com/project/2fauth">
@ -131,66 +136,66 @@
</a>
</div>
<!-- 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 -->
<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 -->
<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 -->
<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 -->
<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 -->
<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" />
<FormToggle v-model="user.preferences.formatPasswordBy" @update:model-value="val => savePreference('formatPasswordBy', val)" :choices="passwordFormats" fieldName="formatPasswordBy" :isDisabled="!user.preferences.formatPassword" />
<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" :isLocked="appSettings.lockedPreferences.includes('formatPasswordBy')" :isDisabled="!user.preferences.formatPassword" />
<!-- 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 -->
<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 -->
<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>
<!-- 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" />
<!-- 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 -->
<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>
<!-- 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 -->
<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 -->
<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 -->
<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 -->
<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 -->
<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 -->
<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 -->
<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>
<!-- 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 -->
<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>
<!-- 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 -->
<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 -->
<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 -->
<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>
</form>
</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!',
'data_input' => 'Data input',
'settings_managed_by_administrator' => 'Some settings are being managed by your administrator',
'forms' => [
'edit_settings' => 'Edit settings',
'setting_saved' => 'Settings saved',
@ -58,23 +59,23 @@ return [
'help' => 'The time zone applied to all dates and times displayed in the application'
],
'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'
],
'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'
],
'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'
],
'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)'
],
'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.'
],
'clear_search_on_copy' => [
@ -86,7 +87,7 @@ return [
'help' => 'When invoked, force the Sort function to sort accounts on a case-sensitive basis'
],
'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'
],
'use_basic_qrcode_reader' => [

View File

@ -78,7 +78,7 @@ return [
],
'plain_text' => 'Plain text',
'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'
],
'digits' => [

View File

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

View File

@ -24,6 +24,7 @@ class UserControllerTest extends FeatureTestCase
private const PREFERENCE_JSON_STRUCTURE = [
'key',
'value',
'locked',
];
public function setUp() : void
@ -62,7 +63,7 @@ class UserControllerTest extends FeatureTestCase
}
#[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')
->json('GET', '/api/v1/user/preferences')
@ -108,7 +109,7 @@ class UserControllerTest extends FeatureTestCase
}
#[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
@ -121,6 +122,28 @@ class UserControllerTest extends FeatureTestCase
->assertExactJson([
'key' => '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);
}
#[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 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