aboutsummaryrefslogtreecommitdiffstats
path: root/apps/settings/src/components/Users
diff options
context:
space:
mode:
Diffstat (limited to 'apps/settings/src/components/Users')
-rw-r--r--apps/settings/src/components/Users/NewUserDialog.vue (renamed from apps/settings/src/components/Users/NewUserModal.vue)270
-rw-r--r--apps/settings/src/components/Users/UserListFooter.vue43
-rw-r--r--apps/settings/src/components/Users/UserListHeader.vue54
-rw-r--r--apps/settings/src/components/Users/UserRow.vue324
-rw-r--r--apps/settings/src/components/Users/UserRowActions.vue51
-rw-r--r--apps/settings/src/components/Users/UserSettingsDialog.vue93
-rw-r--r--apps/settings/src/components/Users/VirtualList.vue26
-rw-r--r--apps/settings/src/components/Users/shared/styles.scss35
8 files changed, 476 insertions, 420 deletions
diff --git a/apps/settings/src/components/Users/NewUserModal.vue b/apps/settings/src/components/Users/NewUserDialog.vue
index 236bc6db7d8..ef401b565fa 100644
--- a/apps/settings/src/components/Users/NewUserModal.vue
+++ b/apps/settings/src/components/Users/NewUserDialog.vue
@@ -1,36 +1,21 @@
<!--
- - @copyright 2023 Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license AGPL-3.0-or-later
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
- <NcModal class="modal"
+ <NcDialog class="dialog"
size="small"
+ :name="t('settings', 'New account')"
+ out-transition
v-on="$listeners">
- <form class="modal__form"
+ <form id="new-user-form"
+ class="dialog__form"
data-test="form"
:disabled="loading.all"
@submit.prevent="createUser">
- <h2>{{ t('settings', 'New user') }}</h2>
<NcTextField ref="username"
- class="modal__item"
+ class="dialog__item"
data-test="username"
:value.sync="newUser.id"
:disabled="settings.newUserGenerateUserID"
@@ -40,7 +25,7 @@
spellcheck="false"
pattern="[a-zA-Z0-9 _\.@\-']+"
required />
- <NcTextField class="modal__item"
+ <NcTextField class="dialog__item"
data-test="displayName"
:value.sync="newUser.displayName"
:label="t('settings', 'Display name')"
@@ -49,11 +34,11 @@
spellcheck="false" />
<span v-if="!settings.newUserRequireEmail"
id="password-email-hint"
- class="modal__hint">
+ class="dialog__hint">
{{ t('settings', 'Either password or email is required') }}
</span>
<NcPasswordField ref="password"
- class="modal__item"
+ class="dialog__item"
data-test="password"
:value.sync="newUser.password"
:minlength="minPasswordLength"
@@ -64,7 +49,7 @@
autocomplete="new-password"
spellcheck="false"
:required="newUser.mailAddress === ''" />
- <NcTextField class="modal__item"
+ <NcTextField class="dialog__item"
data-test="email"
type="email"
:value.sync="newUser.mailAddress"
@@ -74,66 +59,54 @@
autocomplete="off"
spellcheck="false"
:required="newUser.password === '' || settings.newUserRequireEmail" />
- <div class="modal__item">
- <label class="modal__label"
- for="new-user-groups">
- {{ !settings.isAdmin ? t('settings', 'Groups (required)') : t('settings', 'Groups') }}
- </label>
- <NcSelect class="modal__select"
- input-id="new-user-groups"
- :placeholder="t('settings', 'Set user groups')"
+ <div class="dialog__item">
+ <NcSelect class="dialog__select"
+ data-test="groups"
+ :input-label="!settings.isAdmin && !settings.isDelegatedAdmin ? t('settings', 'Member of the following groups (required)') : t('settings', 'Member of the following groups')"
+ :placeholder="t('settings', 'Set account groups')"
:disabled="loading.groups || loading.all"
- :options="canAddGroups"
+ :options="availableGroups"
:value="newUser.groups"
label="name"
:close-on-select="false"
:multiple="true"
- :taggable="true"
- :required="!settings.isAdmin"
- @input="handleGroupInput"
- @option:created="createGroup" />
- <!-- If user is not admin, he is a subadmin.
+ :taggable="settings.isAdmin || settings.isDelegatedAdmin"
+ :required="!settings.isAdmin && !settings.isDelegatedAdmin"
+ :create-option="(value) => ({ id: value, name: value, isCreating: true })"
+ @search="searchGroups"
+ @option:created="createGroup"
+ @option:selected="options => addGroup(options.at(-1))" />
+ <!-- If user is not admin, they are a subadmin.
Subadmins can't create users outside their groups
Therefore, empty select is forbidden -->
</div>
- <div v-if="subAdminsGroups.length > 0"
- class="modal__item">
- <label class="modal__label"
- for="new-user-sub-admin">
- {{ t('settings', 'Administered groups') }}
- </label>
+ <div class="dialog__item">
<NcSelect v-model="newUser.subAdminsGroups"
- class="modal__select"
- input-id="new-user-sub-admin"
- :placeholder="t('settings', 'Set user as admin for …')"
- :options="subAdminsGroups"
+ class="dialog__select"
+ :input-label="t('settings', 'Admin of the following groups')"
+ :placeholder="t('settings', 'Set account as admin for …')"
+ :disabled="loading.groups || loading.all"
+ :options="availableGroups"
:close-on-select="false"
:multiple="true"
- label="name" />
+ label="name"
+ @search="searchGroups" />
</div>
- <div class="modal__item">
- <label class="modal__label"
- for="new-user-quota">
- {{ t('settings', 'Quota') }}
- </label>
+ <div class="dialog__item">
<NcSelect v-model="newUser.quota"
- class="modal__select"
- input-id="new-user-quota"
- :placeholder="t('settings', 'Set user quota')"
+ class="dialog__select"
+ :input-label="t('settings', 'Quota')"
+ :placeholder="t('settings', 'Set account quota')"
:options="quotaOptions"
:clearable="false"
:taggable="true"
:create-option="validateQuota" />
</div>
<div v-if="showConfig.showLanguages"
- class="modal__item">
- <label class="modal__label"
- for="new-user-language">
- {{ t('settings', 'Language') }}
- </label>
- <NcSelect v-model="newUser.language"
- class="modal__select"
- input-id="new-user-language"
+ class="dialog__item">
+ <NcSelect v-model="newUser.language"
+ class="dialog__select"
+ :input-label="t('settings', 'Language')"
:placeholder="t('settings', 'Set default language')"
:clearable="false"
:selectable="option => !option.languages"
@@ -141,44 +114,47 @@
:options="languages"
label="name" />
</div>
- <div :class="['modal__item managers', { 'icon-loading-small': loading.manager }]">
- <label class="modal__label"
- for="new-user-manager">
- <!-- TRANSLATORS This string describes a manager in the context of an organization -->
- {{ t('settings', 'Manager') }}
- </label>
+ <div :class="['dialog__item dialog__managers', { 'icon-loading-small': loading.manager }]">
<NcSelect v-model="newUser.manager"
- class="modal__select"
- input-id="new-user-manager"
+ class="dialog__select"
+ :input-label="managerInputLabel"
:placeholder="managerLabel"
:options="possibleManagers"
:user-select="true"
label="displayname"
@search="searchUserManager" />
</div>
- <NcButton class="modal__submit"
+ </form>
+
+ <template #actions>
+ <NcButton class="dialog__submit"
data-test="submit"
+ form="new-user-form"
type="primary"
native-type="submit">
- {{ t('settings', 'Add new user') }}
+ {{ t('settings', 'Add new account') }}
</NcButton>
- </form>
- </NcModal>
+ </template>
+ </NcDialog>
</template>
<script>
-import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
-import NcModal from '@nextcloud/vue/dist/Components/NcModal.js'
-import NcPasswordField from '@nextcloud/vue/dist/Components/NcPasswordField.js'
-import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js'
-import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
+import { formatFileSize, parseFileSize } from '@nextcloud/files'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcDialog from '@nextcloud/vue/components/NcDialog'
+import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
+import NcSelect from '@nextcloud/vue/components/NcSelect'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
+
+import { searchGroups } from '../../service/groups.ts'
+import logger from '../../logger.ts'
export default {
- name: 'NewUserModal',
+ name: 'NewUserDialog',
components: {
NcButton,
- NcModal,
+ NcDialog,
NcPasswordField,
NcSelect,
NcTextField,
@@ -205,7 +181,11 @@ export default {
return {
possibleManagers: [],
// TRANSLATORS This string describes a manager in the context of an organization
- managerLabel: t('settings', 'Set user manager'),
+ managerInputLabel: t('settings', 'Manager'),
+ // TRANSLATORS This string describes a manager in the context of an organization
+ managerLabel: t('settings', 'Set line manager'),
+ // Cancelable promise for search groups request
+ promise: null,
}
},
@@ -220,36 +200,21 @@ export default {
usernameLabel() {
if (this.settings.newUserGenerateUserID) {
- return t('settings', 'Username will be autogenerated')
+ return t('settings', 'Account name will be autogenerated')
}
- return t('settings', 'Username (required)')
+ return t('settings', 'Account name (required)')
},
minPasswordLength() {
return this.$store.getters.getPasswordPolicyMinLength
},
- groups() {
- // data provided php side + remove the disabled group
- return this.$store.getters.getGroups
- .filter(group => group.id !== 'disabled')
- .sort((a, b) => a.name.localeCompare(b.name))
- },
+ availableGroups() {
+ const groups = (this.settings.isAdmin || this.settings.isDelegatedAdmin)
+ ? this.$store.getters.getSortedGroups
+ : this.$store.getters.getSubAdminGroups
- subAdminsGroups() {
- // data provided php side
- return this.$store.getters.getSubadminGroups
- },
-
- canAddGroups() {
- // disabled if no permission to add new users to group
- return this.groups.map(group => {
- // clone object because we don't want
- // to edit the original groups
- group = Object.assign({}, group)
- group.$isDisabled = group.canAdd === false
- return group
- })
+ return groups.filter(group => group.id !== '__nc_internal_recent' && group.id !== 'disabled')
},
languages() {
@@ -272,6 +237,10 @@ export default {
await this.searchUserManager()
},
+ mounted() {
+ this.$refs.username?.focus?.()
+ },
+
methods: {
async createUser() {
this.loading.all = true
@@ -289,30 +258,49 @@ export default {
})
this.$emit('reset')
- this.$refs.username?.$refs?.inputField?.$refs?.input?.focus?.()
- this.$emit('close')
+ this.$refs.username?.focus?.()
+ this.$emit('closing')
} catch (error) {
this.loading.all = false
if (error.response && error.response.data && error.response.data.ocs && error.response.data.ocs.meta) {
const statuscode = error.response.data.ocs.meta.statuscode
if (statuscode === 102) {
// wrong username
- this.$refs.username?.$refs?.inputField?.$refs?.input?.focus?.()
+ this.$refs.username?.focus?.()
} else if (statuscode === 107) {
// wrong password
- this.$refs.password?.$refs?.inputField?.$refs?.input?.focus?.()
+ this.$refs.password?.focus?.()
}
}
}
},
- handleGroupInput(groups) {
- /**
- * Filter out groups with no id to prevent duplicate selected options
- *
- * Created groups are added programmatically by `createGroup()`
- */
- this.newUser.groups = groups.filter(group => Boolean(group.id))
+ async searchGroups(query, toggleLoading) {
+ if (!this.settings.isAdmin && !this.settings.isDelegatedAdmin) {
+ // managers cannot search for groups
+ return
+ }
+
+ if (this.promise) {
+ this.promise.cancel()
+ }
+ toggleLoading(true)
+ try {
+ this.promise = searchGroups({
+ search: query,
+ offset: 0,
+ limit: 25,
+ })
+ const groups = await this.promise
+ // Populate store from server request
+ for (const group of groups) {
+ this.$store.commit('addGroup', group)
+ }
+ } catch (error) {
+ logger.error(t('settings', 'Failed to search groups'), { error })
+ }
+ this.promise = null
+ toggleLoading(false)
},
/**
@@ -325,11 +313,26 @@ export default {
this.loading.groups = true
try {
await this.$store.dispatch('addGroup', gid)
- this.newUser.groups.push(this.groups.find(group => group.id === gid))
- this.loading.groups = false
+ this.newUser.groups.push({ id: gid, name: gid })
} catch (error) {
- this.loading.groups = false
+ logger.error(t('settings', 'Failed to create group'), { error })
+ }
+ this.loading.groups = false
+ },
+
+ /**
+ * Add user to group
+ *
+ * @param {object} group Group object
+ */
+ async addGroup(group) {
+ if (group.isCreating) {
+ return
}
+ if (group.canAdd === false) {
+ return
+ }
+ this.newUser.groups.push(group)
},
/**
@@ -343,7 +346,7 @@ export default {
const validQuota = OC.Util.computerFileSize(quota)
if (validQuota !== null && validQuota >= 0) {
// unify format output
- quota = OC.Util.humanFileSize(OC.Util.computerFileSize(quota))
+ quota = formatFileSize(parseFileSize(quota, true))
this.newUser.quota = { id: quota, label: quota }
return this.newUser.quota
}
@@ -383,12 +386,12 @@ export default {
</script>
<style lang="scss" scoped>
-.modal {
+.dialog {
&__form {
display: flex;
flex-direction: column;
align-items: center;
- padding: 20px;
+ padding: 0 8px;
gap: 4px 0;
}
@@ -415,8 +418,19 @@ export default {
width: 100%;
}
+ &__managers {
+ margin-bottom: 12px;
+ }
+
&__submit {
- margin-top: 20px;
+ margin-top: 4px;
+ margin-bottom: 8px;
+ }
+
+ :deep {
+ .dialog__actions {
+ margin: auto;
+ }
}
}
</style>
diff --git a/apps/settings/src/components/Users/UserListFooter.vue b/apps/settings/src/components/Users/UserListFooter.vue
index d8974658354..bf9aa43b6d3 100644
--- a/apps/settings/src/components/Users/UserListFooter.vue
+++ b/apps/settings/src/components/Users/UserListFooter.vue
@@ -1,23 +1,6 @@
<!--
- - @copyright 2023 Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license AGPL-3.0-or-later
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
@@ -28,7 +11,7 @@
</th>
<td class="footer__cell footer__cell--loading">
<NcLoadingIcon v-if="loading"
- :title="t('settings', 'Loading users …')"
+ :title="t('settings', 'Loading accounts …')"
:size="32" />
</td>
<td class="footer__cell footer__cell--count footer__cell--multiline">
@@ -43,7 +26,7 @@
<script lang="ts">
import Vue from 'vue'
-import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import {
translate as t,
@@ -73,8 +56,8 @@ export default Vue.extend({
if (this.loading) {
return this.n(
'settings',
- '{userCount} user …',
- '{userCount} users …',
+ '{userCount} account …',
+ '{userCount} accounts …',
this.filteredUsers.length,
{
userCount: this.filteredUsers.length,
@@ -83,8 +66,8 @@ export default Vue.extend({
}
return this.n(
'settings',
- '{userCount} user',
- '{userCount} users',
+ '{userCount} account',
+ '{userCount} accounts',
this.filteredUsers.length,
{
userCount: this.filteredUsers.length,
@@ -101,18 +84,18 @@ export default Vue.extend({
</script>
<style lang="scss" scoped>
-@import './shared/styles.scss';
+@use './shared/styles';
.footer {
- @include row;
- @include cell;
+ @include styles.row;
+ @include styles.cell;
&__cell {
position: sticky;
color: var(--color-text-maxcontrast);
&--loading {
- left: 0;
+ inset-inline-start: 0;
min-width: var(--avatar-cell-width);
width: var(--avatar-cell-width);
align-items: center;
@@ -120,7 +103,7 @@ export default Vue.extend({
}
&--count {
- left: var(--avatar-cell-width);
+ inset-inline-start: var(--avatar-cell-width);
min-width: var(--cell-width);
width: var(--cell-width);
}
diff --git a/apps/settings/src/components/Users/UserListHeader.vue b/apps/settings/src/components/Users/UserListHeader.vue
index e314bcb6a73..a85306d84d3 100644
--- a/apps/settings/src/components/Users/UserListHeader.vue
+++ b/apps/settings/src/components/Users/UserListHeader.vue
@@ -1,23 +1,6 @@
<!--
- - @copyright 2023 Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license AGPL-3.0-or-later
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
@@ -35,8 +18,12 @@
<strong>
{{ t('settings', 'Display name') }}
</strong>
- <span class="header__subtitle">
- {{ t('settings', 'Username') }}
+ </th>
+ <th class="header__cell header__cell--username"
+ data-cy-user-list-header-username
+ scope="col">
+ <span>
+ {{ t('settings', 'Account name') }}
</span>
</th>
<th class="header__cell"
@@ -55,7 +42,7 @@
scope="col">
<span>{{ t('settings', 'Groups') }}</span>
</th>
- <th v-if="subAdminsGroups.length > 0 && settings.isAdmin"
+ <th v-if="settings.isAdmin || settings.isDelegatedAdmin"
class="header__cell header__cell--large"
data-cy-user-list-header-subadmins
scope="col">
@@ -77,13 +64,19 @@
data-cy-user-list-header-storage-location
scope="col">
<span v-if="showConfig.showUserBackend">
- {{ t('settings', 'User backend') }}
+ {{ t('settings', 'Account backend') }}
</span>
<span v-if="showConfig.showStoragePath"
class="header__subtitle">
{{ t('settings', 'Storage location') }}
</span>
</th>
+ <th v-if="showConfig.showFirstLogin"
+ class="header__cell"
+ data-cy-user-list-header-first-login
+ scope="col">
+ <span>{{ t('settings', 'First login') }}</span>
+ </th>
<th v-if="showConfig.showLastLogin"
class="header__cell"
data-cy-user-list-header-last-login
@@ -100,7 +93,7 @@
data-cy-user-list-header-actions
scope="col">
<span class="hidden-visually">
- {{ t('settings', 'User actions') }}
+ {{ t('settings', 'Account actions') }}
</span>
</th>
</tr>
@@ -132,11 +125,6 @@ export default Vue.extend({
return this.$store.getters.getServerData
},
- subAdminsGroups() {
- // @ts-expect-error: allow untyped $store
- return this.$store.getters.getSubadminGroups
- },
-
passwordLabel(): string {
if (this.hasObfuscated) {
// TRANSLATORS This string is for a column header labelling either a password or a message that the current user has insufficient permissions
@@ -153,12 +141,12 @@ export default Vue.extend({
</script>
<style lang="scss" scoped>
-@import './shared/styles.scss';
+@use './shared/styles';
.header {
- @include row;
- @include cell;
-
border-bottom: 1px solid var(--color-border);
+
+ @include styles.row;
+ @include styles.cell;
}
</style>
diff --git a/apps/settings/src/components/Users/UserRow.vue b/apps/settings/src/components/Users/UserRow.vue
index 3586a7702b1..43668725972 100644
--- a/apps/settings/src/components/Users/UserRow.vue
+++ b/apps/settings/src/components/Users/UserRow.vue
@@ -1,27 +1,7 @@
<!--
- - @copyright Copyright (c) 2019 Gary Kim <gary@garykim.dev>
- - @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- - @author Gary Kim <gary@garykim.dev>
- - @author John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
+ - SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<tr class="user-list__row"
@@ -54,13 +34,14 @@
spellcheck="false"
@trailing-button-click="updateDisplayName" />
</template>
- <template v-else>
- <strong v-if="!isObfuscated"
- :title="user.displayname?.length > 20 ? user.displayname : null">
- {{ user.displayname }}
- </strong>
- <span class="row__subtitle">{{ user.id }}</span>
- </template>
+ <strong v-else-if="!isObfuscated"
+ :title="user.displayname?.length > 20 ? user.displayname : null">
+ {{ user.displayname }}
+ </strong>
+ </td>
+
+ <td class="row__cell row__cell--username" data-cy-user-list-cell-username>
+ <span class="row__subtitle">{{ user.id }}</span>
</td>
<td data-cy-user-list-cell-password
@@ -119,23 +100,24 @@
<template v-if="editing">
<label class="hidden-visually"
:for="'groups' + uniqueId">
- {{ t('settings', 'Add user to group') }}
+ {{ t('settings', 'Add account to group') }}
</label>
<NcSelect data-cy-user-list-input-groups
:data-loading="loading.groups || undefined"
:input-id="'groups' + uniqueId"
:close-on-select="false"
- :disabled="isLoadingField"
+ :disabled="isLoadingField || loading.groupsDetails"
:loading="loading.groups"
:multiple="true"
:append-to-body="false"
:options="availableGroups"
:placeholder="t('settings', 'Add account to group')"
- :taggable="settings.isAdmin"
+ :taggable="settings.isAdmin || settings.isDelegatedAdmin"
:value="userGroups"
label="name"
:no-wrap="true"
- :create-option="(value) => ({ name: value, isCreating: true })"
+ :create-option="(value) => ({ id: value, name: value, isCreating: true })"
+ @search="searchGroups"
@option:created="createGroup"
@option:selected="options => addUserGroup(options.at(-1))"
@option:deselected="removeUserGroup" />
@@ -146,10 +128,10 @@
</span>
</td>
- <td v-if="subAdminsGroups.length > 0 && settings.isAdmin"
+ <td v-if="settings.isAdmin || settings.isDelegatedAdmin"
data-cy-user-list-cell-subadmins
class="row__cell row__cell--large row__cell--multiline">
- <template v-if="editing && settings.isAdmin && subAdminsGroups.length > 0">
+ <template v-if="editing && (settings.isAdmin || settings.isDelegatedAdmin)">
<label class="hidden-visually"
:for="'subadmins' + uniqueId">
{{ t('settings', 'Set account as admin for') }}
@@ -158,21 +140,22 @@
:data-loading="loading.subadmins || undefined"
:input-id="'subadmins' + uniqueId"
:close-on-select="false"
- :disabled="isLoadingField"
+ :disabled="isLoadingField || loading.subAdminGroupsDetails"
:loading="loading.subadmins"
label="name"
:append-to-body="false"
:multiple="true"
:no-wrap="true"
- :options="subAdminsGroups"
+ :options="availableSubAdminGroups"
:placeholder="t('settings', 'Set account as admin for')"
- :value="userSubAdminsGroups"
+ :value="userSubAdminGroups"
+ @search="searchGroups"
@option:deselected="removeUserSubAdmin"
@option:selected="options => addUserSubAdmin(options.at(-1))" />
</template>
<span v-else-if="!isObfuscated"
- :title="userSubAdminsGroupsLabels?.length > 40 ? userSubAdminsGroupsLabels : null">
- {{ userSubAdminsGroupsLabels }}
+ :title="userSubAdminGroupsLabels?.length > 40 ? userSubAdminGroupsLabels : null">
+ {{ userSubAdminGroupsLabels }}
</span>
</td>
@@ -248,6 +231,12 @@
</template>
</td>
+ <td v-if="showConfig.showFirstLogin"
+ class="row__cell"
+ data-cy-user-list-cell-first-login>
+ <span v-if="!isObfuscated">{{ userFirstLogin }}</span>
+ </td>
+
<td v-if="showConfig.showLastLogin"
:title="userLastLoginTooltip"
class="row__cell"
@@ -266,16 +255,17 @@
data-cy-user-list-input-manager
:data-loading="loading.manager || undefined"
:input-id="'manager' + uniqueId"
- :close-on-select="true"
:disabled="isLoadingField"
- :append-to-body="false"
:loading="loadingPossibleManagers || loading.manager"
- label="displayname"
:options="possibleManagers"
:placeholder="managerLabel"
+ label="displayname"
+ :filterable="false"
+ :internal-search="false"
+ :clearable="true"
@open="searchInitialUserManager"
@search="searchUserManager"
- @option:selected="updateUserManager" />
+ @update:model-value="updateUserManager" />
</template>
<span v-else-if="!isObfuscated">
{{ user.manager }}
@@ -297,17 +287,20 @@
import { formatFileSize, parseFileSize } from '@nextcloud/files'
import { getCurrentUser } from '@nextcloud/auth'
import { showSuccess, showError } from '@nextcloud/dialogs'
+import { confirmPassword } from '@nextcloud/password-confirmation'
-import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js'
-import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
-import NcProgressBar from '@nextcloud/vue/dist/Components/NcProgressBar.js'
-import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js'
-import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
+import NcAvatar from '@nextcloud/vue/components/NcAvatar'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
+import NcProgressBar from '@nextcloud/vue/components/NcProgressBar'
+import NcSelect from '@nextcloud/vue/components/NcSelect'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
import UserRowActions from './UserRowActions.vue'
import UserRowMixin from '../../mixins/UserRowMixin.js'
-import { isObfuscated, unlimitedQuota } from '../../utils/userUtils.ts';
+import { isObfuscated, unlimitedQuota } from '../../utils/userUtils.ts'
+import { searchGroups, loadUserGroups, loadUserSubAdminGroups } from '../../service/groups.ts'
+import logger from '../../logger.ts'
export default {
name: 'UserRow',
@@ -342,14 +335,6 @@ export default {
type: Boolean,
required: true,
},
- groups: {
- type: Array,
- default: () => [],
- },
- subAdminsGroups: {
- type: Array,
- required: true,
- },
quotaOptions: {
type: Array,
required: true,
@@ -382,6 +367,8 @@ export default {
password: false,
mailAddress: false,
groups: false,
+ groupsDetails: false,
+ subAdminGroupsDetails: false,
subadmins: false,
quota: false,
delete: false,
@@ -393,6 +380,8 @@ export default {
editedDisplayName: this.user.displayname,
editedPassword: '',
editedMail: this.user.email ?? '',
+ // Cancelable promise for search groups request
+ promise: null,
}
},
@@ -422,15 +411,35 @@ export default {
return encodeURIComponent(this.user.id + this.rand)
},
+ availableGroups() {
+ const groups = (this.settings.isAdmin || this.settings.isDelegatedAdmin)
+ ? this.$store.getters.getSortedGroups
+ : this.$store.getters.getSubAdminGroups
+
+ return groups.filter(group => group.id !== '__nc_internal_recent' && group.id !== 'disabled')
+ },
+
+ availableSubAdminGroups() {
+ return this.availableGroups.filter(group => group.id !== 'admin')
+ },
+
userGroupsLabels() {
return this.userGroups
- .map(group => group.name)
+ .map(group => {
+ // Try to match with more extensive group data
+ const availableGroup = this.availableGroups.find(g => g.id === group.id)
+ return availableGroup?.name ?? group.name ?? group.id
+ })
.join(', ')
},
- userSubAdminsGroupsLabels() {
- return this.userSubAdminsGroups
- .map(group => group.name)
+ userSubAdminGroupsLabels() {
+ return this.userSubAdminGroups
+ .map(group => {
+ // Try to match with more extensive group data
+ const availableGroup = this.availableSubAdminGroups.find(g => g.id === group.id)
+ return availableGroup?.name ?? group.name ?? group.id
+ })
.join(', ')
},
@@ -442,7 +451,7 @@ export default {
},
canEdit() {
- return getCurrentUser().uid !== this.user.id || this.settings.isAdmin
+ return getCurrentUser().uid !== this.user.id || this.settings.isAdmin || this.settings.isDelegatedAdmin
},
userQuota() {
@@ -514,7 +523,6 @@ export default {
return this.languages[0].languages.concat(this.languages[1].languages)
},
},
-
async beforeMount() {
if (this.user.manager) {
await this.initManager(this.user.manager)
@@ -522,8 +530,9 @@ export default {
},
methods: {
- wipeUserDevices() {
+ async wipeUserDevices() {
const userid = this.user.id
+ await confirmPassword()
OC.dialogs.confirmDestructive(
t('settings', 'In case of lost device or exiting the organization, this can remotely wipe the Nextcloud data from all devices associated with {userid}. Only works if the devices are connected to the internet.', { userid }),
t('settings', 'Remote wipe of devices'),
@@ -565,6 +574,66 @@ export default {
this.loadingPossibleManagers = false
},
+ async loadGroupsDetails() {
+ this.loading.groups = true
+ this.loading.groupsDetails = true
+ try {
+ const groups = await loadUserGroups({ userId: this.user.id })
+ // Populate store from server request
+ for (const group of groups) {
+ this.$store.commit('addGroup', group)
+ }
+ this.selectedGroups = this.selectedGroups.map(selectedGroup => groups.find(group => group.id === selectedGroup.id) ?? selectedGroup)
+ } catch (error) {
+ logger.error(t('settings', 'Failed to load groups with details'), { error })
+ }
+ this.loading.groups = false
+ this.loading.groupsDetails = false
+ },
+
+ async loadSubAdminGroupsDetails() {
+ this.loading.subadmins = true
+ this.loading.subAdminGroupsDetails = true
+ try {
+ const groups = await loadUserSubAdminGroups({ userId: this.user.id })
+ // Populate store from server request
+ for (const group of groups) {
+ this.$store.commit('addGroup', group)
+ }
+ this.selectedSubAdminGroups = this.selectedSubAdminGroups.map(selectedGroup => groups.find(group => group.id === selectedGroup.id) ?? selectedGroup)
+ } catch (error) {
+ logger.error(t('settings', 'Failed to load sub admin groups with details'), { error })
+ }
+ this.loading.subadmins = false
+ this.loading.subAdminGroupsDetails = false
+ },
+
+ async searchGroups(query, toggleLoading) {
+ if (query === '') {
+ return // Prevent unexpected search behaviour e.g. on option:created
+ }
+ if (this.promise) {
+ this.promise.cancel()
+ }
+ toggleLoading(true)
+ try {
+ this.promise = await searchGroups({
+ search: query,
+ offset: 0,
+ limit: 25,
+ })
+ const groups = await this.promise
+ // Populate store from server request
+ for (const group of groups) {
+ this.$store.commit('addGroup', group)
+ }
+ } catch (error) {
+ logger.error(t('settings', 'Failed to search groups'), { error })
+ }
+ this.promise = null
+ toggleLoading(false)
+ },
+
async searchUserManager(query) {
await this.$store.dispatch('searchUsers', { offset: 0, limit: 10, search: query }).then(response => {
const users = response?.data ? this.filterManagers(Object.values(response?.data.ocs.data.users)) : []
@@ -574,11 +643,12 @@ export default {
})
},
- async updateUserManager(manager) {
- if (manager === null) {
- this.currentManager = ''
- }
+ async updateUserManager() {
this.loading.manager = true
+
+ // Store the current manager before making changes
+ const previousManager = this.user.manager
+
try {
await this.$store.dispatch('setUserData', {
userid: this.user.id,
@@ -587,15 +657,19 @@ export default {
})
} catch (error) {
// TRANSLATORS This string describes a line manager in the context of an organization
- showError(t('setting', 'Failed to update line manager'))
- console.error(error)
+ showError(t('settings', 'Failed to update line manager'))
+ logger.error('Failed to update manager:', { error })
+
+ // Revert to the previous manager in the UI on error
+ this.currentManager = previousManager
} finally {
this.loading.manager = false
}
},
- deleteUser() {
+ async deleteUser() {
const userid = this.user.id
+ await confirmPassword()
OC.dialogs.confirmDestructive(
t('settings', 'Fully delete {userid}\'s account including all their personal files, app data, etc.', { userid }),
t('settings', 'Account deletion'),
@@ -637,68 +711,70 @@ export default {
/**
* Set user displayName
- *
- * @param {string} displayName The display name
*/
- updateDisplayName() {
+ async updateDisplayName() {
this.loading.displayName = true
- this.$store.dispatch('setUserData', {
- userid: this.user.id,
- key: 'displayname',
- value: this.editedDisplayName,
- }).then(() => {
- this.loading.displayName = false
+ try {
+ await this.$store.dispatch('setUserData', {
+ userid: this.user.id,
+ key: 'displayname',
+ value: this.editedDisplayName,
+ })
+
if (this.editedDisplayName === this.user.displayname) {
- showSuccess(t('setting', 'Display name was successfully changed'))
+ showSuccess(t('settings', 'Display name was successfully changed'))
}
- })
+ } finally {
+ this.loading.displayName = false
+ }
},
/**
* Set user password
- *
- * @param {string} password The email address
*/
- updatePassword() {
+ async updatePassword() {
this.loading.password = true
if (this.editedPassword.length === 0) {
- showError(t('setting', "Password can't be empty"))
+ showError(t('settings', "Password can't be empty"))
this.loading.password = false
} else {
- this.$store.dispatch('setUserData', {
- userid: this.user.id,
- key: 'password',
- value: this.editedPassword,
- }).then(() => {
- this.loading.password = false
+ try {
+ await this.$store.dispatch('setUserData', {
+ userid: this.user.id,
+ key: 'password',
+ value: this.editedPassword,
+ })
this.editedPassword = ''
- showSuccess(t('setting', 'Password was successfully changed'))
- })
+ showSuccess(t('settings', 'Password was successfully changed'))
+ } finally {
+ this.loading.password = false
+ }
}
},
/**
* Set user mailAddress
- *
- * @param {string} mailAddress The email address
*/
- updateEmail() {
+ async updateEmail() {
this.loading.mailAddress = true
if (this.editedMail === '') {
- showError(t('setting', "Email can't be empty"))
+ showError(t('settings', "Email can't be empty"))
this.loading.mailAddress = false
this.editedMail = this.user.email
} else {
- this.$store.dispatch('setUserData', {
- userid: this.user.id,
- key: 'email',
- value: this.editedMail,
- }).then(() => {
- this.loading.mailAddress = false
+ try {
+ await this.$store.dispatch('setUserData', {
+ userid: this.user.id,
+ key: 'email',
+ value: this.editedMail,
+ })
+
if (this.editedMail === this.user.email) {
- showSuccess(t('setting', 'Email was successfully changed'))
+ showSuccess(t('settings', 'Email was successfully changed'))
}
- })
+ } finally {
+ this.loading.mailAddress = false
+ }
}
},
@@ -708,17 +784,16 @@ export default {
* @param {string} gid Group id
*/
async createGroup({ name: gid }) {
- this.loading = { groups: true, subadmins: true }
+ this.loading.groups = true
try {
await this.$store.dispatch('addGroup', gid)
const userid = this.user.id
await this.$store.dispatch('addUserGroup', { userid, gid })
+ this.userGroups.push({ id: gid, name: gid })
} catch (error) {
- console.error(error)
- } finally {
- this.loading = { groups: false, subadmins: false }
+ logger.error(t('settings', 'Failed to create group'), { error })
}
- return this.$store.getters.getGroups[this.groups.length]
+ this.loading.groups = false
},
/**
@@ -732,19 +807,19 @@ export default {
// Ignore
return
}
- this.loading.groups = true
const userid = this.user.id
const gid = group.id
if (group.canAdd === false) {
- return false
+ return
}
+ this.loading.groups = true
try {
await this.$store.dispatch('addUserGroup', { userid, gid })
+ this.userGroups.push(group)
} catch (error) {
console.error(error)
- } finally {
- this.loading.groups = false
}
+ this.loading.groups = false
},
/**
@@ -764,6 +839,7 @@ export default {
userid,
gid,
})
+ this.userGroups = this.userGroups.filter(group => group.id !== gid)
this.loading.groups = false
// remove user from current list if current list is the removed group
if (this.$route.params.selectedGroup === gid) {
@@ -788,10 +864,11 @@ export default {
userid,
gid,
})
- this.loading.subadmins = false
+ this.userSubAdminGroups.push(group)
} catch (error) {
console.error(error)
}
+ this.loading.subadmins = false
},
/**
@@ -809,6 +886,7 @@ export default {
userid,
gid,
})
+ this.userSubAdminGroups = this.userSubAdminGroups.filter(group => group.id !== gid)
} catch (error) {
console.error(error)
} finally {
@@ -898,7 +976,7 @@ export default {
sendWelcomeMail() {
this.loading.all = true
this.$store.dispatch('sendWelcomeMail', this.user.id)
- .then(() => showSuccess(t('setting', 'Welcome mail sent!'), { timeout: 2000 }))
+ .then(() => showSuccess(t('settings', 'Welcome mail sent!'), { timeout: 2000 }))
.finally(() => {
this.loading.all = false
})
@@ -909,6 +987,8 @@ export default {
if (this.editing) {
await this.$nextTick()
this.$refs.displayNameField?.$refs?.inputField?.$refs?.input?.focus()
+ this.loadGroupsDetails()
+ this.loadSubAdminGroupsDetails()
}
if (this.editedDisplayName !== this.user.displayname) {
this.editedDisplayName = this.user.displayname
@@ -921,10 +1001,10 @@ export default {
</script>
<style lang="scss" scoped>
-@import './shared/styles.scss';
+@use './shared/styles';
.user-list__row {
- @include row;
+ @include styles.row;
&:hover {
background-color: var(--color-background-hover);
@@ -941,7 +1021,7 @@ export default {
}
.row {
- @include cell;
+ @include styles.cell;
&__cell {
border-bottom: 1px solid var(--color-border);
diff --git a/apps/settings/src/components/Users/UserRowActions.vue b/apps/settings/src/components/Users/UserRowActions.vue
index a01bb868c7a..efd70d879a7 100644
--- a/apps/settings/src/components/Users/UserRowActions.vue
+++ b/apps/settings/src/components/Users/UserRowActions.vue
@@ -1,24 +1,6 @@
<!--
- - @copyright 2023 Ferdinand Thiessen <opensource@fthiessen.de>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- - @author Ferdinand Thiessen <opensource@fthiessen.de>
- -
- - @license AGPL-3.0-or-later
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
@@ -33,13 +15,17 @@
<NcIconSvgWrapper :key="editSvg" :svg="editSvg" aria-hidden="true" />
</template>
</NcActionButton>
- <NcActionButton v-for="({ action, icon, text }, index) in actions"
+ <NcActionButton v-for="({ action, icon, text }, index) in enabledActions"
:key="index"
:disabled="disabled"
:aria-label="text"
:icon="icon"
+ close-after-click
@click="(event) => action(event, { ...user })">
{{ text }}
+ <template v-if="isSvg(icon)" #icon>
+ <NcIconSvgWrapper :svg="icon" aria-hidden="true" />
+ </template>
</NcActionButton>
</NcActions>
</template>
@@ -47,17 +33,19 @@
<script lang="ts">
import type { PropType } from 'vue'
import { defineComponent } from 'vue'
+import isSvg from 'is-svg'
-import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
-import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
-import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
+import NcActions from '@nextcloud/vue/components/NcActions'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import SvgCheck from '@mdi/svg/svg/check.svg?raw'
-import SvgPencil from '@mdi/svg/svg/pencil.svg?raw'
+import SvgPencil from '@mdi/svg/svg/pencil-outline.svg?raw'
interface UserAction {
action: (event: MouseEvent, user: Record<string, unknown>) => void,
+ enabled?: (user: Record<string, unknown>) => boolean,
icon: string,
- text: string
+ text: string,
}
export default defineComponent({
@@ -105,12 +93,21 @@ export default defineComponent({
/**
* Current MDI logo to show for edit toggle
*/
- editSvg() {
+ editSvg(): string {
return this.edit ? SvgCheck : SvgPencil
},
+
+ /**
+ * Enabled user row actions
+ */
+ enabledActions(): UserAction[] {
+ return this.actions.filter(action => typeof action.enabled === 'function' ? action.enabled(this.user) : true)
+ },
},
methods: {
+ isSvg,
+
/**
* Toggle edit mode by emitting the update event
*/
diff --git a/apps/settings/src/components/Users/UserSettingsDialog.vue b/apps/settings/src/components/Users/UserSettingsDialog.vue
index d8db67514c4..94c77d320dd 100644
--- a/apps/settings/src/components/Users/UserSettingsDialog.vue
+++ b/apps/settings/src/components/Users/UserSettingsDialog.vue
@@ -1,23 +1,6 @@
<!--
- - @copyright 2023 Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license AGPL-3.0-or-later
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
@@ -42,6 +25,11 @@
{{ t('settings', 'Show storage path') }}
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch type="switch"
+ data-test="showFirstLogin"
+ :checked.sync="showFirstLogin">
+ {{ t('settings', 'Show first login') }}
+ </NcCheckboxRadioSwitch>
+ <NcCheckboxRadioSwitch type="switch"
data-test="showLastLogin"
:checked.sync="showLastLogin">
{{ t('settings', 'Show last login') }}
@@ -55,6 +43,9 @@
</NcNoteCard>
<fieldset>
<legend>{{ t('settings', 'Group list sorting') }}</legend>
+ <NcNoteCard class="dialog__note"
+ type="info"
+ :text="t('settings', 'Sorting only applies to the currently loaded groups for performance reasons. Groups will be loaded as you navigate or search through the list.')" />
<NcCheckboxRadioSwitch type="radio"
:checked.sync="groupSorting"
data-test="sortGroupsByMemberCount"
@@ -87,13 +78,14 @@
<NcAppSettingsSection id="default-settings"
:name="t('settings', 'Defaults')">
<NcSelect v-model="defaultQuota"
+ :clearable="false"
+ :create-option="validateQuota"
+ :filter-by="filterQuotas"
:input-label="t('settings', 'Default quota')"
- placement="top"
- :taggable="true"
:options="quotaOptions"
- :create-option="validateQuota"
+ placement="top"
:placeholder="t('settings', 'Select default quota')"
- :clearable="false"
+ taggable
@option:selected="setDefaultQuota" />
</NcAppSettingsSection>
</NcAppSettingsDialog>
@@ -104,14 +96,15 @@ import { formatFileSize, parseFileSize } from '@nextcloud/files'
import { generateUrl } from '@nextcloud/router'
import axios from '@nextcloud/axios'
-import NcAppSettingsDialog from '@nextcloud/vue/dist/Components/NcAppSettingsDialog.js'
-import NcAppSettingsSection from '@nextcloud/vue/dist/Components/NcAppSettingsSection.js'
-import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
-import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js'
-import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js'
+import NcAppSettingsDialog from '@nextcloud/vue/components/NcAppSettingsDialog'
+import NcAppSettingsSection from '@nextcloud/vue/components/NcAppSettingsSection'
+import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
+import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
+import NcSelect from '@nextcloud/vue/components/NcSelect'
import { GroupSorting } from '../../constants/GroupManagement.ts'
import { unlimitedQuota } from '../../utils/userUtils.ts'
+import logger from '../../logger.ts'
export default {
name: 'UserSettingsDialog',
@@ -181,6 +174,15 @@ export default {
},
},
+ showFirstLogin: {
+ get() {
+ return this.showConfig.showFirstLogin
+ },
+ set(status) {
+ this.setShowConfig('showFirstLogin', status)
+ },
+ },
+
showLastLogin: {
get() {
return this.showConfig.showLastLogin
@@ -246,8 +248,8 @@ export default {
newUserSendEmail: value,
})
await axios.post(generateUrl('/settings/users/preferences/newUser.sendEmail'), { value: value ? 'yes' : 'no' })
- } catch (e) {
- console.error('could not update newUser.sendEmail preference: ' + e.message, e)
+ } catch (error) {
+ logger.error('Could not update newUser.sendEmail preference', { error })
} finally {
this.loadingSendMail = false
}
@@ -256,6 +258,22 @@ export default {
},
methods: {
+ /**
+ * Check if a quota matches the current search.
+ * This is a custom filter function to allow to map "1GB" to the label "1 GB" (ignoring whitespaces).
+ *
+ * @param option The quota to check
+ * @param label The label of the quota
+ * @param search The search string
+ */
+ filterQuotas(option, label, search) {
+ const searchValue = search.toLocaleLowerCase().replaceAll(/\s/g, '')
+ return (label || '')
+ .toLocaleLowerCase()
+ .replaceAll(/\s/g, '')
+ .indexOf(searchValue) > -1
+ },
+
setShowConfig(key, status) {
this.$store.commit('setShowConfig', { key, value: status })
},
@@ -271,14 +289,13 @@ export default {
quota = quota?.id || quota.label
}
// only used for new presets sent through @Tag
- const validQuota = parseFileSize(quota)
+ const validQuota = parseFileSize(quota, true)
if (validQuota === null) {
return unlimitedQuota
- } else {
- // unify format output
- quota = formatFileSize(parseFileSize(quota))
- return { id: quota, label: quota }
}
+ // unify format output
+ quota = formatFileSize(validQuota)
+ return { id: quota, label: quota }
},
/**
@@ -308,6 +325,12 @@ export default {
</script>
<style scoped lang="scss">
+.dialog {
+ &__note {
+ font-weight: normal;
+ }
+}
+
fieldset {
font-weight: bold;
}
diff --git a/apps/settings/src/components/Users/VirtualList.vue b/apps/settings/src/components/Users/VirtualList.vue
index 4ccc3fc60d6..20dc70ef830 100644
--- a/apps/settings/src/components/Users/VirtualList.vue
+++ b/apps/settings/src/components/Users/VirtualList.vue
@@ -1,23 +1,6 @@
<!--
- - @copyright 2023 Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license AGPL-3.0-or-later
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
@@ -52,7 +35,7 @@
<script lang="ts">
import Vue from 'vue'
import { vElementVisibility } from '@vueuse/components'
-import { debounce } from 'debounce'
+import debounce from 'debounce'
import logger from '../../logger.ts'
@@ -174,6 +157,7 @@ export default Vue.extend({
display: block;
overflow: auto;
height: 100%;
+ will-change: scroll-position;
&__header,
&__footer {
@@ -188,7 +172,7 @@ export default Vue.extend({
}
&__footer {
- left: 0;
+ inset-inline-start: 0;
}
&__body {
diff --git a/apps/settings/src/components/Users/shared/styles.scss b/apps/settings/src/components/Users/shared/styles.scss
index a2ddcd8c8be..4dfdd58af6d 100644
--- a/apps/settings/src/components/Users/shared/styles.scss
+++ b/apps/settings/src/components/Users/shared/styles.scss
@@ -1,23 +1,6 @@
/**
- * @copyright 2023 Christopher Ng <chrng8@gmail.com>
- *
- * @author Christopher Ng <chrng8@gmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
@mixin row {
@@ -57,15 +40,19 @@
}
&--avatar {
- left: 0;
+ inset-inline-start: 0;
}
&--displayname {
- left: var(--avatar-cell-width);
- border-right: 1px solid var(--color-border);
+ inset-inline-start: var(--avatar-cell-width);
+ border-inline-end: 1px solid var(--color-border);
}
}
+ &--username {
+ padding-inline-start: calc(var(--default-grid-baseline) * 3);
+ }
+
&--avatar {
min-width: var(--avatar-cell-width);
width: var(--avatar-cell-width);
@@ -105,7 +92,7 @@
&--actions {
position: sticky;
- right: 0;
+ inset-inline-end: 0;
z-index: var(--sticky-column-z-index);
display: flex;
flex-direction: row;
@@ -113,7 +100,7 @@
min-width: 110px;
width: 110px;
background-color: var(--color-main-background);
- border-left: 1px solid var(--color-border);
+ border-inline-start: 1px solid var(--color-border);
}
}