enh(a11y): Users table

Signed-off-by: Christopher Ng <chrng8@gmail.com>
pull/39050/head
Christopher Ng 1 year ago
parent 97a93c73ce
commit cbfe0c67e9
  1. 2
      apps/settings/css/settings.css.map
  2. 278
      apps/settings/css/settings.scss
  3. 382
      apps/settings/src/components/UserList.vue
  4. 32
      apps/settings/src/components/Users/NewUserModal.vue
  5. 126
      apps/settings/src/components/Users/UserListFooter.vue
  6. 150
      apps/settings/src/components/Users/UserListHeader.vue
  7. 785
      apps/settings/src/components/Users/UserRow.vue
  8. 48
      apps/settings/src/components/Users/UserRowActions.vue
  9. 185
      apps/settings/src/components/Users/UserRowSimple.vue
  10. 110
      apps/settings/src/components/Users/shared/styles.scss
  11. 38
      apps/settings/src/mixins/UserRowMixin.js
  12. 23
      apps/settings/src/store/users.js
  13. 40
      apps/settings/src/utils/userUtils.ts
  14. 60
      apps/settings/src/views/Users.vue

File diff suppressed because one or more lines are too long

@ -1317,284 +1317,6 @@ doesnotexist:-o-prefocus, .strengthify-wrapper {
opacity: .7;
}
/* USERS LIST -------------------------------------------------------------- */
#body-settings {
$grid-row-height: 60px;
$grid-col-min-width: 160px;
#app-content.user-list-grid {
display: grid;
grid-column-gap: 20px;
grid-auto-rows: minmax(60px, max-content);
.row {
// TODO replace with css4 subgrid when available
// fallback for ie11 no grid
display: flex;
display: grid;
min-height: $grid-row-height;
grid-row-start: span 1;
grid-gap: 3px;
align-items: center;
/* let's define the column until storage path,
what follows will be manually defined */
grid-template-columns:
44px
minmax($grid-col-min-width + 30px, 1fr) // username, displayname
minmax($grid-col-min-width, 1fr) // password
minmax($grid-col-min-width, 1fr) // email
minmax(1.5*$grid-col-min-width, 1fr) // groups
minmax(1.5*$grid-col-min-width, 1fr) // group admins
minmax($grid-col-min-width, 1fr) // quota
minmax(1.5*$grid-col-min-width, 1fr) // manager
repeat(auto-fit, minmax($grid-col-min-width, 1fr));
border-bottom: var(--color-border) 1px solid;
&.disabled {
opacity: .5;
}
/* grid col width */
.name,
.password,
.mailAddress,
.languages,
.storageLocation,
.userBackend,
.lastLogin {
min-width: $grid-col-min-width;
doesnotexist:-o-prefocus, .strengthify-wrapper {
color: var(--color-text-dark);
vertical-align: baseline;
text-overflow: ellipsis;
}
}
&:not(.row--editable) {
&.name,
&.password,
&.displayName,
&.mailAddress,
&.userBackend,
&.languages {
overflow: hidden;
}
}
// Scroll if too much groups
&:not(.row--editable) {
.groups,
.subadmins,
.subAdminsGroups {
overflow: auto;
max-height: 100%;
}
}
.managers,
.groups,
.subadmins,
.subAdminsGroups,
.quota {
min-width: $grid-col-min-width;
.select {
width: 100%;
color: var(--color-text-dark);
vertical-align: baseline;
}
progress {
max-width: 95%;
}
}
.obfuscated {
width: 400px;
opacity: .7;
}
.userActions {
display: flex;
justify-content: flex-end;
position: sticky;
right: 0px;
min-width: 88px;
background-color: var(--color-main-background);
}
&.row--editable .userActions {
z-index: 10;
}
.subtitle {
color: var(--color-text-maxcontrast);
vertical-align: baseline;
}
/* various */
&#grid-header {
position: sticky;
align-self: normal;
background-color: var(--color-main-background);
z-index: 100; /* above multiselect */
top: 0;
&.sticky {
box-shadow: 0 -2px 10px 1px var(--color-box-shadow);
}
}
&#grid-header {
color: var(--color-text-maxcontrast);
border-bottom-width: thin;
#headerDisplayName,
#headerPassword,
#headerAddress,
#headerGroups,
#headerSubAdmins,
#theHeaderUserBackend,
#theHeaderLastLogin,
#headerQuota,
#theHeaderStorageLocation,
#headerLanguages {
/* Line up header text with column content for when there’s inputs */
padding-left: 7px;
text-transform: none;
color: var(--color-text-maxcontrast);
vertical-align: baseline;
}
}
&:hover {
&:not(#grid-header) {
box-shadow: 5px 0 0 var(--color-primary-element) inset;
}
}
> form {
width: 100%;
}
> div,
> .displayName > form,
> form {
grid-row: 1;
display: inline-flex;
color: var(--color-text-lighter);
flex-grow: 1;
> input:not(:focus):not(:active) {
border-color: transparent;
cursor: pointer;
}
> input:focus, > input:active {
+ .icon-confirm {
display: block !important;
}
}
/* inputs like mail, username, password */
&:not(.userActions) > input:not([type='submit']) {
width: 100%;
min-width: 0;
}
&.name {
word-break: break-all;
}
&.displayName,
&.mailAddress {
> input {
text-overflow: ellipsis;
flex-grow: 1;
}
}
&.name,
&.userBackend {
/* better multi-line visual */
line-height: 1.3em;
max-height: 100%;
overflow: hidden;
/* not supported by all browsers
so we keep the overflow hidden
as a fallback */
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
&.name .subtitle {
color: var(--color-main-text);
}
&.quota {
display: flex;;
justify-content: left;
white-space: nowrap;
position: relative;
progress {
width: 150px;
margin-top: 35px;
height: 3px;
}
}
.icon-confirm {
flex: 0 0 auto;
cursor: pointer;
&:not(:active) {
display: none;
}
}
&.avatar {
height: 32px;
width: 32px;
margin: 6px;
img {
display: block;
}
}
&.userActions {
display: flex;
align-items: center;
justify-content: flex-end;
// Make sure to cover whole row
height: 100%;
width: fit-content;
padding-inline: 12px;
background-color: var(--color-main-background);
}
}
}
.infinite-loading-container {
display: flex;
align-items: center;
justify-content: center;
grid-row-start: span 4;
}
.users-list-end {
opacity: .5;
user-select: none;
}
}
}
.animated {
animation: blink-animation 1s steps(5, start) 4;
}

@ -21,120 +21,90 @@
-->
<template>
<div id="app-content"
role="grid"
:aria-label="t('settings', 'User\'s table')"
class="user-list-grid"
@scroll.passive="onScroll">
<Fragment>
<NewUserModal v-if="showConfig.showNewUserForm"
:loading="loading"
:new-user="newUser"
:show-config="showConfig"
@reset="resetForm"
@close="showConfig.showNewUserForm = false" />
<div id="grid-header"
:class="{'sticky': scrolled && !showConfig.showNewUserForm}"
class="row">
<div id="headerAvatar" class="avatar" />
<div id="headerName" class="name">
<div class="subtitle">
<strong>
{{ t('settings', 'Display name') }}
</strong>
</div>
{{ t('settings', 'Username') }}
</div>
<div id="headerPassword" class="password">
{{ t('settings', 'Password') }}
</div>
<div id="headerAddress" class="mailAddress">
{{ t('settings', 'Email') }}
</div>
<div id="headerGroups" class="groups">
{{ t('settings', 'Groups') }}
</div>
<div v-if="subAdminsGroups.length>0 && settings.isAdmin"
id="headerSubAdmins"
class="subadmins">
{{ t('settings', 'Group admin for') }}
</div>
<div id="headerQuota" class="quota">
{{ t('settings', 'Quota') }}
</div>
<div v-if="showConfig.showLanguages"
id="headerLanguages"
class="languages">
{{ t('settings', 'Language') }}
</div>
<div v-if="showConfig.showUserBackend || showConfig.showStoragePath"
class="headerUserBackend userBackend">
<div v-if="showConfig.showUserBackend" class="userBackend">
{{ t('settings', 'User backend') }}
</div>
<div v-if="showConfig.showStoragePath"
class="subtitle storageLocation">
{{ t('settings', 'Storage location') }}
</div>
</div>
<div v-if="showConfig.showLastLogin"
class="headerLastLogin lastLogin">
{{ t('settings', 'Last login') }}
</div>
<div id="headerManager" class="manager">
{{ t('settings', 'Manager') }}
</div>
<div class="userActions" />
</div>
<UserRow v-for="user in filteredUsers"
:key="user.id"
:external-actions="externalActions"
:groups="groups"
:languages="languages"
:quota-options="quotaOptions"
:settings="settings"
:show-config="showConfig"
:sub-admins-groups="subAdminsGroups"
:user="user"
:users="users"
:is-dark-theme="isDarkTheme" />
<InfiniteLoading ref="infiniteLoading" @infinite="infiniteHandler">
<div slot="spinner">
<div class="users-icon-loading icon-loading" />
</div>
<div slot="no-more">
<div class="users-list-end" />
</div>
<div slot="no-results">
<div id="emptycontent">
<div class="icon-contacts-dark" />
<h2>{{ t('settings', 'No users in here') }}</h2>
</div>
</div>
</InfiniteLoading>
</div>
@reset="resetForm"
@close="closeModal" />
<NcEmptyContent v-if="filteredUsers.length === 0"
class="empty"
:title="isInitialLoad && loading.users ? null : t('settings', 'No users')">
<template #icon>
<NcLoadingIcon v-if="isInitialLoad && loading.users"
:title="t('settings', 'Loading users …')"
:size="64" />
<NcIconSvgWrapper v-else
:svg="usersSvg" />
</template>
</NcEmptyContent>
<RecycleScroller v-else
class="user-list"
:style="style"
ref="scroller"
:items="filteredUsers"
key-field="id"
role="table"
list-tag="tbody"
list-class="user-list__body"
item-tag="tr"
item-class="user-list__row"
:item-size="rowHeight"
@hook:mounted="handleMounted"
@scroll-end="handleScrollEnd">
<template #before>
<caption class="hidden-visually">
{{ t('settings', 'List of users. This list is not fully rendered for performances reasons. The users will be rendered as you navigate through the list.') }}
</caption>
<UserListHeader :has-obfuscated="hasObfuscated" />
</template>
<template #default="{ item: user }">
<UserRow :user="user"
:users="users"
:settings="settings"
:has-obfuscated="hasObfuscated"
:groups="groups"
:sub-admins-groups="subAdminsGroups"
:quota-options="quotaOptions"
:languages="languages"
:external-actions="externalActions" />
</template>
<template #after>
<UserListFooter :loading="loading.users"
:filtered-users="filteredUsers" />
</template>
</RecycleScroller>
</Fragment>
</template>
<script>
import Vue from 'vue'
import InfiniteLoading from 'vue-infinite-loading'
import { Fragment } from 'vue-frag'
import { RecycleScroller } from 'vue-virtual-scroller'
import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
import { showError } from '@nextcloud/dialogs'
import UserRow from './Users/UserRow.vue'
import NewUserModal from './Users/NewUserModal.vue'
import UserListFooter from './Users/UserListFooter.vue'
import UserListHeader from './Users/UserListHeader.vue'
import UserRow from './Users/UserRow.vue'
const unlimitedQuota = {
id: 'none',
label: t('settings', 'Unlimited'),
}
import { defaultQuota, isObfuscated, unlimitedQuota } from '../utils/userUtils.ts'
import logger from '../logger.js'
const defaultQuota = {
id: 'default',
label: t('settings', 'Default quota'),
}
import usersSvg from '../../img/users.svg?raw'
const newUser = {
id: '',
@ -155,20 +125,18 @@ export default {
name: 'UserList',
components: {
InfiniteLoading,
Fragment,
NcEmptyContent,
NcIconSvgWrapper,
NcLoadingIcon,
NewUserModal,
RecycleScroller,
UserListFooter,
UserListHeader,
UserRow,
},
props: {
users: {
type: Array,
default: () => [],
},
showConfig: {
type: Object,
required: true,
},
selectedGroup: {
type: String,
default: null,
@ -184,20 +152,39 @@ export default {
loading: {
all: false,
groups: false,
users: false,
},
scrolled: false,
isInitialLoad: true,
rowHeight: 55,
usersSvg,
searchQuery: '',
newUser: Object.assign({}, newUser),
}
},
computed: {
showConfig() {
return this.$store.getters.getShowConfig
},
settings() {
return this.$store.getters.getServerData
},
selectedGroupDecoded() {
return decodeURIComponent(this.selectedGroup)
style() {
return {
'--row-height': `${this.rowHeight}px`,
}
},
hasObfuscated() {
return this.filteredUsers.some(user => isObfuscated(user))
},
users() {
return this.$store.getters.getUsers
},
filteredUsers() {
if (this.selectedGroup === 'disabled') {
return this.users.filter(user => user.enabled === false)
@ -208,16 +195,19 @@ export default {
}
return this.users.filter(user => user.enabled !== false)
},
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))
},
subAdminsGroups() {
// data provided php side
return this.$store.getters.getSubadminGroups
},
quotaOptions() {
// convert the preset array into objects
const quotaPreset = this.settings.quotaPreset.reduce((acc, cur) => acc.concat({
@ -231,12 +221,15 @@ export default {
quotaPreset.unshift(defaultQuota)
return quotaPreset
},
usersOffset() {
return this.$store.getters.getUsersOffset
},
usersLimit() {
return this.$store.getters.getUsersLimit
},
usersCount() {
return this.users.length
},
@ -254,37 +247,29 @@ export default {
},
]
},
isDarkTheme() {
return window.getComputedStyle(this.$el)
.getPropertyValue('--background-invert-if-dark') === 'invert(100%)'
},
},
watch: {
// watch url change and group select
selectedGroup(val, old) {
async selectedGroup(val, old) {
this.isInitialLoad = true
// if selected is the disabled group but it's empty
this.redirectIfDisabled()
await this.redirectIfDisabled()
this.$store.commit('resetUsers')
this.$refs.infiniteLoading.stateChanger.reset()
await this.loadUsers()
this.setNewUserDefaultGroup(val)
},
// make sure the infiniteLoading state is changed if we manually
// add/remove data from the store
usersCount(val, old) {
// deleting the last user, reset the list
if (val === 0 && old === 1) {
this.$refs.infiniteLoading.stateChanger.reset()
// adding the first user, warn the infiniteLoader that
// the list is not empty anymore (we don't fetch the newly
// added user as we already have all the info we need)
} else if (val === 1 && old === 0) {
this.$refs.infiniteLoading.stateChanger.loaded()
}
filteredUsers(filteredUsers) {
logger.debug(`${filteredUsers.length} filtered user(s)`)
},
},
mounted() {
async created() {
await this.loadUsers()
},
async mounted() {
if (!this.settings.canChangePassword) {
OC.Notification.showTemporary(t('settings', 'Password change is disabled because the master key is disabled'))
}
@ -303,40 +288,58 @@ export default {
/**
* If disabled group but empty, redirect
*/
this.redirectIfDisabled()
await this.redirectIfDisabled()
},
beforeDestroy() {
unsubscribe('nextcloud:unified-search.search', this.search)
unsubscribe('nextcloud:unified-search.reset', this.resetSearch)
},
methods: {
onScroll(event) {
this.scrolled = event.target.scrollTo > 0
async handleMounted() {
// Add proper semantics to the recycle scroller slots
const header = this.$refs.scroller.$refs.before
const footer = this.$refs.scroller.$refs.after
header.classList.add('user-list__header')
header.setAttribute('role', 'rowgroup')
footer.classList.add('user-list__footer')
footer.setAttribute('role', 'rowgroup')
},
infiniteHandler($state) {
this.$store.dispatch('getUsers', {
offset: this.usersOffset,
limit: this.usersLimit,
group: this.selectedGroup !== 'disabled' ? this.selectedGroup : '',
search: this.searchQuery,
})
.then((usersCount) => {
if (usersCount > 0) {
$state.loaded()
}
if (usersCount < this.usersLimit) {
$state.complete()
}
async handleScrollEnd() {
await this.loadUsers()
},
async loadUsers() {
this.loading.users = true
try {
await this.$store.dispatch('getUsers', {
offset: this.usersOffset,
limit: this.usersLimit,
group: this.selectedGroup !== 'disabled' ? this.selectedGroup : '',
search: this.searchQuery,
})
logger.debug(`${this.users.length} total user(s) loaded`)
} catch (error) {
logger.error('Failed to load users', { error })
showError('Failed to load users')
}
this.loading.users = false
this.isInitialLoad = false
},
closeModal() {
this.$store.commit('setShowConfig', {
key: 'showNewUserForm',
value: false,
})
},
/* SEARCH */
search({ query }) {
async search({ query }) {
this.searchQuery = query
this.$store.commit('resetUsers')
this.$refs.infiniteLoading.stateChanger.reset()
await this.loadUsers()
},
resetSearch() {
@ -384,15 +387,86 @@ export default {
* we only check for 0 because we don't have the count on ldap
* and we therefore set the usercount to -1 in this specific case
*/
redirectIfDisabled() {
async redirectIfDisabled() {
const allGroups = this.$store.getters.getGroups
if (this.selectedGroup === 'disabled'
&& allGroups.findIndex(group => group.id === 'disabled' && group.usercount === 0) > -1) {
// disabled group is empty, redirection to all users
this.$router.push({ name: 'users' })
this.$refs.infiniteLoading.stateChanger.reset()
await this.loadUsers()
}
},
},
}
</script>
<style lang="scss" scoped>
@import './Users/shared/styles.scss';
.empty {
:deep {
.icon-vue {
width: 64px;
height: 64px;
svg {
max-width: 64px;
max-height: 64px;
}
}
}
}
.user-list {
--avatar-cell-width: 48px;
--cell-padding: 7px;
--cell-width: 200px;
--cell-min-width: calc(var(--cell-width) - (2 * var(--cell-padding)));
display: block;
overflow: auto;
height: 100%;
:deep {
.user-list {
&__body {
display: flex;
flex-direction: column;
width: 100%;
// Necessary for virtual scrolling absolute
position: relative;
margin-top: var(--row-height);
}
&__row {
@include row;
border-bottom: 1px solid var(--color-border);
&:hover {
background-color: var(--color-background-hover);
.row__cell:not(.row__cell--actions) {
background-color: var(--color-background-hover);
}
}
}
}
.vue-recycle-scroller__slot {
&.user-list__header,
&.user-list__footer {
position: sticky;
}
&.user-list__header {
top: 0;
z-index: 10;
}
&.user-list__footer {
left: 0;
}
}
}
}
</style>

@ -182,16 +182,6 @@ 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'
const unlimitedQuota = {
id: 'none',
label: t('settings', 'Unlimited'),
}
const defaultQuota = {
id: 'default',
label: t('settings', 'Default quota'),
}
export default {
name: 'NewUserModal',
@ -214,8 +204,8 @@ export default {
required: true,
},
showConfig: {
type: Object,
quotaOptions: {
type: Array,
required: true,
},
},
@ -227,6 +217,10 @@ export default {
},
computed: {
showConfig() {
return this.$store.getters.getShowConfig
},
settings() {
return this.$store.getters.getServerData
},
@ -265,20 +259,6 @@ export default {
})
},
quotaOptions() {
// convert the preset array into objects
const quotaPreset = this.settings.quotaPreset.reduce((acc, cur) => acc.concat({
id: cur,
label: cur,
}), [])
// add default presets
if (this.settings.allowUnlimitedQuota) {
quotaPreset.unshift(unlimitedQuota)
}
quotaPreset.unshift(defaultQuota)
return quotaPreset
},
languages() {
return [
{

@ -0,0 +1,126 @@
<!--
- @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/>.
-
-->
<template>
<tr class="footer">
<th scope="row">
<span class="hidden-visually">{{ t('settings', 'Total rows summary') }}</span>
</th>
<td class="footer__cell footer__cell--loading">
<NcLoadingIcon v-if="loading"
:title="t('settings', 'Loading users …')"
:size="32" />
</td>
<td class="footer__cell footer__cell--count footer__cell--multiline">
<span aria-describedby="user-count-desc">{{ userCount }}</span>
<span id="user-count-desc"
class="hidden-visually">
{{ t('settings', 'Scroll to load more rows') }}
</span>
</td>
</tr>
</template>
<script lang="ts">
import Vue from 'vue'
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
import {
translate as t,
translatePlural as n,
} from '@nextcloud/l10n'
export default Vue.extend({
name: 'UserListFooter',
components: {
NcLoadingIcon,
},
props: {
loading: {
type: Boolean,
required: true,
},
filteredUsers: {
type: Array,
required: true,
},
},
computed: {
userCount(): string {
if (this.loading) {
return this.n(
'settings',
'{userCount} user …',
'{userCount} users …',
this.filteredUsers.length,
{
userCount: this.filteredUsers.length,
},
)
}
return this.n(
'settings',
'{userCount} user',
'{userCount} users',
this.filteredUsers.length,
{
userCount: this.filteredUsers.length,
},
)
},
},
methods: {
t,
n,
},
})
</script>
<style lang="scss" scoped>
@import './shared/styles.scss';
.footer {
@include row;
@include cell;
&__cell {
position: sticky;
color: var(--color-text-maxcontrast);
&--loading {
left: 0;
width: var(--avatar-cell-width);
align-items: center;
padding: 0;
}
&--count {
left: var(--avatar-cell-width);
width: var(--cell-width);
}
}
}
</style>

@ -0,0 +1,150 @@
<!--
- @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/>.
-
-->
<template>
<tr class="header">
<th class="header__cell header__cell--avatar"
scope="col">
<span class="hidden-visually">
{{ t('settings', 'Avatar') }}
</span>
</th>
<th class="header__cell header__cell--displayname"
scope="col">
<strong>
{{ t('settings', 'Display name') }}
</strong>
<span class="header__subtitle">
{{ t('settings', 'Username') }}
</span>
</th>
<th class="header__cell"
:class="{ 'header__cell--obfuscated': hasObfuscated }"
scope="col">
<span>{{ passwordLabel }}</span>
</th>
<th class="header__cell"
scope="col">
<span>{{ t('settings', 'Email') }}</span>
</th>
<th class="header__cell header__cell--large"
scope="col">
<span>{{ t('settings', 'Groups') }}</span>
</th>
<th v-if="subAdminsGroups.length > 0 && settings.isAdmin"
class="header__cell header__cell--large"
scope="col">
<span>{{ t('settings', 'Group admin for') }}</span>
</th>
<th class="header__cell"
scope="col">
<span>{{ t('settings', 'Quota') }}</span>
</th>
<th v-if="showConfig.showLanguages"
class="header__cell header__cell--large"
scope="col">
<span>{{ t('settings', 'Language') }}</span>
</th>
<th v-if="showConfig.showUserBackend || showConfig.showStoragePath"
class="header__cell header__cell--large"
scope="col">
<span v-if="showConfig.showUserBackend">
{{ t('settings', 'User backend') }}
</span>
<span v-if="showConfig.showStoragePath"
class="header__subtitle">
{{ t('settings', 'Storage location') }}
</span>
</th>
<th v-if="showConfig.showLastLogin"
class="header__cell"
scope="col">
<span>{{ t('settings', 'Last login') }}</span>
</th>
<th class="header__cell header__cell--large"
scope="col">
<span>{{ t('settings', 'Manager') }}</span>
</th>
<th class="header__cell header__cell--actions"
scope="col">
<span class="hidden-visually">
{{ t('settings', 'User actions') }}
</span>
</th>
</tr>
</template>
<script lang="ts">
import Vue from 'vue'
import { translate as t } from '@nextcloud/l10n'
export default Vue.extend({
name: 'UserListHeader',
props: {
hasObfuscated: {
type: Boolean,
required: true,
},
},
computed: {
showConfig() {
// @ts-expect-error: allow untyped $store
return this.$store.getters.getShowConfig
},
settings() {
// @ts-expect-error: allow untyped $store
return this.$store.getters.getServerData
},
subAdminsGroups() {
// @ts-expect-error: allow untyped $store
return this.$store.getters.getSubadminGroups
},
passwordLabel(): string {
if (this.hasObfuscated) {
return t('settings', 'Password or insufficient permissions message')
}
return t('settings', 'Password')
},
},
methods: {
t,
},
})
</script>
<style lang="scss" scoped>
@import './shared/styles.scss';
.header {
@include row;
@include cell;
border-bottom: 1px solid var(--color-border);
}
</style>

File diff suppressed because it is too large Load Diff

@ -1,18 +1,44 @@
<!--
- @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/>.
-
-->
<template>
<NcActions :aria-label="t('settings', 'Toggle user actions menu')"
:disabled="disabled"
:inline="1">
<NcActionButton @click="toggleEdit">
<NcActionButton :disabled="disabled"
@click="toggleEdit">
{{ edit ? t('settings', 'Done') : t('settings', 'Edit') }}
<template #icon>
<NcIconSvgWrapper :svg="editSvg" aria-hidden="true" />
<NcIconSvgWrapper :key="editSvg" :svg="editSvg" aria-hidden="true" />
</template>
</NcActionButton>
<NcActionButton v-for="(action, index) in actions"
<NcActionButton v-for="({ action, icon, text }, index) in actions"
:key="index"
:aria-label="action.text"
:icon="action.icon"
@click="action.action">
{{ action.text }}
:disabled="disabled"
:aria-label="text"
:icon="icon"
@click="action">
{{ text }}
</NcActionButton>
</NcActions>
</template>
@ -48,6 +74,14 @@ export default defineComponent({
required: true,
},
/**
* The state whether the row is currently disabled
*/
disabled: {
type: Boolean,
required: true,
},
/**
* The state whether the row is currently edited
*/

@ -1,185 +0,0 @@
<template>
<div class="row"
:class="{'disabled': loading.delete || loading.disable}"
:data-id="user.id">
<div class="avatar" :class="{'icon-loading-small': loading.delete || loading.disable || loading.wipe}">
<img v-if="!loading.delete && !loading.disable && !loading.wipe"
alt=""
width="32"
height="32"
:src="generateAvatar(user.id, isDarkTheme)">
</div>
<!-- dirty hack to ellipsis on two lines -->
<div class="name">
<div class="displayName subtitle">
<div :title="user.displayname.length > 20 ? user.displayname : ''" class="cellText">
<strong>
{{ user.displayname }}
</strong>
</div>
</div>
{{ user.id }}
</div>
<div />
<div class="mailAddress">
<div :title="user.email !== null && user.email.length > 20 ? user.email : ''" class="cellText">
{{ user.email }}
</div>
</div>
<div class="groups">
{{ userGroupsLabels }}
</div>
<div v-if="subAdminsGroups.length > 0 && settings.isAdmin" class="subAdminsGroups">
{{ userSubAdminsGroupsLabels }}
</div>
<div class="userQuota">
<div class="quota">
{{ userQuota }} ({{ usedSpace }})
<progress class="quota-user-progress"
:class="{'warn': usedQuota > 80}"
:value="usedQuota"
max="100" />
</div>
</div>
<div v-if="showConfig.showLanguages" class="languages">
{{ userLanguage.name }}
</div>
<div v-if="showConfig.showUserBackend || showConfig.showStoragePath" class="userBackend">
<div v-if="showConfig.showUserBackend" class="userBackend">
{{ user.backend }}
</div>
<div v-if="showConfig.showStoragePath" :title="user.storageLocation" class="storageLocation subtitle">
{{ user.storageLocation }}
</div>
</div>
<div v-if="showConfig.showLastLogin" :title="userLastLoginTooltip" class="lastLogin">
{{ userLastLogin }}
</div>
<div class="managers">
{{ user.manager }}
</div>
<div class="userActions">
<UserRowActions v-if="canEdit && !loading.all"
:actions="userActions"
:edit="false"
@update:edit="toggleEdit" />
</div>
</div>
</template>
<script>
import { getCurrentUser } from '@nextcloud/auth'
import ClickOutside from 'vue-click-outside'
import UserRowActions from './UserRowActions.vue'
import UserRowMixin from '../../mixins/UserRowMixin.js'
export default {
name: 'UserRowSimple',
components: {
UserRowActions,
},
directives: {
ClickOutside,
},
mixins: [UserRowMixin],
props: {
user: {
type: Object,
required: true,
},
loading: {
type: Object,
required: true,
},
showConfig: {
type: Object,
required: true,
},
userActions: {
type: Array,
required: true,
},
openedMenu: {
type: Boolean,
required: true,
},
subAdminsGroups: {
type: Array,
required: true,
},
settings: {
type: Object,
required: true,
},
isDarkTheme: {
type: Boolean,
required: true,
},
},
computed: {
userGroupsLabels() {
return this.userGroups
.map(group => group.name)
.join(', ')
},
userSubAdminsGroupsLabels() {
return this.userSubAdminsGroups
.map(group => group.name)
.join(', ')
},
usedSpace() {
if (this.user.quota.used) {
return t('settings', '{size} used', { size: OC.Util.humanFileSize(this.user.quota.used) })
}
return t('settings', '{size} used', { size: OC.Util.humanFileSize(0) })
},
canEdit() {
return getCurrentUser().uid !== this.user.id || this.settings.isAdmin
},
userQuota() {
let quota = this.user.quota.quota
if (quota === 'default') {
quota = this.settings.defaultQuota
if (quota !== 'none') {
// convert to numeric value to match what the server would usually return
quota = OC.Util.computerFileSize(quota)
}
}
// when the default quota is unlimited, the server returns -3 here, map it to "none"
if (quota === 'none' || quota === -3) {
return t('settings', 'Unlimited')
} else if (quota >= 0) {
return OC.Util.humanFileSize(quota)
}
return OC.Util.humanFileSize(0)
},
},
methods: {
toggleMenu() {
this.$emit('update:openedMenu', !this.openedMenu)
},
hideMenu() {
this.$emit('update:openedMenu', false)
},
toggleEdit() {
this.$emit('update:editing', true)
},
},
}
</script>
<style lang="scss">
.cellText {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.icon-more {
background-color: var(--color-main-background);
border: 0;
}
</style>

@ -0,0 +1,110 @@
/**
* @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/>.
*
*/
@mixin row {
position: absolute;
display: flex;
height: var(--row-height);
background-color: var(--color-main-background);
}
@mixin cell {
&__cell {
display: flex;
flex-direction: column;
justify-content: center;
padding: 0 var(--cell-padding);
width: var(--cell-width);
color: var(--color-main-text);
strong,
span,
label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
overflow-wrap: anywhere;
}
@media (min-width: 670px) { /* Show one &--large column between stickied columns */
&--avatar,
&--displayname {
position: sticky;
z-index: 10;
background-color: var(--color-main-background);
}
&--avatar {
left: 0;
}
&--displayname {
left: var(--avatar-cell-width);
border-right: 1px solid var(--color-border);
}
}
&--avatar {
width: var(--avatar-cell-width);
align-items: center;
padding: 0;
user-select: none;
}
&--multiline {
span {
line-height: 1.3em;
white-space: unset;
@supports (-webkit-line-clamp: 2) {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
}
}
&--large {
width: 300px;
}
&--obfuscated {
width: 400px;
}
&--actions {
position: sticky;
right: 0;
z-index: 10;
display: flex;
flex-direction: row;
align-items: center;
width: 110px;
background-color: var(--color-main-background);
border-left: 1px solid var(--color-border);
}
}
&__subtitle {
color: var(--color-text-maxcontrast);
}
}

@ -22,8 +22,6 @@
*
*/
import { generateUrl } from '@nextcloud/router'
export default {
props: {
user: {
@ -46,10 +44,6 @@ export default {
type: Array,
default: () => [],
},
showConfig: {
type: Object,
default: () => ({}),
},
languages: {
type: Array,
required: true,
@ -60,6 +54,10 @@ export default {
},
},
computed: {
showConfig() {
return this.$store.getters.getShowConfig
},
/* GROUPS MANAGEMENT */
userGroups() {
const userGroups = this.groups.filter(group => this.user.groups.includes(group.id))
@ -153,32 +151,4 @@ export default {
return t('settings', 'Never')
},
},
methods: {
/**
* Generate avatar url
*
* @param {string} user The user name
* @param {bool} isDarkTheme Whether the avatar should be the dark version
* @return {string}
*/
generateAvatar(user, isDarkTheme) {
if (isDarkTheme) {
return generateUrl(
'/avatar/{user}/64/dark?v={version}',
{
user,
version: oc_userconfig.avatar.version,
}
)
} else {
return generateUrl(
'/avatar/{user}/64?v={version}',
{
user,
version: oc_userconfig.avatar.version,
}
)
}
},
},
}

@ -62,12 +62,22 @@ const state = {
usersOffset: 0,
usersLimit: 25,
userCount: 0,
showConfig: {
showStoragePath: false,
showUserBackend: false,
showLastLogin: false,
showNewUserForm: false,
showLanguages: false,
},
}
const mutations = {
appendUsers(state, usersObj) {
// convert obj to array
const users = state.users.concat(Object.keys(usersObj).map(userid => usersObj[userid]))
const existingUsers = state.users.map(({ id }) => id)
const newUsers = Object.values(usersObj)
.filter(({ id }) => !existingUsers.includes(id))
const users = state.users.concat(newUsers)
state.usersOffset += state.usersLimit
state.users = users
},
@ -149,7 +159,7 @@ const mutations = {
},
addUserData(state, response) {
const user = response.data.ocs.data
state.users.push(user)
state.users.unshift(user)
this.commit('updateUserCounts', { user, actionType: 'create' })
},
enableDisableUser(state, { userid, enabled }) {
@ -221,6 +231,10 @@ const mutations = {
state.users = []
state.usersOffset = 0
},
setShowConfig(state, { key, value }) {
state.showConfig[key] = value
},
}
const getters = {
@ -246,6 +260,9 @@ const getters = {
getUserCount(state) {
return state.userCount
},
getShowConfig(state) {
return state.showConfig
},
}
const CancelToken = axios.CancelToken

@ -0,0 +1,40 @@
/**
* @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/>.
*
*/
export const unlimitedQuota = {
id: 'none',
label: t('settings', 'Unlimited'),
}
export const defaultQuota = {
id: 'default',
label: t('settings', 'Default quota'),
}
/**
* Return `true` if the logged in user does not have permissions to view the
* data of `user`
*/
export const isObfuscated = (user: { id: string, [key: string]: any }) => {
const keys = Object.keys(user)
return keys.length === 1 && keys.at(0) === 'id'
}

@ -129,9 +129,7 @@
</template>
</NcAppNavigation>
<NcAppContent>
<UserList :users="users"
:show-config="showConfig"
:selected-group="selectedGroupDecoded"
<UserList :selected-group="selectedGroupDecoded"
:external-actions="externalActions" />
</NcAppContent>
</NcContent>
@ -160,6 +158,7 @@ import { generateUrl } from '@nextcloud/router'
import GroupListItem from '../components/GroupListItem.vue'
import UserList from '../components/UserList.vue'
import { unlimitedQuota } from '../utils/userUtils.ts'
Vue.use(VueLocalStorage)
@ -189,23 +188,17 @@ export default {
},
data() {
return {
// default quota is set to unlimited
unlimitedQuota: { id: 'none', label: t('settings', 'Unlimited') },
// temporary value used for multiselect change
selectedQuota: false,
externalActions: [],
loadingAddGroup: false,
loadingSendMail: false,
showConfig: {
showStoragePath: false,
showUserBackend: false,
showLastLogin: false,
showNewUserForm: false,
showLanguages: false,
},
}
},
computed: {
showConfig() {
return this.$store.getters.getShowConfig
},
selectedGroupDecoded() {
return this.selectedGroup ? decodeURIComponent(this.selectedGroup) : null
},
@ -224,25 +217,33 @@ export default {
// Local settings
showLanguages: {
get() { return this.getLocalstorage('showLanguages') },
get() {
return this.getLocalstorage('showLanguages')
},
set(status) {
this.setLocalStorage('showLanguages', status)
},
},
showLastLogin: {
get() { return this.getLocalstorage('showLastLogin') },
get() {
return this.getLocalstorage('showLastLogin')
},
set(status) {
this.setLocalStorage('showLastLogin', status)
},
},
showUserBackend: {
get() { return this.getLocalstorage('showUserBackend') },
get() {
return this.getLocalstorage('showUserBackend')
},
set(status) {
this.setLocalStorage('showUserBackend', status)
},
},
showStoragePath: {
get() { return this.getLocalstorage('showStoragePath') },
get() {
return this.getLocalstorage('showStoragePath')
},
set(status) {
this.setLocalStorage('showStoragePath', status)
},
@ -261,7 +262,7 @@ export default {
const quotaPreset = this.settings.quotaPreset.reduce((acc, cur) => acc.concat({ id: cur, label: cur }), [])
// add default presets
if (this.settings.allowUnlimitedQuota) {
quotaPreset.unshift(this.unlimitedQuota)
quotaPreset.unshift(unlimitedQuota)
}
return quotaPreset
},
@ -271,11 +272,11 @@ export default {
if (this.selectedQuota !== false) {
return this.selectedQuota
}
if (this.settings.defaultQuota !== this.unlimitedQuota.id && OC.Util.computerFileSize(this.settings.defaultQuota) >= 0) {
if (this.settings.defaultQuota !== unlimitedQuota.id && OC.Util.computerFileSize(this.settings.defaultQuota) >= 0) {
// if value is valid, let's map the quotaOptions or return custom quota
return { id: this.settings.defaultQuota, label: this.settings.defaultQuota }
}
return this.unlimitedQuota // unlimited
return unlimitedQuota // unlimited
},
set(quota) {
this.selectedQuota = quota
@ -340,17 +341,20 @@ export default {
},
methods: {
showNewUserMenu() {
this.showConfig.showNewUserForm = true
this.$store.commit('setShowConfig', {
key: 'showNewUserForm',
value: true,
})
},
getLocalstorage(key) {
// force initialization
const localConfig = this.$localStorage.get(key)
// if localstorage is null, fallback to original values
this.showConfig[key] = localConfig !== null ? localConfig === 'true' : this.showConfig[key]
this.$store.commit('setShowConfig', { key, value: localConfig !== null ? localConfig === 'true' : this.showConfig[key] })
return this.showConfig[key]
},
setLocalStorage(key, status) {
this.showConfig[key] = status
this.$store.commit('setShowConfig', { key, value: status })
this.$localStorage.set(key, status)
return status
},
@ -363,7 +367,7 @@ export default {
setDefaultQuota(quota = 'none') {
// Make sure correct label is set for unlimited quota
if (quota === 'none') {
quota = this.unlimitedQuota
quota = unlimitedQuota
}
this.$store.dispatch('setAppConfig', {
app: 'files',
@ -391,7 +395,7 @@ export default {
// only used for new presets sent through @Tag
const validQuota = OC.Util.computerFileSize(quota)
if (validQuota === null) {
return this.unlimitedQuota
return unlimitedQuota
} else {
// unify format output
quota = OC.Util.humanFileSize(OC.Util.computerFileSize(quota))
@ -485,6 +489,14 @@ export default {
</script>
<style lang="scss" scoped>
.app-content {
// Virtual list needs to be full height and is scrollable
display: flex;
overflow: hidden;
flex-direction: column;
max-height: 100%;
}
// force hiding the editing action for the add group entry
.app-navigation__list #addgroup::v-deep .app-navigation-entry__utils {
display: none;

Loading…
Cancel
Save