mirror of
https://github.com/nextcloud/server.git
synced 2026-04-15 22:11:17 -04:00
enh(a11y): Users table
Signed-off-by: Christopher Ng <chrng8@gmail.com>
This commit is contained in:
parent
97a93c73ce
commit
cbfe0c67e9
14 changed files with 1220 additions and 1033 deletions
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" />
|
||||
@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>
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
{
|
||||
|
|
|
|||
126
apps/settings/src/components/Users/UserListFooter.vue
Normal file
126
apps/settings/src/components/Users/UserListFooter.vue
Normal 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>
|
||||
150
apps/settings/src/components/Users/UserListHeader.vue
Normal file
150
apps/settings/src/components/Users/UserListHeader.vue
Normal 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
|
|
@ -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>
|
||||
110
apps/settings/src/components/Users/shared/styles.scss
Normal file
110
apps/settings/src/components/Users/shared/styles.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
40
apps/settings/src/utils/userUtils.ts
Normal file
40
apps/settings/src/utils/userUtils.ts
Normal 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'
|
||||
}
|
||||
|
|
@ -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…
Reference in a new issue