Add User management features to front-end

This commit is contained in:
Bubka 2024-01-29 08:55:56 +01:00
parent 91242860f0
commit 6aeb7dcbc9
20 changed files with 844 additions and 56 deletions

View File

@ -120,14 +120,18 @@ a:hover {
}
// has-text-weight-semibold / $weight-semibold
.group-item {
.group-item, .list-item {
border-bottom: 1px solid $grey-lighter;
padding: 0.75rem;
}
:root[data-theme="dark"] .group-item {
:root[data-theme="dark"] .group-item,
:root[data-theme="dark"] .list-item {
border-color: $grey-darker;
color: $light;
}
:root[data-theme="dark"] .list-item {
color: $grey-lighter;
}
.group-item:first-of-type {
margin-top: 2.5rem;
@ -386,6 +390,24 @@ a:hover {
background: none !important;
}
.is-left-bordered-link,
.is-left-bordered-warning,
.is-left-bordered-danger {
border: none;
border-left-style: solid;
border-left-width: 3px;
padding-left: $size-normal;
}
.is-left-bordered-link {
border-left-color: $link;
}
.is-left-bordered-warning {
border-left-color: $warning;
}
.is-left-bordered-danger {
border-left-color: $danger;
}
.add-icon-button {
height: 64px;
width: 64px;

View File

@ -0,0 +1,35 @@
<script setup>
const tabs = ref([
{
'name' : 'admin.app_setup',
'view' : 'admin.appSetup',
'id' : 'lnkTabApp'
},
{
'name' : 'admin.users',
'view' : 'admin.users',
'id' : 'lnkTabUsers'
},
])
const props = defineProps({
activeTab: {
type: String,
default: ''
},
})
</script>
<template>
<div class="options-header">
<ResponsiveWidthWrapper>
<div class="tabs is-centered is-fullwidth">
<ul>
<li v-for="tab in tabs" :key="tab.view" :class="{ 'is-active': tab.view === props.activeTab }">
<RouterLink :id="tab.id" :to="{ name: tab.view }">{{ $t(tab.name) }}</RouterLink>
</li>
</ul>
</div>
</ResponsiveWidthWrapper>
</div>
</template>

View File

@ -46,8 +46,11 @@
</router-link>
</div>
<div v-else>
<router-link id="lnkSettings" :to="{ name: 'settings.options' }" class="has-text-grey">
{{ $t('settings.settings') }}<span v-if="appSettings.latestRelease && appSettings.checkForUpdate" class="release-flag"></span>
<router-link id="lnkSettings" :to="{ name: 'settings.options' }" class="has-text-grey">
{{ $t('settings.settings') }}
</router-link>
<router-link v-if="user.isAdmin" id="lnkAdmin" :to="{ name: 'admin.appSetup' }" class="has-text-grey">
| {{ $t('admin.admin') }}<span v-if="appSettings.latestRelease && appSettings.checkForUpdate" class="release-flag"></span>
</router-link>
<span v-if="!$2fauth.config.proxyAuth || ($2fauth.config.proxyAuth && $2fauth.config.proxyLogoutUrl)">
- <button id="lnkSignOut" class="button is-text is-like-text has-text-grey" @click="logout">{{ $t('auth.sign_out') }}</button>

View File

@ -0,0 +1,17 @@
<script setup>
</script>
<template>
<div class="list-item is-size-5 is-size-6-mobile is-flex is-justify-content-space-between">
<div>
<slot name="item"></slot>
<slot name="legend"></slot>
</div>
<div>
<div class="tags ml-3">
<slot name="buttons"></slot>
</div>
</div>
</div>
</template>

View File

@ -6,6 +6,7 @@ import { useAppSettingsStore } from '@/stores/appSettings'
import { useNotifyStore } from '@/stores/notify'
import authGuard from './middlewares/authGuard'
import adminOnly from './middlewares/adminOnly'
import starter from './middlewares/starter'
import noEmptyError from './middlewares/noEmptyError'
import noRegistration from './middlewares/noRegistration'
@ -34,6 +35,11 @@ const router = createRouter({
{ path: '/settings/webauthn/:credentialId/edit', name: 'settings.webauthn.editCredential', component: () => import('../views/settings/Credentials/Edit.vue'), meta: { middlewares: [authGuard], watchedByKicker: true, showAbout: true }, props: true },
{ path: '/settings/webauthn', name: 'settings.webauthn.devices', component: () => import('../views/settings/WebAuthn.vue'), meta: { middlewares: [authGuard], watchedByKicker: true, showAbout: true } },
{ path: '/admin/app', name: 'admin.appSetup', component: () => import('../views/admin/AppSetup.vue'), meta: { middlewares: [authGuard, adminOnly], watchedByKicker: true, showAbout: true } },
{ path: '/admin/users', name: 'admin.users', component: () => import('../views/admin/Users.vue'), meta: { middlewares: [authGuard, adminOnly], watchedByKicker: true, showAbout: true } },
{ path: '/admin/users/create', name: 'admin.users.create', component: () => import('../views/admin/users/Create.vue'), meta: { middlewares: [authGuard, adminOnly], watchedByKicker: true, showAbout: true } },
{ path: '/admin/users/:userId/manage', name: 'admin.users.manage', component: () => import('../views/admin/users/Manage.vue'), meta: { middlewares: [authGuard, adminOnly], watchedByKicker: true, showAbout: true }, props: true },
{ path: '/login', name: 'login', component: () => import('../views/auth/Login.vue'), meta: { middlewares: [skipIfAuthProxy, setReturnTo], showAbout: true } },
{ path: '/register', name: 'register', component: () => import('../views/auth/Register.vue'), meta: { middlewares: [skipIfAuthProxy, noRegistration, setReturnTo], showAbout: true } },
{ path: '/password/request', name: 'password.request', component: () => import('../views/auth/RequestReset.vue'), meta: { middlewares: [skipIfAuthProxy, setReturnTo], showAbout: true } },

View File

@ -0,0 +1,14 @@
/**
* Allows an authenticated user to access the route only if he has administrator rights
*/
export default async function adminOnly({ to, next, nextMiddleware, stores }) {
const { user } = stores
const { notify } = stores
if (! user.isAdmin) {
let err = new Error('unauthorized')
err.response.status = 403
notify.error(err)
}
else nextMiddleware()
}

View File

@ -9,6 +9,7 @@ export default async function authGuard({ to, next, nextMiddleware, stores }) {
await authService.getCurrentUser({ returnError: true }).then(async (response) => {
const currentUser = response.data
await user.loginAs({
id: currentUser.id,
name: currentUser.name,
email: currentUser.email,
oauth_provider: currentUser.oauth_provider,

View File

@ -50,7 +50,7 @@ export default {
* Get all user PATs
*
* @param {*} config
* @returns
* @returns promise
*/
getPersonalAccessTokens(config = {}) {
return webClient.get('/oauth/personal-access-tokens', { ...config })
@ -60,10 +60,73 @@ export default {
* Delete a user PAT
*
* @param {*} tokenId
* @returns
* @returns promise
*/
deletePersonalAccessToken(tokenId, config = {}) {
return webClient.delete('/oauth/personal-access-tokens/' + tokenId, { ...config })
}
},
/**
* Get all registered users
*
* @returns promise
*/
getAll(config = {}) {
return apiClient.get('/users', { ...config })
},
/**
* Get a registered user by id
*
* @returns promise
*/
getById(id, config = {}) {
return apiClient.get('/users/' + id, { ...config })
},
/**
* Reset user password
*
* @returns promise
*/
resetPassword(id, config = {}) {
return apiClient.patch('/users/' + id + '/password/reset', {}, { ...config })
},
/**
* Delete user
*
* @returns promise
*/
delete(id, config = {}) {
return apiClient.delete('/users/' + id, { ...config })
},
/**
* Update user
*
* @returns promise
*/
update(id, payload, config = {}) {
return apiClient.patch('/users/' + id, payload, { ...config })
},
/**
* Purge user's PATs
*
* @returns promise
*/
revokePATs(id, config = {}) {
return apiClient.delete('/users/' + id + '/pats', { ...config })
},
/**
* Purge user's PATs
*
* @returns promise
*/
revokeWebauthnCredentials(id, config = {}) {
return apiClient.delete('/users/' + id + '/credentials', { ...config })
},
}

View File

@ -12,6 +12,7 @@ export const useUserStore = defineStore({
state: () => {
return {
id: undefined,
name: undefined,
email: undefined,
oauth_provider: undefined,

View File

@ -0,0 +1,58 @@
<script setup>
import AdminTabs from '@/layouts/AdminTabs.vue'
import appSettingService from '@/services/appSettingService'
import { useAppSettingsStore } from '@/stores/appSettings'
import { useNotifyStore } from '@/stores/notify'
import VersionChecker from '@/components/VersionChecker.vue'
const $2fauth = inject('2fauth')
const notify = useNotifyStore()
const appSettings = useAppSettingsStore()
const returnTo = useStorage($2fauth.prefix + 'returnTo', 'accounts')
/**
* Saves a setting on the backend
* @param {string} preference
* @param {any} value
*/
function saveSetting(setting, value) {
appSettingService.update(setting, value).then(response => {
useNotifyStore().success({ type: 'is-success', text: trans('settings.forms.setting_saved') })
})
}
onBeforeRouteLeave((to) => {
if (! to.name.startsWith('admin.')) {
notify.clear()
}
})
</script>
<template>
<div>
<AdminTabs activeTab="admin.appSetup" />
<div class="options-tabs">
<FormWrapper>
<form>
<h4 class="title is-4 pt-4 has-text-grey-light">{{ $t('settings.general') }}</h4>
<div class="block has-text-grey">
<p class="mb-2">{{ $t('admin.administration_legend') }}</p>
</div>
<!-- Check for update -->
<FormCheckbox v-model="appSettings.checkForUpdate" @update:model-value="val => saveSetting('checkForUpdate', val)" fieldName="checkForUpdate" label="commons.check_for_update" help="commons.check_for_update_help" />
<VersionChecker />
<!-- protect db -->
<FormCheckbox v-model="appSettings.useEncryption" @update:model-value="val => saveSetting('useEncryption', val)" fieldName="useEncryption" label="admin.forms.use_encryption.label" help="admin.forms.use_encryption.help" />
<!-- disable registration -->
<FormCheckbox v-model="appSettings.disableRegistration" @update:model-value="val => saveSetting('disableRegistration', val)" fieldName="disableRegistration" label="admin.forms.disable_registration.label" help="admin.forms.disable_registration.help" />
<!-- disable SSO registration -->
<FormCheckbox v-model="appSettings.enableSso" @update:model-value="val => saveSetting('enableSso', val)" fieldName="enableSso" label="admin.forms.enable_sso.label" help="admin.forms.enable_sso.help" />
</form>
</FormWrapper>
</div>
<VueFooter :showButtons="true">
<ButtonBackCloseCancel :returnTo="{ name: returnTo }" action="close" />
</VueFooter>
</div>
</template>

View File

@ -0,0 +1,180 @@
<script setup>
import AdminTabs from '@/layouts/AdminTabs.vue'
import userService from '@/services/userService'
import { useNotifyStore } from '@/stores/notify'
import { UseColorMode } from '@vueuse/components'
import Spinner from '@/components/Spinner.vue'
import SearchBox from '@/components/SearchBox.vue'
const $2fauth = inject('2fauth')
const notify = useNotifyStore()
const returnTo = useStorage($2fauth.prefix + 'returnTo', 'accounts')
const users = ref([])
const searched = ref('')
const isFetching = ref(false)
const visibleUsers = computed(() => {
return users.value.filter(
user => {
let ret = user.name.toLowerCase().includes(searchedObj.value.keywords)
|| user.email.toLowerCase().includes(searchedObj.value.keywords)
if (searchedObj.value.admin != undefined) {
ret = ret && user.is_admin == searchedObj.value.admin
}
if (searchedObj.value.oauth != undefined) {
ret = ret && user.oauth_provider == searchedObj.value.oauth
}
return ret
}
)
})
const searchedObj = computed(() => {
const obj = {
admin: undefined,
oauth: undefined,
keywords: searched.value.toLowerCase()
}
const searchedChunks = searched.value.toLowerCase().split(' ')
const regexAdmin = /admin:([01])/
const regexOAuth = /oauth:([a-zA-Z0-9])/
searchedChunks.forEach(chunk => {
if (chunk.match(regexAdmin)) {
obj.admin = parseInt(chunk.replace(regexAdmin, '$1'))
obj.keywords = obj.keywords.replace(chunk, '').trim()
}
if (chunk.match(regexOAuth)) {
obj.oauth = chunk.replace(regexOAuth, '$1')
obj.keywords = obj.keywords.replace(chunk, '').trim()
}
})
return obj
})
onMounted(() => {
fetchUsers()
})
/**
*
* @param {*} filter
*/
function addFilter(filter) {
const regexAdmin = /admin:([01])/
const regexOAuth = /oauth:([a-zA-Z0-9]*)/
if (searched.value.match(regexAdmin) && filter.match(regexAdmin)) {
searched.value = searched.value.replace(regexAdmin, filter)
}
else if (searchedObj.value.oauth != undefined && filter.match(regexOAuth)) {
searched.value = searched.value.replace(regexOAuth, filter)
}
else searched.value = searched.value ? searched.value + ' ' + filter : filter
}
/**
* Gets all users from backend
*/
function fetchUsers() {
isFetching.value = true
userService.getAll({returnError: true})
.then(response => {
users.value = response.data
})
.catch(error => {
notify.error(error)
})
.finally(() => {
isFetching.value = false
})
}
/**
* Deletes a user
*/
function deleteUser(userId) {
if(confirm(trans('admin.confirm.delete_user'))) {
// TODO
}
}
onBeforeRouteLeave((to) => {
if (! to.name.startsWith('admin.')) {
notify.clear()
}
})
</script>
<template>
<div>
<AdminTabs activeTab="admin.users" />
<div class="options-tabs">
<FormWrapper>
<h4 class="title is-4 has-text-grey-light">{{ $t('admin.users') }}</h4>
<div class="is-size-7-mobile">
{{ $t('admin.users_legend')}}
</div>
<div class="mb-6 mt-3">
<RouterLink class="is-link mt-5" :to="{ name: 'admin.users.create' }">
<FontAwesomeIcon :icon="['fas', 'plus-circle']" /> {{ $t('admin.create_new_user') }}
</RouterLink>
</div>
<!-- search -->
<div class="columns">
<div class="column pb-0">
<SearchBox v-model:keyword="searched" :hasNoBackground="true" />
</div>
</div>
<div class="buttons is-centered mb-0">
<button class="button is-small is-ghost p-0" @click="addFilter('admin:1')">admin</button>
<button class="button is-small is-ghost p-0" @click="addFilter('oauth:github')">github</button>
<button class="button is-small is-ghost p-0" @click="addFilter('oauth:openid')">openId</button>
</div>
<div v-if="visibleUsers.length > 0">
<div v-for="user in visibleUsers" :key="user.id" class="list-item is-size-5 is-size-6-mobile is-flex is-justify-content-space-between">
<div class="has-ellipsis">
<span>{{ user.name }}</span>
<!-- <FontAwesomeIcon v-if="token.value" class="has-text-success" :icon="['fas', 'check']" /> {{ token.name }} -->
<!-- set as admin link -->
<!-- admin tag -->
<span class="is-block has-ellipsis is-family-primary is-size-6 is-size-7-mobile has-text-grey">{{ user.email }}</span>
<!-- tag -->
<div class="tags mt-2">
<span v-if="user.is_admin" class="tag is-rounded has-background-black-bis has-text-warning-dark">admin</span>
<span v-if="user.oauth_provider" class="tag is-rounded has-background-black-bis has-text-grey">oauth: {{ user.oauth_provider }}</span>
</div>
</div>
<div>
<div class="tags ml-3">
<UseColorMode v-slot="{ mode }">
<!-- manage link -->
<RouterLink :to="{ name: 'admin.users.manage', params: { userId: user.id }}" class="button tag is-pulled-right" :class="mode == 'dark' ? 'is-dark' : 'is-white'" :title="$t('commons.manage')">
{{ $t('commons.manage') }}
</RouterLink>
</UseColorMode>
</div>
</div>
</div>
<!-- <div class="mt-2 is-size-7 is-pulled-right">
{{ $t('settings.revoking_a_token_is_permanent')}}
</div> -->
</div>
<div v-else class="mt-4 pl-3">
{{ $t('commons.no_result') }}
</div>
<Spinner :isVisible="isFetching && users.length === 0" />
<!-- footer -->
<VueFooter :showButtons="true">
<ButtonBackCloseCancel :returnTo="{ name: returnTo }" action="close" />
</VueFooter>
</FormWrapper>
</div>
</div>
</template>

View File

@ -0,0 +1,44 @@
<script setup>
import Form from '@/components/formElements/Form'
import { useNotifyStore } from '@/stores/notify'
const notify = useNotifyStore()
const router = useRouter()
const registerForm = reactive(new Form({
name : '',
email : '',
password : '',
password_confirmation : '',
is_admin : false
}))
/**
* Register a new user
*/
async function createUser(e) {
registerForm.password_confirmation = registerForm.password
registerForm.post('/api/v1/users').then(response => {
const user = response.data
notify.success({ text: trans('admin.user_created') })
router.push({ name: 'admin.users.manage', params: { userId: user.info.id } })
})
}
</script>
<template>
<div>
<FormWrapper title="admin.new_user">
<form @submit.prevent="createUser" @keydown="registerForm.onKeydown($event)">
<FormField v-model="registerForm.name" fieldName="name" :fieldError="registerForm.errors.get('name')" inputType="text" label="auth.forms.name" :maxLength="255" autofocus />
<FormField v-model="registerForm.email" fieldName="email" :fieldError="registerForm.errors.get('email')" inputType="email" label="auth.forms.email" :maxLength="255" />
<FormPasswordField v-model="registerForm.password" fieldName="password" :fieldError="registerForm.errors.get('password')" :showRules="true" label="auth.forms.password" :autocomplete="'new-password'" />
<FormCheckbox v-model="registerForm.is_admin" fieldName="is_admin" label="admin.forms.is_admin.label" help="admin.forms.is_admin.help" />
<FormButtons :isBusy="registerForm.isBusy" :isDisabled="registerForm.isDisabled" :showCancelButton="true" :cancelLandingView="'admin.users'" caption="commons.create" submitId="btnCreateUser" />
</form>
</FormWrapper>
<!-- footer -->
<VueFooter />
</div>
</template>

View File

@ -0,0 +1,304 @@
<script setup>
import CopyButton from '@/components/CopyButton.vue'
import userService from '@/services/userService'
import { useNotifyStore } from '@/stores/notify'
import { UseColorMode } from '@vueuse/components'
import { useUserStore } from '@/stores/user'
const notify = useNotifyStore()
const router = useRouter()
const route = useRoute()
const user = useUserStore()
const isFetching = ref(false)
const managedUser = ref(null)
const listUserPreferences = ref(null)
const props = defineProps({
userId: [Number, String]
})
onMounted(async () => {
await getUser()
})
/**
* Gets the user from backend
*/
async function getUser() {
isFetching.value = true
userService.getById(props.userId, {returnError: true})
.then(response => {
managedUser.value = response.data
})
.catch(error => {
notify.error(error)
})
.finally(() => {
isFetching.value = false
})
}
/**
* Resends a pwd reset email to the user
*/
async function resendPasswordEmail() {
if (! confirmForYourself()) {
return false
}
if (confirm(trans('admin.confirm.purge_password_reset_request')) === true) {
await userService.resendPasswordEmail(managedUser.value.info.id)
managedUser.value.password_reset = null
}
}
/**
* Resets the user password
*/
async function resetPassword() {
if (! confirmForYourself()) {
return false
}
if (confirm(trans('admin.confirm.request_password_reset')) === true) {
userService.resetPassword(managedUser.value.info.id, { returnError: true })
.then(response => {
managedUser.value = response.data
notify.success({ text: trans('admin.password_successfully_reset') })
})
.catch(error => {
if(error.response.status === 400) {
notify.alert({ text: error.response.data.reason })
}
else notify.error(error)
})
}
}
/**
* Set admin role
*
* @param {string} preference
* @param {boolean} isAdmin
*/
function saveAdminRole(isAdmin) {
if(isAdmin === false && managedUser.value.info.id === user.id) {
if (! confirm(trans('admin.confirm.demote_own_account'))) {
nextTick().then(() => {
managedUser.value.info.is_admin = true
})
return
}
}
userService.update(managedUser.value.info.id, { 'is_admin': isAdmin }).then(response => {
managedUser.value.info.is_admin = response.data.info.is_admin
notify.success({ text: trans('admin.user_role_updated') })
})
.catch(error => {
notify.error(error)
})
}
/**
* submit user account deletion
*/
function deleteUser() {
if (! confirmForYourself()) {
return false
}
if(confirm(trans('admin.confirm.delete_account'))) {
userService.delete(managedUser.value.info.id, { returnError: true }).then(response => {
notify.success({ text: trans('auth.forms.user_account_successfully_deleted') })
router.push({ name: 'admin.users' });
})
.catch(error => {
if( error.response.status === 403 ) {
notify.alert({ text: error.response.data.message })
}
else {
notify.error(error.response)
}
})
}
}
/**
* submit user account deletion
*/
function revokePATs() {
if (! confirmForYourself()) {
return false
}
userService.revokePATs(managedUser.value.info.id).then(response => {
managedUser.value.valid_personal_access_tokens = 0
notify.success({ text: trans('admin.pats_succesfully_revoked') })
})
}
/**
* submit user account deletion
*/
function revokeWebauthnCredentials() {
if (! confirmForYourself()) {
return false
}
userService.revokeWebauthnCredentials(managedUser.value.info.id).then(response => {
managedUser.value.valid_personal_access_tokens = 0
notify.success({ text: trans('admin.security_devices_succesfully_revoked') })
})
}
/**
* Confirmation for modification on own account
*/
function confirmForYourself() {
if(managedUser.value.info.id === user.id) {
if (! confirm(trans('admin.confirm.edit_own_account'))) {
return false
}
}
return true
}
</script>
<template>
<ResponsiveWidthWrapper>
<h1 class="title has-text-grey-dark mb-6">
{{ $t('admin.user_management') }}
</h1>
<!-- <button class="button is-text is-pulled-right" @click="getUser()">.</button> -->
<!-- loader -->
<div v-if="isFetching || ! managedUser" class="has-text-centered">
<span class="is-size-4">
<FontAwesomeIcon :icon="['fas', 'spinner']" spin />
</span>
</div>
<div v-else>
<div class="mb-6" :class="managedUser.info.is_admin ? 'is-left-bordered-warning' : 'is-left-bordered-link'">
<UseColorMode v-slot="{ mode }">
<p class="title is-4" :class="{ 'has-text-grey-lighter' : mode == 'dark' }">
<span class="has-text-weight-light has-text-grey-dark is-pulled-right">#{{ managedUser.info.id }}</span>{{ managedUser.info.name }}</p>
</UseColorMode>
<p class="subtitle is-6 block">{{ managedUser.info.email }}</p>
</div>
<div v-if="managedUser.info.oauth_provider" class="notification is-dark is-size-7-mobile has-text-centered">
{{ $t('admin.account_bound_to_x_via_oauth', { provider: managedUser.info.oauth_provider }) }}
</div>
<div class="block">
<!-- otp as dot -->
<FormCheckbox v-model="managedUser.info.is_admin" @update:model-value="val => saveAdminRole(val === true)" fieldName="is_admin" label="admin.forms.is_admin.label" help="admin.forms.is_admin.help" />
</div>
<h2 class="title is-4 has-text-grey-light">{{ $t('admin.access') }}</h2>
<div class="block">
<div class="list-item is-size-6 is-size-6-mobile has-text-grey">
<div class="mb-3 is-flex is-justify-content-space-between">
<div>
<span class="has-text-weight-bold">{{ $t('auth.forms.password') }}</span>
</div>
<div>
<div class="tags ml-3 is-right">
<UseColorMode v-slot="{ mode }">
<!-- resend email button -->
<button v-if="managedUser.password_reset" class="button tag is-pulled-right has-background-link" :class="mode == 'dark' ? 'is-dark' : 'is-white'" @click="resendPasswordEmail" :title="$t('admin.resend_email_title')">
{{ $t('admin.resend_email') }}
</button>
<!-- reset password button -->
<button class="button tag is-pulled-right has-background-link" :class="mode == 'dark' ? 'is-dark' : 'is-white'" @click="resetPassword" :title="$t('admin.reset_password_title')">
{{ $t('admin.reset_password') }}
</button>
</UseColorMode>
</div>
</div>
</div>
<div class="is-size-7 is-size-7-mobile has-text-grey-dark">
<span v-if="managedUser.password_reset === 0" v-html="$t('admin.password_request_expired')" class="is-block block"></span>
<span v-else-if="managedUser.password_reset" v-html="$t('admin.password_requested_on_t', { datetime: managedUser.password_reset })" class="is-block block"></span>
<span v-if="managedUser.password_reset" v-html="$t('admin.resend_email_help')" class="is-block block"></span>
<span v-html="$t('admin.reset_password_help')" class="is-block block"></span>
</div>
</div>
<div class="list-item is-size-6 is-size-6-mobile has-text-grey is-flex is-justify-content-space-between">
<div>
<span class="has-text-weight-bold">{{ $t('settings.personal_access_tokens') }}</span>
<span class="is-block is-family-primary is-size-7 is-size-7-mobile has-text-grey-dark">
{{ $t('admin.user_has_x_active_pat', { count: managedUser.valid_personal_access_tokens }) }}
</span>
</div>
<div v-if="managedUser.valid_personal_access_tokens > 0">
<div class="tags ml-3 is-right">
<UseColorMode v-slot="{ mode }">
<!-- manage link -->
<button class="button tag is-pulled-right has-background-link" :class="mode == 'dark' ? 'is-dark' : 'is-white'" @click="revokePATs" :title="$t('admin.revoke_all_pat_for_user')">
{{ $t('settings.revoke') }}
</button>
</UseColorMode>
</div>
</div>
</div>
<div class="list-item is-size-6 is-size-6-mobile has-text-grey is-flex is-justify-content-space-between">
<div>
<span class="has-text-weight-bold">{{ $t('auth.webauthn.security_devices') }}</span>
<span class="is-block is-size-7 is-size-7-mobile has-text-grey-dark">
{{ $t('admin.user_has_x_security_devices', { count: managedUser.webauthn_credentials }) }}
</span>
</div>
<div v-if="managedUser.webauthn_credentials > 0">
<div class="tags ml-3 is-right">
<UseColorMode v-slot="{ mode }">
<!-- manage link -->
<button class="button tag is-pulled-right has-background-link" :class="mode == 'dark' ? 'is-dark' : 'is-white'" :title="$t('admin.revoke_all_devices_for_user')">
{{ $t('settings.revoke') }}
</button>
</UseColorMode>
</div>
</div>
</div>
</div>
<h2 class="title is-4 has-text-grey-light">{{ $t('settings.preferences') }}</h2>
<div class="about-debug box is-family-monospace is-size-7">
<!-- <button id="btnCopyUserPreferences" :aria-label="$t('commons.copy_to_clipboard')" class="button is-like-text is-pulled-right is-small is-text" @click.stop="copyToClipboard(listUserPreferences.innerText)">
<FontAwesomeIcon :icon="['fas', 'copy']" />
</button> -->
<CopyButton id="btnCopyEnvVars" :token="listUserPreferences?.innerText" />
<ul ref="listUserPreferences" id="listUserPreferences">
<li v-for="(value, preference) in managedUser.info.preferences" :value="value" :key="preference">
<b>{{preference}}</b>: <span class="has-text-grey">{{value}}</span>
</li>
</ul>
</div>
<h2 class="title is-4 has-text-grey-light">{{ $t('admin.logs') }}</h2>
<div class="block">
<ul class="is-size-6 is-size-7-mobile">
<li>{{ $t('admin.last_seen_on_date', { date: managedUser.info.last_seen_at }) }}</li>
<li>{{ $t('admin.registered_on_date', { date: managedUser.info.created_at }) }}</li>
<li>{{ $t('admin.updated_on_date', { date: managedUser.info.updated_at }) }}</li>
</ul>
</div>
<!-- danger zone -->
<h2 class="title is-4 has-text-danger">{{ $t('admin.danger_zone') }}</h2>
<div class="is-left-bordered-danger">
<div class="block is-size-6 is-size-7-mobile">
{{ $t('admin.delete_this_user_legend') }}
<span class="is-block has-text-grey has-text-weight-bold">
{{ $t('admin.this_is_not_soft_delete') }}
</span>
</div>
<button class="button is-danger" @click="deleteUser" title="delete">
{{ $t('admin.delete_this_user') }}
</button>
</div>
</div>
<!-- footer -->
<VueFooter :showButtons="true">
<ButtonBackCloseCancel :returnTo="{ name: 'admin.users' }" action="back" />
</VueFooter>
</ResponsiveWidthWrapper>
</template>

View File

@ -33,6 +33,7 @@
form.post('/user/login', {returnError: true}).then(async (response) => {
await user.loginAs({
id: response.data.id,
name: response.data.name,
email: response.data.email,
oauth_provider: response.data.oauth_provider,
@ -62,6 +63,7 @@
webauthnService.authenticate(form.email).then(async (response) => {
await user.loginAs({
id: response.data.id,
name: response.data.name,
email: response.data.email,
oauth_provider: response.data.oauth_provider,

View File

@ -1,18 +1,14 @@
<script setup>
import SettingTabs from '@/layouts/SettingTabs.vue'
import userService from '@/services/userService'
import appSettingService from '@/services/appSettingService'
import { useUserStore } from '@/stores/user'
import { useGroups } from '@/stores/groups'
import { useAppSettingsStore } from '@/stores/appSettings'
import { useNotifyStore } from '@/stores/notify'
import VersionChecker from '@/components/VersionChecker.vue'
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 = [
@ -100,17 +96,6 @@
})
}
/**
* Saves a setting on the backend
* @param {string} preference
* @param {any} value
*/
function saveSetting(setting, value) {
appSettingService.update(setting, value).then(response => {
useNotifyStore().success({ type: 'is-success', text: trans('settings.forms.setting_saved') })
})
}
onBeforeRouteLeave((to) => {
if (! to.name.startsWith('settings.')) {
notify.clear()
@ -177,23 +162,6 @@
<!-- 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" />
</div>
<!-- Admin settings -->
<div v-if="user.isAdmin">
<h4 class="title is-4 pt-4 has-text-grey-light">{{ $t('settings.administration') }}</h4>
<div class="is-size-7-mobile block has-text-grey">
<p class="mb-2">{{ $t('settings.administration_legend') }}</p>
<p>{{ $t('settings.only_an_admin_can_edit_them') }}</p>
</div>
<!-- Check for update -->
<FormCheckbox v-model="appSettings.checkForUpdate" @update:model-value="val => saveSetting('checkForUpdate', val)" fieldName="checkForUpdate" label="commons.check_for_update" help="commons.check_for_update_help" />
<VersionChecker />
<!-- protect db -->
<FormCheckbox v-model="appSettings.useEncryption" @update:model-value="val => saveSetting('useEncryption', val)" fieldName="useEncryption" label="settings.forms.use_encryption.label" help="settings.forms.use_encryption.help" />
<!-- disable registration -->
<FormCheckbox v-model="appSettings.disableRegistration" @update:model-value="val => saveSetting('disableRegistration', val)" fieldName="disableRegistration" label="settings.forms.disable_registration.label" help="settings.forms.disable_registration.help" />
<!-- disable SSO registration -->
<FormCheckbox v-model="appSettings.enableSso" @update:model-value="val => saveSetting('enableSso', val)" fieldName="enableSso" label="settings.forms.enable_sso.label" help="settings.forms.enable_sso.help" />
</div>
</form>
</FormWrapper>
</div>

View File

@ -0,0 +1,81 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Admin Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used during authentication for various
| messages that we need to display to the user. You are free to modify
| these language lines according to your application's requirements.
|
*/
'admin' => 'Admin',
'app_setup' => 'App setup',
'users' => 'Users',
'users_legend' => 'Manage users registered on your instance or create new ones.',
'admin_settings' => 'Admin settings',
'create_new_user' => 'Create a user',
'new_user' => 'New user',
'user_created' => 'user successfully created',
'confirm' => [
'delete_user' => 'Are you sure you want to delete this user? There is no going back.',
'request_password_reset' => 'Are you sure you want to reset this user\'s password?',
'purge_password_reset_request' => 'Are you sure you want to purge the request?',
'delete_account' => 'Are you sure you want to delete this user?',
'edit_own_account' => 'This is your own account. Are you sure?',
'demote_own_account' => 'You will no longer be an administrator. Are you sure?'
],
'administration' => 'Administration',
'logs' => 'Logs',
'administration_legend' => 'Following settings are global and apply to all users.',
'user_management' => 'User management',
'oauth_provider' => 'OAuth provider',
'account_bound_to_x_via_oauth' => 'This account is bound to a :provider account via OAuth',
'last_seen_on_date' => 'Last seen at :date',
'registered_on_date' => 'Registered on :date',
'updated_on_date' => 'Updated on :date',
'access' => 'Access',
'password_requested_on_t' => 'A password reset request exists for this user (request sent at :datetime) meaning the user didn\'t change its password yet but the link he received is still valid. This could be a request from the user himself or from an administrator.',
'password_request_expired' => 'A password reset request exists for this user but has expired, meaning the user didn\'t change its password in time. This could be a request from the user himself or from an administrator.',
'resend_email' => 'Resend email',
'resend_email_title' => 'Resend a password reset email to the user',
'resend_email_help' => 'Use <b>Resend email</b> to send a new password reset email to the user so he can set a new password. This will leave its current password as is and any previous request will be revoked.',
'reset_password' => 'Reset password',
'reset_password_help' => 'Use <b>Reset password</b> to force a password reset (this will set a temporary password) before sending a password reset email to the user so he can set a new password. Any previous request will be revoked.',
'reset_password_title' => 'Reset the user\'s password',
'password_successfully_reset' => 'Password successfully reset',
'user_has_x_active_pat' => ':count active token(s)',
'user_has_x_security_devices' => ':count security device(s) (passkeys)',
'revoke_all_pat_for_user' => 'Revoke all user\'s tokens',
'revoke_all_devices_for_user' => 'Revoke all user\'s security devices',
'danger_zone' => 'Danger Zone',
'delete_this_user_legend' => 'The user account will be deleted as well as all its 2FA data.',
'this_is_not_soft_delete' => 'This is not a soft delete, there is no going back.',
'delete_this_user' => 'Delete this user',
'user_role_updated' => 'User role updated',
'pats_succesfully_revoked' => 'User\'s PATs successfully revoked',
'security_devices_succesfully_revoked' => 'User\'s security devices successfully revoked',
'forms' => [
'use_encryption' => [
'label' => 'Protect sensible data',
'help' => 'Sensitive data, the 2FA secrets and emails, are stored encrypted in database. Be sure to backup the APP_KEY value of your .env file (or the whole file) as it serves as key encryption. There is no way to decypher encrypted data without this key.',
],
'disable_registration' => [
'label' => 'Disable registration',
'help' => 'Prevent new user registration. This affects SSO as well, so new SSO users won\'t be able to sign on',
],
'enable_sso' => [
'label' => 'Enable Single Sign-On (SSO)',
'help' => 'Allow visitors to authenticate using an external ID via the Single Sign-On scheme',
],
'is_admin' => [
'label' => 'Is administrator',
'help' => 'Give administrator rights to the user. Administrators have permissions to manage app settings and users.'
]
],
];

View File

@ -101,7 +101,7 @@ return [
],
'change_password' => 'Change password',
'send_password_reset_link' => 'Send password reset link',
'password_successfully_changed' => 'Password successfully changed',
'password_successfully_reset' => 'Password successfully reset',
'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</strong>',

View File

@ -77,4 +77,7 @@ return [
'default' => 'Default',
'back_to_home' => 'Back to home',
'nothing' => 'nothing',
'no_result' => 'No result',
'information' => 'Information',
'permissions' => 'Permissions'
];

View File

@ -65,5 +65,6 @@ return [
'sso_no_register' => 'Registrations are disabled',
'sso_email_already_used' => 'A user account with the same email address already exists but it does not match your external account ID. Do not use SSO if you are already registered on 2FAuth with this email.',
'account_managed_by_external_provider' => 'Account managed by an external provider',
'data_cannot_be_refreshed_from_server' => 'Data cannot be refreshed from server'
'data_cannot_be_refreshed_from_server' => 'Data cannot be refreshed from server',
'no_pwd_reset_for_this_user_type' => 'Password reset unavailable for this user',
];

View File

@ -25,9 +25,6 @@ return [
'confirm' => [
],
'administration' => 'Administration',
'administration_legend' => 'While previous settings are user settings (every user can set its own preferences), following settings are global and apply to all users.',
'only_an_admin_can_edit_them' => 'Only an administrator can view and edit them.',
'you_are_administrator' => 'You are an administrator',
'account_linked_to_sso_x_provider' => 'You signed-in via SSO using your :provider account. Your information cannot be changed here but on :provider.',
'general' => 'General',
@ -110,10 +107,6 @@ return [
'label' => 'Auto lock',
'help' => 'Log out the user automatically in case of inactivity. Has no effect when authentication is handled by a proxy and no custom logout url is specified.'
],
'use_encryption' => [
'label' => 'Protect sensitive data',
'help' => 'Sensitive data, the 2FA secrets and emails, are stored encrypted in database. Be sure to backup the APP_KEY value of your .env file (or the whole file) as it serves as key encryption. There is no way to decypher encrypted data without this key.',
],
'default_group' => [
'label' => 'Default group',
'help' => 'The group to which the newly created accounts are associated',
@ -130,14 +123,6 @@ return [
'label' => 'Remember group filter',
'help' => 'Save the last group filter applied and restore it on your next visit',
],
'disable_registration' => [
'label' => 'Disable registration',
'help' => 'Prevent new user registration. This affects SSO as well, so new SSO users won\'t be able to sign on',
],
'enable_sso' => [
'label' => 'Enable Single Sign-On (SSO)',
'help' => 'Allow visitors to authenticate using an external ID via the Single Sign-On scheme',
],
'otp_generation' => [
'label' => 'Show Password',
'help' => 'Set how and when <abbr title="One-Time Passwords">OTPs</abbr> are displayed.<br/>',