281 lines
9.5 KiB
Vue
281 lines
9.5 KiB
Vue
<script setup>
|
|
|
|
import { useI18n } from 'vue-i18n';
|
|
const i18n = useI18n();
|
|
const t = i18n.t;
|
|
|
|
import { ref, onMounted, computed, useTemplateRef, inject } from 'vue';
|
|
import { Button, ButtonGroup, Menu, TextInput, SingleSelect, TableView, InputDialog } from '@cloudron/pankow';
|
|
import { ROLES } from '../constants.js';
|
|
import Section from '../components/Section.vue';
|
|
import UserDialog from '../components/UserDialog.vue';
|
|
import ImpersonateDialog from '../components/ImpersonateDialog.vue';
|
|
import InvitationDialog from '../components/InvitationDialog.vue';
|
|
import PasswordResetDialog from '../components/PasswordResetDialog.vue';
|
|
import UsersModel from '../models/UsersModel.js';
|
|
import GroupsModel from '../models/GroupsModel.js';
|
|
import ProfileModel from '../models/ProfileModel.js';
|
|
|
|
const usersModel = UsersModel.create();
|
|
const groupsModel = GroupsModel.create();
|
|
const profileModel = ProfileModel.create();
|
|
|
|
const usersColumns = {
|
|
role: {
|
|
width: '33.5px'
|
|
},
|
|
user: {
|
|
label: t('users.users.user'),
|
|
sort: true
|
|
},
|
|
groups: {
|
|
label: t('users.users.groups'),
|
|
sort: true,
|
|
hideMobile: true,
|
|
},
|
|
actions: {}
|
|
};
|
|
|
|
const actionMenuModel = ref([]);
|
|
const actionMenuElement = useTemplateRef('actionMenuElement');
|
|
function onUserActionMenu(user, event) {
|
|
actionMenuModel.value = [{
|
|
icon: 'fa fa-pencil-alt',
|
|
label: t('main.action.edit'),
|
|
disabled: !canEdit(user),
|
|
action: onEditOrAddUser.bind(null, user),
|
|
}, {
|
|
separator: true,
|
|
}, {
|
|
icon: 'fa-solid fa-paper-plane',
|
|
label: t('users.users.invitationTooltip'),
|
|
visible: !user.inviteAccepted && !isMe(user) && !user.source,
|
|
disabled: !canEdit(user),
|
|
action: onInvitation.bind(null, user),
|
|
}, {
|
|
icon: 'fa-solid fa-key',
|
|
label: t('users.users.resetPasswordTooltip'),
|
|
visible: user.inviteAccepted && !user.source,
|
|
disabled: !canEdit(user),
|
|
action: onPasswordReset.bind(null, user),
|
|
}, {
|
|
icon: 'fa-solid fa-user-secret',
|
|
label: t('users.users.setGhostTooltip'),
|
|
visible: canImpersonate(user),
|
|
action: onImpersonate.bind(null, user),
|
|
}, {
|
|
separator: true,
|
|
}, {
|
|
icon: 'fa-solid fa-trash-alt',
|
|
label: t('main.action.remove'),
|
|
disabled: !canEdit(user) || isMe(user),
|
|
action: onRemoveUser.bind(null, user),
|
|
}];
|
|
|
|
actionMenuElement.value.open(event, event.currentTarget);
|
|
}
|
|
|
|
const profile = ref({});
|
|
const busy = ref(true);
|
|
const filterOptions = ref([
|
|
{ id: 'all', name: 'All Users' },
|
|
{ id: 'active', name: 'Active Users' },
|
|
{ id: 'inactive', name: 'Inactive Users' }
|
|
]);
|
|
const users = ref([]);
|
|
const usersById = ref({});
|
|
const search = ref('');
|
|
const filter = ref(filterOptions.value[0].id);
|
|
const groups = ref([]);
|
|
const groupsById = ref({});
|
|
const roles = ref([]);
|
|
|
|
const features = inject('features');
|
|
const subscriptionRequiredDialog = inject('subscriptionRequiredDialog');
|
|
|
|
const inputDialog = useTemplateRef('inputDialog');
|
|
const userDialog = useTemplateRef('userDialog');
|
|
const impersonateDialog = useTemplateRef('impersonateDialog');
|
|
const passwordResetDialog = useTemplateRef('passwordResetDialog');
|
|
|
|
const filteredUsers = computed(() => {
|
|
return users.value.filter(u => {
|
|
const username = u.username || ''; // username is null if not yet set
|
|
return username.indexOf(search.value) !== -1 || u.email.indexOf(search.value) !== -1 || u.displayName.indexOf(search.value) !== -1;
|
|
}).filter(u => {
|
|
if (filter.value === 'active') {
|
|
return u.active;
|
|
} else if (filter.value === 'inactive') {
|
|
return !u.active;
|
|
} else {
|
|
return true;
|
|
}
|
|
});
|
|
});
|
|
|
|
async function refreshUsers() {
|
|
const [error, result] = await usersModel.list();
|
|
if (error) return console.error(error);
|
|
|
|
usersById.value = {};
|
|
users.value = result;
|
|
result.forEach(user => {
|
|
usersById.value[user.id] = user;
|
|
});
|
|
}
|
|
|
|
async function refreshGroups() {
|
|
const [error, result] = await groupsModel.list();
|
|
if (error) return console.error(error);
|
|
|
|
groupsById.value = {};
|
|
groups.value = result;
|
|
result.forEach(group => {
|
|
groupsById.value[group.id] = group;
|
|
});
|
|
}
|
|
|
|
function canEdit(user) {
|
|
const roleInt1 = roles.value.findIndex(function (role) { return role.id === profile.value.role; });
|
|
const roleInt2 = roles.value.findIndex(function (role) { return role.id === user.role; });
|
|
|
|
return (roleInt1 - roleInt2) >= 0;
|
|
}
|
|
|
|
function canImpersonate(user) {
|
|
// only admins can impersonate
|
|
if (!profile.value.isAtLeastAdmin) return false;
|
|
|
|
// only users with username can be impersonated
|
|
if (!user.username) return false;
|
|
|
|
// normal admins cannot impersonate owners
|
|
if (!profile.value.isAtLeastOwner && [ ROLES.OWNER ].indexOf(user.role) !== -1) return false;
|
|
|
|
return true;
|
|
}
|
|
|
|
function isMe(user) {
|
|
return user.username === profile.value.username;
|
|
}
|
|
|
|
function onImpersonate(user) {
|
|
impersonateDialog.value.open(user);
|
|
}
|
|
|
|
function onPasswordReset(user) {
|
|
passwordResetDialog.value.open(user);
|
|
}
|
|
|
|
function onEditOrAddUser(user = null) {
|
|
if (user || features.value.userMaxCount > users.value.length) userDialog.value.open(user);
|
|
else subscriptionRequiredDialog.value.open();
|
|
}
|
|
|
|
const invitationDialog = useTemplateRef('invitationDialog');
|
|
function onInvitation(user) {
|
|
invitationDialog.value.open(user);
|
|
}
|
|
|
|
async function onRemoveUser(user) {
|
|
const yes = await inputDialog.value.confirm({
|
|
title: t('users.deleteUserDialog.title', { username: (user.username || user.email) }),
|
|
message: t('users.deleteUserDialog.description'),
|
|
confirmStyle: 'danger',
|
|
confirmLabel: t('users.deleteUserDialog.deleteAction'),
|
|
rejectLabel: t('main.dialog.cancel')
|
|
});
|
|
|
|
if (!yes) return;
|
|
|
|
const [error] = await usersModel.remove(user.id);
|
|
if (error) console.error(error);
|
|
|
|
await refreshUsers();
|
|
}
|
|
|
|
onMounted(async () => {
|
|
const [error, result] = await profileModel.get();
|
|
if (error) return console.error(error);
|
|
|
|
profile.value = result;
|
|
|
|
await refreshUsers();
|
|
await refreshGroups();
|
|
|
|
// Order matters for permissions used in canEdit
|
|
roles.value = [
|
|
{ id: 'user', name: t('users.role.user'), disabled: false },
|
|
{ id: 'usermanager', name: t('users.role.usermanager'), disabled: false },
|
|
{ id: 'mailmanager', name: t('users.role.mailmanager'), disabled: false },
|
|
{ id: 'admin', name: t('users.role.admin'), disabled: !profile.value.isAtLeastAdmin },
|
|
{ id: 'owner', name: t('users.role.owner'), disabled: !profile.value.isAtLeastOwner }
|
|
];
|
|
|
|
busy.value = false;
|
|
});
|
|
|
|
</script>
|
|
|
|
<template>
|
|
<div class="content-large">
|
|
<Menu ref="actionMenuElement" :model="actionMenuModel" />
|
|
<InputDialog ref="inputDialog" />
|
|
<UserDialog ref="userDialog" @success="refreshUsers()"/>
|
|
<ImpersonateDialog ref="impersonateDialog" />
|
|
<PasswordResetDialog ref="passwordResetDialog" />
|
|
<InvitationDialog ref="invitationDialog" @refresh-required="refreshUsers()" />
|
|
|
|
<Section :title="$t('main.navbar.users')">
|
|
<template #header-title-extra>
|
|
<span style="font-weight: normal; font-size: 14px">({{ busy ? '-' : filteredUsers.length }})</span>
|
|
</template>
|
|
<template #header-buttons>
|
|
<TextInput v-model="search" placeholder="Search ..." />
|
|
<SingleSelect :options="filterOptions" option-key="id" option-label="name" v-model="filter" />
|
|
<Button @click="onEditOrAddUser()">{{ $t('main.action.add') }}</Button>
|
|
</template>
|
|
|
|
<TableView :columns="usersColumns" :model="filteredUsers" :busy="busy" style="max-height: 400px;" :placeholder="$t(search ? 'users.users.noMatchesPlaceholder' : 'users.users.emptyPlaceholder')">
|
|
<template #role="user">
|
|
<i class="fas fa-crown arrow" v-if="user.active && user.role === 'owner'" v-tooltip="$t('users.users.superadminTooltip')"></i>
|
|
<i class="fa fa-user-tie arrow" v-if="user.active && user.role === 'admin'" v-tooltip="$t('users.users.adminTooltip')"></i>
|
|
<i class="fas fa-users-cog arrow" v-if="user.active && user.role === 'usermanager'" v-tooltip="$t('users.users.usermanagerTooltip')"></i>
|
|
<i class="fas fa-mail-bulk arrow" v-if="user.active && user.role === 'mailmanager'" v-tooltip="$t('users.users.mailmanagerTooltip')"></i>
|
|
<i class="fa fa-ban" v-if="!user.active" v-tooltip="$t('users.users.inactiveTooltip')"></i>
|
|
</template>
|
|
<template #user="user">
|
|
{{ user.displayName }}
|
|
<span class="text-muted" style="margin: 0 6px" v-if="user.username">{{ user.username }}</span>
|
|
<span class="text-muted" style="margin: 0 6px" v-else>{{ user.email }}</span>
|
|
<i v-show="user.source" class="far fa-address-book" v-tooltip="$t('users.users.externalLdapTooltip')"></i>
|
|
</template>
|
|
<template #groups="user">
|
|
<span class="group-label" v-for="groupId in user.groupIds" :key="groupId">
|
|
{{ groupsById[groupId] ? groupsById[groupId].name : groupId }}
|
|
</span>
|
|
</template>
|
|
<template #actions="user">
|
|
<div class="table-actions">
|
|
<ButtonGroup class="table-actions-hover">
|
|
<Button tool secondary :disabled="!canEdit(user)" @click.capture="onEditOrAddUser(user)" :tooltip="$t('main.action.edit')" icon="fa-solid fa-pencil-alt" />
|
|
<Button tool secondary @click.capture="onUserActionMenu(user, $event)" icon="fa-solid fa-ellipsis" />
|
|
</ButtonGroup>
|
|
<div class="table-actions-no-hover">
|
|
<Button tool plain secondary @click.capture="onUserActionMenu(user, $event)" icon="fa-solid fa-ellipsis" />
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</TableView>
|
|
</Section>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
|
|
.group-label {
|
|
padding: 0 5px;
|
|
}
|
|
|
|
</style>
|