site-accueil-insa/matomo/plugins/UsersManager/vue/src/PagedUsersList/PagedUsersList.vue

751 lines
23 KiB
Vue

<!--
Matomo - free/libre analytics platform
@link https://matomo.org
@license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
-->
<template>
<div
class="pagedUsersList"
:class="{loading: isLoadingUsers}"
>
<div class="userListFilters row">
<div class="col s12 m12 l8">
<div class="input-field col s12 m3 l3">
<a
class="dropdown-trigger btn bulk-actions"
href=""
data-target="user-list-bulk-actions"
:class="{ disabled: isBulkActionsDisabled }"
v-dropdown-menu
>
{{ translate('UsersManager_BulkActions') }}
</a>
<ul
id="user-list-bulk-actions"
class="dropdown-content"
>
<li>
<a
class="dropdown-trigger"
data-target="bulk-set-access"
v-dropdown-menu
>
{{ translate('UsersManager_SetPermission') }}
</a>
<ul
id="bulk-set-access"
class="dropdown-content"
>
<li v-for="access in bulkActionAccessLevels" :key="access.key">
<a
href=""
@click.prevent="
userToChange = null; roleToChangeTo = access.key; showAccessChangeConfirm();
"
>
{{ access.value }}
</a>
</li>
</ul>
</li>
<li>
<a
href=""
@click.prevent="
userToChange = null; roleToChangeTo = 'noaccess'; showAccessChangeConfirm();
"
>
{{ translate('UsersManager_RemovePermissions') }}
</a>
</li>
<li v-if="currentUserRole === 'superuser'">
<a
href=""
@click.prevent="showDeleteConfirm()"
>{{ translate('UsersManager_DeleteUsers') }}</a>
</li>
</ul>
</div>
<div class="input-field col s12 m3 l3">
<div
class="permissions-for-selector"
>
<Field
:model-value="userTextFilter"
@update:model-value="onUserTextFilterChange($event)"
name="user-text-filter"
uicontrol="text"
:full-width="true"
:placeholder="translate('UsersManager_UserSearch')"
/>
</div>
</div>
<div class="input-field col s12 m3 l3">
<div>
<Field
:model-value="accessLevelFilter"
@update:model-value="accessLevelFilter = $event; changeSearch({
filter_access: accessLevelFilter,
offset: 0,
})"
name="access-level-filter"
uicontrol="select"
:options="filterAccessLevels"
:full-width="true"
:placeholder="translate('UsersManager_FilterByAccess')"
/>
</div>
</div>
<div class="input-field col s12 m3 l3">
<div>
<Field
:model-value="statusLevelFilter"
@update:model-value="statusLevelFilter = $event; changeSearch({
filter_status: statusLevelFilter,
offset: 0,
})"
name="status-level-filter"
uicontrol="select"
:options="filterStatusLevels"
:full-width="true"
:placeholder="translate('UsersManager_FilterByStatus')"
/>
</div>
</div>
</div>
<div
class="input-field col s12 m12 l4 users-list-pagination-container"
v-if="totalEntries > searchParams.limit"
>
<div class="usersListPagination">
<a
class="btn prev"
:class="{ disabled: searchParams.offset <= 0 }"
@click.prevent="gotoPreviousPage()"
>
<span class="pointer">&#xAB; {{ translate('General_Previous') }}</span>
</a>
<div class="counter">
<span
:class="{ visibility: isLoadingUsers ? 'hidden' : 'visible' }"
>
{{ translate(
'General_Pagination',
paginationLowerBound,
paginationUpperBound,
totalEntries
) }}
</span>
<ActivityIndicator
:loading="isLoadingUsers"
/>
</div>
<a
class="btn next"
:class="{ disabled: searchParams.offset + searchParams.limit >= totalEntries }"
@click.prevent="gotoNextPage()"
>
<span class="pointer">{{ translate('General_Next') }} &#xBB;</span>
</a>
</div>
</div>
</div>
<div
class="roles-help-notification"
v-if="isRoleHelpToggled"
>
<Notification
context="info"
type="persistent"
:noclear="true"
>
<span v-html="$sanitize(rolesHelpText)"></span>
</Notification>
</div>
<ContentBlock>
<table
id="manageUsersTable"
:class="{ loading: isLoadingUsers }"
v-content-table
>
<thead>
<tr>
<th class="select-cell">
<span class="checkbox-container">
<label>
<input
type="checkbox"
id="paged_users_select_all"
checked="checked"
v-model="isAllCheckboxSelected"
@change="onAllCheckboxChange()"
/>
<span/>
</label>
</span>
</th>
<th class="first">{{ translate('UsersManager_Username') }}</th>
<th class="role_header">
<span style="margin-right: 3.5px">{{ translate('UsersManager_RoleFor') }}</span>
<a
href=""
class="helpIcon"
@click.prevent="isRoleHelpToggled = !isRoleHelpToggled"
:class="{ sticky: isRoleHelpToggled }"
>
<span class="icon-help"/>
</a>
<div>
<Field
class="permissions-for-selector"
:model-value="permissionsForSite"
@update:model-value="onPermissionsForUpdate($event);"
uicontrol="site"
:ui-control-attributes="{
onlySitesWithAdminAccess: currentUserRole !== 'superuser',
}"
/>
</div>
</th>
<th v-if="currentUserRole === 'superuser'">{{ translate('UsersManager_Email') }}</th>
<th
v-if="currentUserRole === 'superuser'"
:title="translate('UsersManager_UsesTwoFactorAuthentication')"
>{{ translate('UsersManager_2FA') }}
</th>
<th v-if="currentUserRole === 'superuser'">{{ translate('UsersManager_LastSeen') }}</th>
<th>{{ translate('UsersManager_Status') }}</th>
<th class="actions-cell-header">
<div>{{ translate('General_Actions') }}</div>
</th>
</tr>
</thead>
<tbody>
<tr
class="select-all-row"
v-if="isAllCheckboxSelected && users.length && users.length < totalEntries"
>
<td colspan="8">
<div v-if="!areAllResultsSelected">
<span
v-html="$sanitize(translate(
'UsersManager_TheDisplayedUsersAreSelected',
`<strong>${users.length}</strong>`,
))"
style="margin-right:3.5px"
></span>
<a
class="toggle-select-all-in-search"
href="#"
@click.prevent="areAllResultsSelected = !areAllResultsSelected"
v-html="$sanitize(translate(
'UsersManager_ClickToSelectAll',
`<strong>${totalEntries}</strong>`,
))"
></a>
</div>
<div v-if="areAllResultsSelected">
<span v-html="$sanitize(translate(
'UsersManager_AllUsersAreSelected',
`<strong>${totalEntries}</strong>`,
))"
style="margin-right:3.5px"
></span>
<a
class="toggle-select-all-in-search"
href="#"
@click.prevent="areAllResultsSelected = !areAllResultsSelected"
v-html="$sanitize(translate(
'UsersManager_ClickToSelectDisplayedUsers',
`<strong>${users.length}</strong>`,
))"
></a>
</div>
</td>
</tr>
<tr
v-for="(user, index) in users"
:id="`row${index}`"
:key="user.login"
>
<td class="select-cell">
<span class="checkbox-container">
<label>
<input
type="checkbox"
:id="`paged_users_select_row${index}`"
v-model="selectedRows[index]"
@click="onRowSelected()"
/>
<span/>
</label>
</span>
</td>
<td id="userLogin">{{ user.login }}</td>
<td class="access-cell">
<div>
<Field
:model-value="user.role"
@update:model-value="
userToChange = user;
roleToChangeTo = $event;
showAccessChangeConfirm();"
:disabled="user.role === 'superuser'"
uicontrol="select"
:options="
user.login === 'anonymous' ? anonymousAccessLevels :
(user.role === 'noaccess' ? onlyRoleAccessLevels : accessLevels)"
/>
</div>
</td>
<td
id="email"
v-if="currentUserRole === 'superuser'"
>{{ user.email }}
</td>
<td
id="twofa"
v-if="currentUserRole === 'superuser'"
>
<span
class="icon-ok"
v-if="user.uses_2fa"
/>
<span
class="icon-close"
v-if="!user.uses_2fa"
/>
</td>
<td
id="last_seen"
v-if="currentUserRole === 'superuser'"
>
{{ user.last_seen ? `${user.last_seen} ago`:'-' }}
</td>
<td id="status">
<span :class="Number.isInteger(user.invite_status)? 'pending':user.invite_status"
:title="user.invite_status === 'expired' ?
translate('UsersManager_ExpiredInviteAutomaticallyRemoved', '3') :
''"
>{{ getInviteStatus(user.invite_status) }}</span>
</td>
<td class="center actions-cell">
<button
class="resend table-action"
title="Resend/Copy Invite Link"
@click="userToChange = user; resendRequestedUser()"
v-if="(
currentUserRole === 'superuser'
|| (currentUserRole === 'admin' && user.invited_by === currentUserLogin)
) && user.invite_status!=='active'"
>
<span class="icon-email"/>
</button>
<button
class="edituser table-action"
title="Edit"
@click="$emit('editUser', { user: user })"
v-if="user.login !== 'anonymous'"
>
<span class="icon-edit"/>
</button>
<button
class="deleteuser table-action"
title="Delete"
@click="userToChange = user; showDeleteConfirm()"
v-if="(
currentUserRole === 'superuser'
|| (currentUserRole === 'admin' && user.invited_by === currentUserLogin)
) && user.login !== 'anonymous'"
>
<span class="icon-delete"/>
</button>
</td>
</tr>
</tbody>
</table>
</ContentBlock>
<PasswordConfirmation
v-model="showPasswordConfirmationForUserRemoval"
@confirmed="deleteRequestedUsers"
@aborted="userToChange = null; roleToChangeTo = null;"
>
<h2
v-if="userToChange"
v-html="$sanitize(translate(
'UsersManager_DeleteUserConfirmSingle',
`<strong>${userToChange.login}</strong>`,
))"
></h2>
<h2
v-if="!userToChange"
v-html="$sanitize(translate(
'UsersManager_DeleteUserConfirmMultiple',
`<strong>${affectedUsersCount}</strong>`,
))"
></h2>
<p>{{ translate('UsersManager_ConfirmWithPassword') }}</p>
</PasswordConfirmation>
<div class="change-user-role-confirm-modal modal" ref="changeUserRoleConfirmModal">
<div class="modal-content">
<h3
v-if="userToChange"
v-html="$sanitize(deleteUserPermConfirmSingleText)"
></h3>
<h3 v-if="userToChange && userToChange.login === 'anonymous' && roleToChangeTo === 'view'">
<em>{{ translate('General_Note') }}:
<span v-html="$sanitize(translate(
'UsersManager_AnonymousUserRoleChangeWarning',
'anonymous',
getRoleDisplay(roleToChangeTo),
))">
</span>
</em>
</h3>
<p
v-if="!userToChange"
v-html="$sanitize(deleteUserPermConfirmMultipleText)"
></p>
</div>
<div class="modal-footer">
<a
href=""
class="modal-action modal-close btn"
@click.prevent="changeUserRole()"
style="margin-right:3.5px"
>{{ translate('General_Yes') }}</a>
<a
href=""
class="modal-action modal-close modal-no"
@click.prevent="
userToChange = null;
roleToChangeTo = null;"
>{{ translate('General_No') }}</a>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import {
DropdownMenu,
ActivityIndicator,
Notification,
ContentBlock,
ContentTable,
debounce,
translate,
SiteRef,
Matomo,
} from 'CoreHome';
import { Field, PasswordConfirmation } from 'CorePluginsAdmin';
import User from '../User';
import SearchParams from './SearchParams';
interface AccessLevel {
key: string;
value: unknown;
type: string
}
interface PagedUsersListState {
areAllResultsSelected: boolean;
selectedRows: Record<string, boolean>;
isAllCheckboxSelected: boolean;
isBulkActionsDisabled: boolean;
userToChange: User | null;
roleToChangeTo: string | null;
accessLevelFilter: string | null;
statusLevelFilter: string | null;
isRoleHelpToggled: boolean;
userTextFilter: string;
permissionsForSite: SiteRef;
showPasswordConfirmationForUserRemoval: boolean;
}
const { $ } = window;
export default defineComponent({
props: {
initialSiteId: {
type: [String, Number],
required: true,
},
initialSiteName: {
type: String,
required: true,
},
currentUserRole: String,
isLoadingUsers: Boolean,
accessLevels: {
type: Array,
required: true,
},
filterAccessLevels: {
type: Array,
required: true,
},
filterStatusLevels: {
type: Array,
required: true,
},
totalEntries: Number,
users: {
type: Array,
required: true,
},
searchParams: {
type: Object,
required: true,
},
},
components: {
Field,
ActivityIndicator,
Notification,
ContentBlock,
PasswordConfirmation,
},
directives: {
DropdownMenu,
ContentTable,
},
data(): PagedUsersListState {
return {
areAllResultsSelected: false,
selectedRows: {},
isAllCheckboxSelected: false,
isBulkActionsDisabled: true,
userToChange: null,
roleToChangeTo: null,
accessLevelFilter: null,
statusLevelFilter: null,
isRoleHelpToggled: false,
userTextFilter: '',
permissionsForSite: {
id: this.initialSiteId,
name: this.initialSiteName,
},
showPasswordConfirmationForUserRemoval: false,
};
},
emits: ['editUser', 'changeUserRole', 'deleteUser', 'searchChange', 'resendInvite'],
created() {
this.onUserTextFilterChange = debounce(this.onUserTextFilterChange, 300);
},
watch: {
users() {
this.clearSelection();
},
},
methods: {
getInviteStatus(inviteStatus: string | number) {
if (Number.isInteger(inviteStatus)) {
return translate('UsersManager_InviteDayLeft', inviteStatus);
}
if (inviteStatus === 'expired') {
return translate('UsersManager_Expired');
}
return translate('UsersManager_Active');
},
onPermissionsForUpdate(site: SiteRef) {
this.permissionsForSite = site;
this.changeSearch({ idSite: this.permissionsForSite.id });
},
clearSelection() {
this.selectedRows = {};
this.areAllResultsSelected = false;
this.isBulkActionsDisabled = true;
this.isAllCheckboxSelected = false;
this.userToChange = null;
},
onAllCheckboxChange() {
if (!this.isAllCheckboxSelected) {
this.clearSelection();
} else {
for (let i = 0; i !== this.users.length; i += 1) {
this.selectedRows[i] = true;
}
this.isBulkActionsDisabled = false;
}
},
changeUserRole() {
this.$emit('changeUserRole', {
users: this.userOperationSubject,
role: this.roleToChangeTo,
});
},
onRowSelected() {
// (angularjs comment): use a timeout since the method is called after the model is updated
setTimeout(() => {
const selectedRowKeyCount = this.selectedCount;
this.isBulkActionsDisabled = selectedRowKeyCount === 0;
this.isAllCheckboxSelected = selectedRowKeyCount === this.users.length;
});
},
deleteRequestedUsers(password: string) {
this.$emit('deleteUser', {
users: this.userOperationSubject,
password,
});
},
resendRequestedUser() {
this.$emit('resendInvite', {
user: this.userToChange,
});
},
showDeleteConfirm() {
this.showPasswordConfirmationForUserRemoval = true;
},
showAccessChangeConfirm() {
$(this.$refs.changeUserRoleConfirmModal as HTMLElement)
.modal({
dismissible: false,
})
.modal('open');
},
getRoleDisplay(role: string | null) {
let result = null;
(this.accessLevels as AccessLevel[]).forEach((entry) => {
if (entry.key === role) {
result = entry.value;
}
});
return result;
},
changeSearch(changes: Partial<SearchParams>) {
const params = { ...this.searchParams, ...changes };
this.$emit('searchChange', { params });
},
gotoPreviousPage() {
this.changeSearch({
offset: Math.max(0, this.searchParams.offset - this.searchParams.limit),
});
},
gotoNextPage() {
const newOffset = this.searchParams.offset + this.searchParams.limit;
if (newOffset >= this.totalEntries!) {
return;
}
this.changeSearch({
offset: newOffset,
});
},
onUserTextFilterChange(filter: string) {
this.userTextFilter = filter;
this.changeSearch({
filter_search: filter,
offset: 0,
});
},
},
computed: {
currentUserLogin() {
return Matomo.userLogin;
},
paginationLowerBound() {
return this.searchParams.offset + 1;
},
paginationUpperBound() {
if (this.totalEntries === null) {
return '?';
}
const searchParams = this.searchParams as SearchParams;
return Math.min(searchParams.offset + searchParams.limit, this.totalEntries!);
},
userOperationSubject() {
if (this.userToChange) {
return [this.userToChange];
}
if (this.areAllResultsSelected) {
return 'all';
}
return this.selectedUsers;
},
selectedUsers() {
const users = this.users as User[];
const result: User[] = [];
Object.keys(this.selectedRows)
.forEach((index) => {
const indexN = parseInt(index, 10);
if (this.selectedRows[index]
&& users[indexN] // sanity check
) {
result.push(users[indexN]);
}
});
return result;
},
rolesHelpText() {
const faq70 = 'https://matomo.org/faq/general/faq_70/';
const faq69 = 'https://matomo.org/faq/general/faq_69/';
return translate(
'UsersManager_RolesHelp',
`<a href="${faq70}" target="_blank" rel="noreferrer noopener">`,
'</a>',
`<a href="${faq69}" target="_blank" rel="noreferrer noopener">`,
'</a>',
);
},
affectedUsersCount() {
if (this.areAllResultsSelected) {
return this.totalEntries || 0;
}
return this.selectedCount;
},
selectedCount() {
let selectedRowKeyCount = 0;
Object.keys(this.selectedRows)
.forEach((key) => {
if (this.selectedRows[key]) {
selectedRowKeyCount += 1;
}
});
return selectedRowKeyCount;
},
deleteUserPermConfirmSingleText() {
return translate(
'UsersManager_DeleteUserPermConfirmSingle',
`<strong>${this.userToChange?.login || ''}</strong>`,
`<strong>${this.getRoleDisplay(this.roleToChangeTo)}</strong>`,
`<strong>${Matomo.helper.htmlEntities(this.permissionsForSite?.name || '')}</strong>`,
);
},
deleteUserPermConfirmMultipleText() {
return translate(
'UsersManager_DeleteUserPermConfirmMultiple',
`<strong>${this.affectedUsersCount}</strong>`,
`<strong>${this.getRoleDisplay(this.roleToChangeTo)}</strong>`,
`<strong>${Matomo.helper.htmlEntities(this.permissionsForSite?.name || '')}</strong>`,
);
},
bulkActionAccessLevels() {
return (this.accessLevels as AccessLevel[]).filter(
(e) => e.key !== 'noaccess' && e.key !== 'superuser',
);
},
anonymousAccessLevels() {
return (this.accessLevels as AccessLevel[]).filter(
(e) => e.key === 'noaccess' || e.key === 'view',
);
},
onlyRoleAccessLevels() {
return (this.accessLevels as AccessLevel[]).filter(
(e) => e.type === 'role',
);
},
},
});
</script>