Merge pull request #39050 from nextcloud/enh/a11y-users-table

This commit is contained in:
Pytal 2023-07-12 22:29:05 -07:00 committed by GitHub
commit 41540ad4c3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
39 changed files with 1568 additions and 1222 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -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 theres 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;
}

View file

@ -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" />
@reset="resetForm"
@close="closeModal" />
<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>
<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
},
/* SEARCH */
search({ query }) {
closeModal() {
this.$store.commit('setShowConfig', {
key: 'showNewUserForm',
value: false,
})
},
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>

View file

@ -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 [
{

View file

@ -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>

View file

@ -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

View file

@ -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
*/

View file

@ -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>

View file

@ -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);
}
}

View file

@ -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,
}
)
}
},
},
}

View file

@ -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

View file

@ -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'
}

View file

@ -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;

View file

@ -24,29 +24,126 @@ import { User } from '@nextcloud/cypress'
const admin = new User('admin', 'admin')
const jdoe = new User('jdoe', 'jdoe')
const john = new User('john', '123456')
describe('Settings: Create and delete users', function() {
before(function() {
cy.login(admin)
// open the User settings
cy.visit('/settings/users')
})
after(() => {
cy.deleteUser(jdoe)
beforeEach(function() {
cy.login(admin)
cy.listUsers().then((users) => {
cy.login(admin)
if (users.includes('john')) {
// ensure created user is deleted
cy.deleteUser(john).login(admin)
// ensure deleted user is not present
cy.reload().login(admin)
}
})
})
it('Can create a user', function() {
// open the New user modal
cy.get('button#new-user-button').click()
cy.get('form[data-test="form"]').within(() => {
// see that the username is ""
cy.get('input[data-test="username"]').should('exist').and('have.value', '')
// set the username to john
cy.get('input[data-test="username"]').type('john')
// see that the username is john
cy.get('input[data-test="username"]').should('have.value', 'john')
// see that the password is ""
cy.get('input[type="password"]').should('exist').and('have.value', '')
// set the password to 123456
cy.get('input[type="password"]').type('123456')
// see that the password is 123456
cy.get('input[type="password"]').should('have.value', '123456')
// submit the new user form
cy.get('button[type="submit"]').click()
})
// Ignore failure if modal is not shown
cy.once('fail', (error) => {
expect(error.name).to.equal('AssertionError')
expect(error).to.have.property('node', '.modal-container')
})
// Make sure no confirmation modal is shown on top of the New user modal
cy.get('body').find('.modal-container').then(($modals) => {
if ($modals.length > 1) {
cy.wrap($modals.first()).find('input[type="password"]').type(admin.password)
cy.wrap($modals.first()).find('button').contains('Confirm').click()
}
})
// see that the created user is in the list
cy.get(`tbody.user-list__body tr td[data-test="john"]`).parents('tr').within(() => {
// see that the list of users contains the user john
cy.contains('john').should('exist')
})
})
it('Can create a user with additional field data', function() {
// open the New user modal
cy.get('button#new-user-button').click()
cy.get('form[data-test="form"]').within(() => {
// set the username
cy.get('input[data-test="username"]').should('exist').and('have.value', '')
cy.get('input[data-test="username"]').type('john')
cy.get('input[data-test="username"]').should('have.value', 'john')
// set the display name
cy.get('input[data-test="displayName"]').should('exist').and('have.value', '')
cy.get('input[data-test="displayName"]').type('John Smith')
cy.get('input[data-test="displayName"]').should('have.value', 'John Smith')
// set the email
cy.get('input[data-test="email"]').should('exist').and('have.value', '')
cy.get('input[data-test="email"]').type('john@example.org')
cy.get('input[data-test="email"]').should('have.value', 'john@example.org')
// set the password
cy.get('input[type="password"]').should('exist').and('have.value', '')
cy.get('input[type="password"]').type('123456')
cy.get('input[type="password"]').should('have.value', '123456')
// submit the new user form
cy.get('button[type="submit"]').click()
})
// Ignore failure if modal is not shown
cy.once('fail', (error) => {
expect(error.name).to.equal('AssertionError')
expect(error).to.have.property('node', '.modal-container')
})
// Make sure no confirmation modal is shown on top of the New user modal
cy.get('body').find('.modal-container').then(($modals) => {
if ($modals.length > 1) {
cy.wrap($modals.first()).find('input[type="password"]').type(admin.password)
cy.wrap($modals.first()).find('button').contains('Confirm').click()
}
})
// see that the created user is in the list
cy.get(`tbody.user-list__body tr td[data-test="john"]`).parents('tr').within(() => {
// see that the list of users contains the user john
cy.contains('john').should('exist')
})
})
it('Can delete a user', function() {
// ensure user exists
// create user
cy.createUser(jdoe).login(admin)
// open the User settings
cy.visit('/settings/users')
// ensure created user is present
cy.reload().login(admin)
// see that the user is in the list
cy.get(`.user-list-grid .row[data-id="${jdoe.userId}"]`).within(() => {
cy.get(`tbody.user-list__body tr td[data-test="${jdoe.userId}"]`).parents('tr').within(() => {
// see that the list of users contains the user jdoe
cy.contains(jdoe.userId).should('exist')
// open the actions menu for the user
cy.get('.userActions button.action-item__menutoggle').click()
cy.get('td.row__cell--actions button.action-item__menutoggle').click()
})
// The "Delete user" action in the actions menu is shown and clicked
@ -54,6 +151,6 @@ describe('Settings: Create and delete users', function() {
// And confirmation dialog accepted
cy.get('.oc-dialog button').contains(`Delete ${jdoe.userId}`).click()
// deleted clicked the user is not shown anymore
cy.get(`.user-list-grid .row[data-id="${jdoe.userId}"]`).should('not.exist')
cy.get(`tbody.user-list__body tr td[data-test="${jdoe.userId}"]`).parents('tr').should('not.be.visible')
})
})

View file

@ -0,0 +1,116 @@
/**
* @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/>.
*
*/
import { User } from '@nextcloud/cypress'
const admin = new User('admin', 'admin')
describe('Settings: Show and hide columns', function() {
before(function() {
cy.login(admin)
// open the User settings
cy.visit('/settings/users')
})
beforeEach(function() {
// open the settings pane
cy.get('.app-navigation button.settings-button').click()
// reset all toggles
cy.get('.app-navigation #app-settings__content input[type="checkbox"]').uncheck({ force: true })
// enable the last login toggle
cy.get('.app-navigation #app-settings__content').within(() => {
cy.get('[data-test="showLastLogin"] input[type="checkbox"]').check({ force: true })
})
// close the settings pane
cy.get('.app-navigation button.settings-button').click()
})
it('Can show a column', function() {
// see that the language column is not in the header
cy.get(`.user-list__header tr`).within(() => {
cy.contains('Language').should('not.exist')
})
// see that the language column is not in all user rows
cy.get(`tbody.user-list__body tr`).each(($row) => {
cy.wrap($row).get('[data-test="language"]').should('not.exist')
})
// open the settings pane
cy.get('.app-navigation button.settings-button').click()
// enable the languages toggle
cy.get('.app-navigation #app-settings__content').within(() => {
cy.get('[data-test="showLanguages"] input[type="checkbox"]').should('not.be.checked')
cy.get('[data-test="showLanguages"] input[type="checkbox"]').check({ force: true })
cy.get('[data-test="showLanguages"] input[type="checkbox"]').should('be.checked')
})
// close the settings pane
cy.get('.app-navigation button.settings-button').click()
// see that the language column is in the header
cy.get(`.user-list__header tr`).within(() => {
cy.contains('Language').should('exist')
})
// see that the language column is in all user rows
cy.get(`tbody.user-list__body tr`).each(($row) => {
cy.wrap($row).get('[data-test="language"]').should('exist')
})
})
it('Can hide a column', function() {
// see that the last login column is in the header
cy.get(`.user-list__header tr`).within(() => {
cy.contains('Last login').should('exist')
})
// see that the last login column is in all user rows
cy.get(`tbody.user-list__body tr`).each(($row) => {
cy.wrap($row).get('[data-test="lastLogin"]').should('exist')
})
// open the settings pane
cy.get('.app-navigation button.settings-button').click()
// disable the last login toggle
cy.get('.app-navigation #app-settings__content').within(() => {
cy.get('[data-test="showLastLogin"] input[type="checkbox"]').should('be.checked')
cy.get('[data-test="showLastLogin"] input[type="checkbox"]').uncheck({ force: true })
cy.get('[data-test="showLastLogin"] input[type="checkbox"]').should('not.be.checked')
})
// close the settings pane
cy.get('.app-navigation button.settings-button').click()
// see that the last login column is not in the header
cy.get(`.user-list__header tr`).within(() => {
cy.contains('Last login').should('not.exist')
})
// see that the last login column is not in all user rows
cy.get(`tbody.user-list__body tr`).each(($row) => {
cy.wrap($row).get('[data-test="lastLogin"]').should('not.exist')
})
})
})

View file

@ -29,6 +29,8 @@ describe('Settings: Disable and enable users', function() {
before(function() {
cy.createUser(jdoe)
cy.login(admin)
// open the User settings
cy.visit('/settings/users')
})
after(() => {
@ -38,44 +40,42 @@ describe('Settings: Disable and enable users', function() {
it('Can disable the user', function() {
// ensure user is enabled
cy.enableUser(jdoe)
// open the User settings
cy.visit('/settings/users')
// see that the user is in the list of active users
cy.get(`.user-list-grid .row[data-id="${jdoe.userId}"]`).within(() => {
cy.get(`tbody.user-list__body tr td[data-test="${jdoe.userId}"]`).parents('tr').within(() => {
// see that the list of users contains the user jdoe
cy.contains(jdoe.userId).should('exist')
// open the actions menu for the user
cy.get('.userActions button.action-item__menutoggle').click()
cy.get('td.row__cell--actions button.action-item__menutoggle').click()
})
// The "Disable user" action in the actions menu is shown and clicked
cy.get('.action-item__popper .action').contains('Disable user').should('exist').click()
// When clicked the section is not shown anymore
cy.get(`.user-list-grid .row[data-id="${jdoe.userId}"]`).should('not.exist')
cy.get(`tbody.user-list__body tr td[data-test="${jdoe.userId}"]`).parents('tr').should('not.be.visible')
// But the disabled user section now exists
cy.get('#disabled').should('exist')
// Open disabled users section
cy.get('#disabled a').click()
cy.url().should('match', /\/disabled/)
// The list of disabled users should now contain the user
cy.get(`.user-list-grid .row[data-id="${jdoe.userId}"]`).should('exist')
cy.get(`tbody.user-list__body tr td[data-test="${jdoe.userId}"]`).parents('tr').should('exist')
})
it('Can enable the user', function() {
// ensure user is disabled
cy.enableUser(jdoe, false)
// open the User settings
cy.visit('/settings/users')
// Open disabled users section
cy.get('#disabled a').click()
cy.url().should('match', /\/disabled/)
cy.get(`.user-list-grid .row[data-id="${jdoe.userId}"]`).within(() => {
// see that the user is in the list of active users
cy.get(`tbody.user-list__body tr td[data-test="${jdoe.userId}"]`).parents('tr').within(() => {
// see that the list of disabled users contains the user jdoe
cy.contains(jdoe.userId).should('exist')
// open the actions menu for the user
cy.get('.userActions button.action-item__menutoggle').click()
cy.get('td.row__cell--actions button.action-item__menutoggle').click()
})
// The "Enable user" action in the actions menu is shown and clicked

View file

@ -29,33 +29,80 @@ describe('Settings: Change user properties', function() {
before(function() {
cy.createUser(jdoe)
cy.login(admin)
// open the User settings
cy.visit('/settings/users')
})
beforeEach(function() {
cy.get(`tbody.user-list__body tr td[data-test="${jdoe.userId}"]`).parents('tr').within(() => {
// reset edit mode for the user jdoe
cy.get('td.row__cell--actions .action-items > button:first-of-type')
.invoke('attr', 'title')
.then((title) => {
if (title === 'Done') {
cy.get('td.row__cell--actions .action-items > button:first-of-type').click()
}
})
})
})
after(() => {
cy.deleteUser(jdoe)
})
it('Can change the password', function() {
// open the User settings
cy.visit('/settings/users')
cy.get(`.user-list-grid .row[data-id="${jdoe.userId}"]`).within(($row) => {
it('Can change the display name', function() {
cy.get(`tbody.user-list__body tr td[data-test="${jdoe.userId}"]`).parents('tr').within(() => {
// see that the list of users contains the user jdoe
cy.contains(jdoe.userId).should('exist')
// toggle the edit mode for the user jdoe
cy.get('.userActions .action-items > button:first-of-type').click()
cy.get('td.row__cell--actions .action-items > button:first-of-type').click()
})
cy.get(`.user-list-grid .row[data-id="${jdoe.userId}"]`).within(($row) => {
// see that the edit mode is on
cy.wrap($row).should('have.class', 'row--editable')
cy.get(`tbody.user-list__body tr td[data-test="${jdoe.userId}"]`).parents('tr').within(() => {
// set the display name
cy.get('input[data-test="displayNameField"]').should('exist').and('have.value', 'jdoe')
cy.get('input[data-test="displayNameField"]').clear()
cy.get('input[data-test="displayNameField"]').type('John Doe')
cy.get('input[data-test="displayNameField"]').should('have.value', 'John Doe')
cy.get('input[data-test="displayNameField"] ~ button').click()
// Ignore failure if modal is not shown
cy.once('fail', (error) => {
expect(error.name).to.equal('AssertionError')
expect(error).to.have.property('node', '.modal-container')
})
// Make sure no confirmation modal is shown
cy.root().closest('body').find('.modal-container').then(($modal) => {
if ($modal.length > 0) {
cy.wrap($modal).find('input[type="password"]').type(admin.password)
cy.wrap($modal).find('button').contains('Confirm').click()
}
})
// see that the display name cell is done loading
cy.get('.user-row-text-field.icon-loading-small').should('exist')
cy.waitUntil(() => cy.get('.user-row-text-field.icon-loading-small').should('not.exist'), { timeout: 10000 })
})
// Success message is shown
cy.get('.toastify.toast-success').contains(/Display.+name.+was.+successfully.+changed/i).should('exist')
})
it('Can change the password', function() {
cy.get(`tbody.user-list__body tr td[data-test="${jdoe.userId}"]`).parents('tr').within(() => {
// see that the list of users contains the user jdoe
cy.contains(jdoe.userId).should('exist')
// toggle the edit mode for the user jdoe
cy.get('td.row__cell--actions .action-items > button:first-of-type').click()
})
cy.get(`tbody.user-list__body tr td[data-test="${jdoe.userId}"]`).parents('tr').within(() => {
// see that the password of user0 is ""
cy.get('input[type="password"]').should('exist').and('have.value', '')
// set the password for user0 to 123456
cy.get('input[type="password"]').type('123456')
// When I set the password for user0 to 123456
cy.get('input[type="password"]').should('have.value', '123456')
cy.get('.password button').click()
cy.get('input[type="password"] ~ button').click()
// Ignore failure if modal is not shown
cy.once('fail', (error) => {

4
dist/core-common.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/files-main.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,9 +1,3 @@
/*!
* vue-infinite-loading v2.4.5
* (c) 2016-2020 PeachScript
* MIT License
*/
/*! For license information please see NcAppNavigationCaption.js.LICENSE.txt */
/*! For license information please see NcAppNavigationNew.js.LICENSE.txt */
@ -11,27 +5,3 @@
/*! For license information please see NcAppNavigationNewItem.js.LICENSE.txt */
/*! For license information please see NcAppNavigationSettings.js.LICENSE.txt */
/**
* @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author Greta Doci <gretadoci@gmail.com>
* @author John Molakvoæ <skjnldsv@protonmail.com>
* @author Roeland Jago Douma <roeland@famdouma.nl>
*
* @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/>.
*
*/

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -4,6 +4,28 @@
* @license MIT
*/
/**
* @copyright 2020 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @author Roeland Jago Douma <roeland@famdouma.nl>
*
* @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/>.
*
*/
/**
* @copyright 2022, Julia Kirschenheuter <julia.kirschenheuter@nextcloud.com>
*

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -82,7 +82,7 @@ class UsersSettingsContext implements Context, ActorAwareInterface {
* @return Locator
*/
public static function rowForUser($user) {
return Locator::forThe()->css("div.user-list-grid div.row[data-id=$user]")->
return Locator::forThe()->xpath("//tbody[contains(@class, 'user-list__body')]/tr[td[@data-test='$user']]")->
describedAs("Row for user $user in Users Settings");
}

View file

@ -50,7 +50,7 @@ Feature: header
And I click the New user button
And I see that the new user form is shown
And I create user user2 with password 123456acb
And I see that the list of users contains the user user2
# And I see that the list of users contains the user user2
When I open the Contacts menu
Then I see that the Contacts menu is shown
And I see that the contact "user0" in the Contacts menu is shown
@ -84,4 +84,3 @@ Feature: header
Then I see that the no results message in the Contacts menu is shown
And I see that the contact "user0" in the Contacts menu is not shown
And I see that the contact "admin" in the Contacts menu is not shown

View file

@ -44,7 +44,7 @@ Feature: login
And I click the New user button
And I see that the new user form is shown
And I create user unknownUser with password 123456acb
And I see that the list of users contains the user unknownUser
# And I see that the list of users contains the user unknownUser
And I act as John
And I log in with user unknownUser and password 123456acb
Then I see that the current page is the Files app

View file

@ -1,71 +1,13 @@
@apache
Feature: users
Scenario: create a new user
Given I act as Jane
And I am logged in as the admin
And I open the User settings
And I click the New user button
And I see that the new user form is shown
When I create user unknownUser with password 123456acb
Then I see that the list of users contains the user unknownUser
Scenario: create a new user with a custom display name
Given I am logged in as the admin
And I open the User settings
When I click the New user button
And I see that the new user form is shown
And I set the user name for the new user to "test"
And I set the display name for the new user to "Test display name"
And I set the password for the new user to "123456acb"
And I create the new user
Then I see that the list of users contains the user "test"
# And I see that the display name for the user "test" is "Test display name"
# Scenario: delete a user
# Given I act as Jane
# And I am logged in as the admin
# And I open the User settings
# And I see that the list of users contains the user user0
# And I open the actions menu for the user user0
# And I see that the "Delete user" action in the user0 actions menu is shown
# When I click the "Delete user" action in the user0 actions menu
# And I click the "Delete user0's account" button of the confirmation dialog
# Then I see that the list of users does not contains the user user0
# Scenario: disable a user
# Given I act as Jane
# And I am logged in as the admin
# And I open the User settings
# And I see that the list of users contains the user user0
# And I open the actions menu for the user user0
# And I see that the "Disable user" action in the user0 actions menu is shown
# When I click the "Disable user" action in the user0 actions menu
# Then I see that the list of users does not contains the user user0
# When I open the "Disabled users" section
# Then I see that the list of users contains the user user0
# Scenario: users navigation without disabled users
# Given I act as Jane
# And I am logged in as the admin
# And I open the User settings
# And I open the "Disabled users" section
# And I see that the list of users contains the user disabledUser
# And I open the actions menu for the user disabledUser
# And I see that the "Enable user" action in the disabledUser actions menu is shown
# When I click the "Enable user" action in the disabledUser actions menu
# Then I see that the section "Disabled users" is not shown
# # check again after reloading the settings
# When I open the User settings
# Then I see that the section "Disabled users" is not shown
Scenario: assign user to a group
Given I act as Jane
And I am logged in as the admin
And I open the User settings
And I see that the list of users contains the user user0
When I toggle the edit mode for the user user0
Then I see that the edit mode is on for user user0
# And I see that the list of users contains the user user0
# When I toggle the edit mode for the user user0
# Then I see that the edit mode is on for user user0
# disabled because we need the TAB patch:
# https://github.com/minkphp/MinkSelenium2Driver/pull/244
# When I assign the user user0 to the group admin
@ -76,7 +18,7 @@ Feature: users
Given I act as Jane
And I am logged in as the admin
And I open the User settings
And I see that the list of users contains the user user0
# And I see that the list of users contains the user user0
# disabled because we need the TAB patch:
# https://github.com/minkphp/MinkSelenium2Driver/pull/244
# And I assign the user user0 to the group Group1
@ -101,44 +43,6 @@ Feature: users
# When I click the "Yes" button of the confirmation dialog
# Then I see that the section Group1 is not shown
Scenario: change columns visibility
Given I act as Jane
And I am logged in as the admin
And I open the User settings
And I open the settings
And I see that the settings are opened
When I toggle the showLanguages checkbox in the settings
Then I see that the "Language" column is shown
When I toggle the showLastLogin checkbox in the settings
Then I see that the "Last login" column is shown
When I toggle the showStoragePath checkbox in the settings
Then I see that the "Storage location" column is shown
When I toggle the showUserBackend checkbox in the settings
Then I see that the "User backend" column is shown
# Scenario: change display name
# Given I act as Jane
# And I am logged in as the admin
# And I open the User settings
# And I see that the list of users contains the user user0
# And I see that the displayName of user0 is user0
# When I set the displayName for user0 to user1
# And I see that the displayName cell for user user0 is done loading
# Then I see that the displayName of user0 is user1
# Scenario: change password
# Given I act as Jane
# And I am logged in as the admin
# And I open the User settings
# And I see that the list of users contains the user user0
# When I toggle the edit mode for the user user0
# Then I see that the edit mode is on for user user0
# And I see that the password of user0 is ""
# When I set the password for user0 to 123456
# And I see that the password cell for user user0 is done loading
# # password input is emptied on change
# Then I see that the password of user0 is ""
# Scenario: change email
# Given I act as Jane
# And I am logged in as the admin
@ -153,10 +57,10 @@ Feature: users
Given I act as Jane
And I am logged in as the admin
And I open the User settings
And I see that the list of users contains the user user0
When I toggle the edit mode for the user user0
Then I see that the edit mode is on for user user0
And I see that the user quota of user0 is Unlimited
# And I see that the list of users contains the user user0
# When I toggle the edit mode for the user user0
# Then I see that the edit mode is on for user user0
# And I see that the user quota of user0 is Unlimited
# disabled because we need the TAB patch:
# https://github.com/minkphp/MinkSelenium2Driver/pull/244
# When I set the user user0 quota to 1GB