2022-12-27 15:37:40 +01:00
|
|
|
import m, { Children } from "mithril"
|
2024-07-26 16:53:31 +02:00
|
|
|
import { NotFoundError } from "../api/common/error/RestError.js"
|
|
|
|
import { size } from "../gui/size.js"
|
|
|
|
import type { GroupInfo, User } from "../api/entities/sys/TypeRefs.js"
|
|
|
|
import { GroupInfoTypeRef, GroupMemberTypeRef } from "../api/entities/sys/TypeRefs.js"
|
2023-06-15 17:08:41 +02:00
|
|
|
import { contains, LazyLoaded, memoized, noOp } from "@tutao/tutanota-utils"
|
2022-12-27 15:37:40 +01:00
|
|
|
import { UserViewer } from "./UserViewer.js"
|
2024-07-26 16:53:31 +02:00
|
|
|
import { FeatureType, GroupType } from "../api/common/TutanotaConstants.js"
|
|
|
|
import { Icon } from "../gui/base/Icon.js"
|
|
|
|
import { Icons } from "../gui/base/icons/Icons.js"
|
|
|
|
import { BootIcons } from "../gui/base/icons/BootIcons.js"
|
2024-01-08 17:14:09 +01:00
|
|
|
|
2024-07-26 16:53:31 +02:00
|
|
|
import { compareGroupInfos } from "../api/common/utils/GroupUtils.js"
|
|
|
|
import { elementIdPart, GENERATED_MAX_ID } from "../api/common/utils/EntityUtils.js"
|
|
|
|
import { ListColumnWrapper } from "../gui/ListColumnWrapper.js"
|
|
|
|
import { assertMainOrNode } from "../api/common/Env.js"
|
|
|
|
import { locator } from "../api/main/CommonLocator.js"
|
2023-05-25 11:07:39 +02:00
|
|
|
import Stream from "mithril/stream"
|
2022-02-28 12:13:28 +01:00
|
|
|
import * as AddUserDialog from "./AddUserDialog.js"
|
2024-07-26 16:53:31 +02:00
|
|
|
import { SelectableRowContainer, SelectableRowSelectedSetter, setVisibility } from "../gui/SelectableRowContainer.js"
|
|
|
|
import { ListModel } from "../misc/ListModel.js"
|
|
|
|
import { List, ListAttrs, MultiselectMode, RenderConfig } from "../gui/base/List.js"
|
|
|
|
import { listSelectionKeyboardShortcuts, VirtualRow } from "../gui/base/ListUtils.js"
|
|
|
|
import ColumnEmptyMessageBox from "../gui/base/ColumnEmptyMessageBox.js"
|
|
|
|
import { theme } from "../gui/theme.js"
|
|
|
|
import { BaseSearchBar, BaseSearchBarAttrs } from "../gui/base/BaseSearchBar.js"
|
|
|
|
import { IconButton } from "../gui/base/IconButton.js"
|
|
|
|
import { attachDropdown } from "../gui/base/Dropdown.js"
|
|
|
|
import { lang } from "../misc/LanguageViewModel.js"
|
|
|
|
import { keyManager } from "../misc/KeyManager.js"
|
|
|
|
import { EntityUpdateData, isUpdateFor, isUpdateForTypeRef } from "../api/common/utils/EntityUpdateUtils.js"
|
|
|
|
import { ListAutoSelectBehavior } from "../misc/DeviceConfig.js"
|
|
|
|
import { UpdatableSettingsViewer } from "./Interfaces.js"
|
2022-01-07 15:58:30 +01:00
|
|
|
|
2017-08-15 13:54:22 +02:00
|
|
|
assertMainOrNode()
|
2022-01-07 15:58:30 +01:00
|
|
|
|
2023-06-15 17:08:41 +02:00
|
|
|
/**
|
|
|
|
* Displays a list with users that are available to manage by the current user.
|
|
|
|
* Global admins see all users.
|
|
|
|
* Local admins see only their assigned users.
|
|
|
|
*/
|
2018-10-25 10:42:40 +02:00
|
|
|
export class UserListView implements UpdatableSettingsViewer {
|
2023-06-15 17:08:41 +02:00
|
|
|
private searchQuery: string = ""
|
|
|
|
private listModel: ListModel<GroupInfo>
|
|
|
|
private readonly renderConfig: RenderConfig<GroupInfo, UserRow> = {
|
|
|
|
createElement: (dom) => {
|
|
|
|
const row = new UserRow((groupInfo) => this.isAdmin(groupInfo))
|
|
|
|
m.render(dom, row.render())
|
|
|
|
return row
|
|
|
|
},
|
|
|
|
itemHeight: size.list_row_height,
|
|
|
|
swipe: null,
|
|
|
|
multiselectionAllowed: MultiselectMode.Disabled,
|
|
|
|
}
|
2022-02-25 17:32:48 +01:00
|
|
|
|
|
|
|
private readonly listId: LazyLoaded<Id>
|
|
|
|
private adminUserGroupInfoIds: Id[] = []
|
2023-06-15 17:08:41 +02:00
|
|
|
private listStateSubscription: Stream<unknown> | null = null
|
2023-09-07 12:09:11 +02:00
|
|
|
private listSelectionSubscription: Stream<unknown> | null = null
|
2022-02-25 17:32:48 +01:00
|
|
|
|
2023-06-15 17:08:41 +02:00
|
|
|
constructor(
|
|
|
|
private readonly updateDetailsViewer: (viewer: UserViewer | null) => unknown,
|
|
|
|
private readonly focusDetailsViewer: () => unknown,
|
|
|
|
private readonly canImportUsers: () => boolean,
|
|
|
|
private readonly onImportUsers: () => unknown,
|
|
|
|
private readonly onExportUsers: () => unknown,
|
|
|
|
) {
|
|
|
|
// doing it after "onSelectionChanged" is initialized
|
|
|
|
this.listModel = this.makeListModel()
|
2022-02-25 17:32:48 +01:00
|
|
|
this.listId = new LazyLoaded(async () => {
|
2023-03-22 15:12:59 +01:00
|
|
|
const customer = await locator.logins.getUserController().loadCustomer()
|
2022-02-25 17:32:48 +01:00
|
|
|
return customer.userGroups
|
2022-01-07 15:58:30 +01:00
|
|
|
})
|
2021-02-26 14:59:25 +01:00
|
|
|
|
2023-06-15 17:08:41 +02:00
|
|
|
this.listModel.loadInitial()
|
2017-08-15 13:54:22 +02:00
|
|
|
|
2023-06-15 17:08:41 +02:00
|
|
|
this.oncreate = this.oncreate.bind(this)
|
2022-02-28 12:13:28 +01:00
|
|
|
this.onremove = this.onremove.bind(this)
|
|
|
|
this.view = this.view.bind(this)
|
|
|
|
}
|
|
|
|
|
2023-06-15 17:08:41 +02:00
|
|
|
private readonly shortcuts = listSelectionKeyboardShortcuts(MultiselectMode.Disabled, () => this.listModel)
|
|
|
|
|
|
|
|
oncreate() {
|
|
|
|
keyManager.registerShortcuts(this.shortcuts)
|
|
|
|
}
|
|
|
|
|
2022-02-28 12:13:28 +01:00
|
|
|
view(): Children {
|
2023-03-21 15:32:40 +01:00
|
|
|
if (locator.logins.isEnabled(FeatureType.WhitelabelChild)) {
|
2022-02-28 12:13:28 +01:00
|
|
|
return null
|
|
|
|
}
|
|
|
|
|
2022-12-27 15:37:40 +01:00
|
|
|
return m(
|
|
|
|
ListColumnWrapper,
|
|
|
|
{
|
|
|
|
headerContent: m(
|
2023-06-15 17:08:41 +02:00
|
|
|
".flex.flex-space-between.center-vertically.plr-l",
|
|
|
|
m(BaseSearchBar, {
|
|
|
|
text: this.searchQuery,
|
|
|
|
onInput: (text) => this.updateQuery(text),
|
|
|
|
busy: false,
|
|
|
|
onKeyDown: (e) => e.stopPropagation(),
|
|
|
|
onClear: () => {
|
|
|
|
this.searchQuery = ""
|
|
|
|
this.listModel.reapplyFilter()
|
|
|
|
},
|
|
|
|
placeholder: lang.get("searchUsers_placeholder"),
|
|
|
|
} satisfies BaseSearchBarAttrs),
|
2023-04-18 13:07:11 +02:00
|
|
|
m(
|
|
|
|
".mr-negative-s",
|
2023-06-15 17:08:41 +02:00
|
|
|
m(IconButton, {
|
|
|
|
title: "addUsers_action",
|
|
|
|
icon: Icons.Add,
|
2023-04-18 13:07:11 +02:00
|
|
|
click: () => this.addButtonClicked(),
|
|
|
|
}),
|
2023-06-15 17:08:41 +02:00
|
|
|
this.renderImportButton(),
|
2023-04-18 13:07:11 +02:00
|
|
|
),
|
2022-02-28 12:13:28 +01:00
|
|
|
),
|
|
|
|
},
|
2023-06-15 17:08:41 +02:00
|
|
|
this.listModel.isEmptyAndDone()
|
|
|
|
? m(ColumnEmptyMessageBox, {
|
|
|
|
color: theme.list_message_bg,
|
|
|
|
icon: BootIcons.Contacts,
|
|
|
|
message: "noEntries_msg",
|
|
|
|
})
|
2023-06-28 17:50:50 +02:00
|
|
|
: m(List, {
|
2023-06-15 17:08:41 +02:00
|
|
|
renderConfig: this.renderConfig,
|
|
|
|
state: this.listModel.state,
|
|
|
|
onLoadMore: () => this.listModel.loadMore(),
|
|
|
|
onRetryLoading: () => this.listModel.retryLoading(),
|
|
|
|
onStopLoading: () => this.listModel.stopLoading(),
|
|
|
|
onSingleSelection: (item: GroupInfo) => {
|
|
|
|
this.listModel.onSingleSelection(item)
|
|
|
|
this.focusDetailsViewer()
|
|
|
|
},
|
2023-07-10 15:18:01 +02:00
|
|
|
onSingleTogglingMultiselection: noOp,
|
|
|
|
onRangeSelectionTowards: noOp,
|
2023-06-28 17:50:50 +02:00
|
|
|
} satisfies ListAttrs<GroupInfo, UserRow>),
|
2022-02-28 12:13:28 +01:00
|
|
|
)
|
|
|
|
}
|
|
|
|
|
2023-06-15 17:08:41 +02:00
|
|
|
private renderImportButton() {
|
|
|
|
if (this.canImportUsers()) {
|
|
|
|
return m(
|
|
|
|
IconButton,
|
|
|
|
attachDropdown({
|
|
|
|
mainButtonAttrs: {
|
|
|
|
title: "more_label",
|
|
|
|
icon: Icons.More,
|
|
|
|
},
|
|
|
|
childAttrs: () => [
|
|
|
|
{
|
|
|
|
label: "importUsers_action",
|
|
|
|
click: () => {
|
|
|
|
this.onImportUsers()
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
label: "exportUsers_action",
|
|
|
|
click: () => {
|
|
|
|
this.onExportUsers()
|
|
|
|
},
|
|
|
|
},
|
|
|
|
],
|
|
|
|
}),
|
|
|
|
)
|
|
|
|
} else {
|
|
|
|
return null
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-02-28 12:13:28 +01:00
|
|
|
onremove() {
|
2023-06-15 17:08:41 +02:00
|
|
|
keyManager.unregisterShortcuts(this.shortcuts)
|
|
|
|
|
|
|
|
this.listStateSubscription?.end(true)
|
2023-09-07 12:09:11 +02:00
|
|
|
this.listSelectionSubscription?.end(true)
|
2022-01-07 15:58:30 +01:00
|
|
|
}
|
2017-08-15 13:54:22 +02:00
|
|
|
|
2022-02-25 17:32:48 +01:00
|
|
|
private async loadAdmins(): Promise<void> {
|
2023-03-21 15:32:40 +01:00
|
|
|
const adminGroupMembership = locator.logins.getUserController().user.memberships.find((gm) => gm.groupType === GroupType.Admin)
|
2022-02-28 12:13:28 +01:00
|
|
|
if (adminGroupMembership == null) {
|
|
|
|
return
|
|
|
|
}
|
2022-01-07 15:58:30 +01:00
|
|
|
const members = await locator.entityClient.loadAll(GroupMemberTypeRef, adminGroupMembership.groupMember[0])
|
2022-12-27 15:37:40 +01:00
|
|
|
this.adminUserGroupInfoIds = members.map((adminGroupMember) => elementIdPart(adminGroupMember.userGroupInfo))
|
2022-01-07 15:58:30 +01:00
|
|
|
}
|
2017-11-30 12:26:46 +01:00
|
|
|
|
2023-06-15 17:08:41 +02:00
|
|
|
private isAdmin(userGroupInfo: GroupInfo): boolean {
|
2022-02-25 17:32:48 +01:00
|
|
|
return contains(this.adminUserGroupInfoIds, userGroupInfo._id[1])
|
2022-01-07 15:58:30 +01:00
|
|
|
}
|
2017-08-15 13:54:22 +02:00
|
|
|
|
2022-02-25 17:32:48 +01:00
|
|
|
private addButtonClicked() {
|
2023-05-25 11:07:39 +02:00
|
|
|
AddUserDialog.show()
|
2022-01-07 15:58:30 +01:00
|
|
|
}
|
2017-08-15 13:54:22 +02:00
|
|
|
|
2023-06-15 17:08:41 +02:00
|
|
|
async entityEventsReceived<T>(updates: ReadonlyArray<EntityUpdateData>): Promise<void> {
|
|
|
|
for (const update of updates) {
|
2022-12-27 15:37:40 +01:00
|
|
|
const { instanceListId, instanceId, operation } = update
|
2017-08-15 13:54:22 +02:00
|
|
|
|
2022-02-25 17:32:48 +01:00
|
|
|
if (isUpdateForTypeRef(GroupInfoTypeRef, update) && this.listId.getSync() === instanceListId) {
|
2024-08-07 08:38:58 +02:00
|
|
|
await this.listModel.entityEventReceived(instanceListId, instanceId, operation)
|
2023-06-15 17:08:41 +02:00
|
|
|
} else if (isUpdateFor(locator.logins.getUserController().user, update)) {
|
|
|
|
await this.loadAdmins()
|
|
|
|
this.listModel.reapplyFilter()
|
2022-01-07 15:58:30 +01:00
|
|
|
}
|
2023-06-15 17:08:41 +02:00
|
|
|
m.redraw()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private readonly localAdminGroups = memoized((user: User) => {
|
|
|
|
return locator.logins
|
|
|
|
.getUserController()
|
|
|
|
.getLocalAdminGroupMemberships()
|
|
|
|
.map((gm) => gm.group)
|
|
|
|
})
|
|
|
|
|
|
|
|
private makeListModel(): ListModel<GroupInfo> {
|
|
|
|
const listModel = new ListModel<GroupInfo>({
|
|
|
|
topId: GENERATED_MAX_ID,
|
|
|
|
sortCompare: compareGroupInfos,
|
|
|
|
fetch: async (startId) => {
|
|
|
|
if (startId !== GENERATED_MAX_ID) {
|
|
|
|
throw new Error("fetch user group infos called for specific start id")
|
|
|
|
}
|
|
|
|
await this.loadAdmins()
|
|
|
|
const listId = await this.listId.getAsync()
|
|
|
|
const allUserGroupInfos = await locator.entityClient.loadAll(GroupInfoTypeRef, listId)
|
|
|
|
|
|
|
|
return { items: allUserGroupInfos, complete: true }
|
|
|
|
},
|
2024-08-07 08:38:58 +02:00
|
|
|
loadSingle: async (_listId: Id, elementId: Id) => {
|
2023-06-15 17:08:41 +02:00
|
|
|
const listId = await this.listId.getAsync()
|
|
|
|
try {
|
|
|
|
return await locator.entityClient.load<GroupInfo>(GroupInfoTypeRef, [listId, elementId])
|
|
|
|
} catch (e) {
|
|
|
|
if (e instanceof NotFoundError) {
|
|
|
|
// we return null if the GroupInfo does not exist
|
|
|
|
return null
|
|
|
|
} else {
|
|
|
|
throw e
|
|
|
|
}
|
|
|
|
}
|
|
|
|
},
|
2024-03-13 14:32:37 +01:00
|
|
|
autoSelectBehavior: () => ListAutoSelectBehavior.OLDER,
|
2023-06-15 17:08:41 +02:00
|
|
|
})
|
|
|
|
|
|
|
|
listModel.setFilter((gi) => this.groupFilter(gi) && this.queryFilter(gi))
|
|
|
|
|
|
|
|
this.listStateSubscription?.end(true)
|
|
|
|
this.listStateSubscription = listModel.stateStream.map((state) => {
|
2023-09-07 12:09:11 +02:00
|
|
|
m.redraw()
|
|
|
|
})
|
|
|
|
|
|
|
|
this.listSelectionSubscription?.end(true)
|
|
|
|
this.listSelectionSubscription = listModel.differentItemsSelected.map((newSelection) => {
|
|
|
|
let detailsViewer: UserViewer | null
|
|
|
|
if (newSelection.size === 0) {
|
|
|
|
detailsViewer = null
|
|
|
|
} else {
|
|
|
|
const item = newSelection.values().next().value
|
|
|
|
detailsViewer = new UserViewer(item, this.isAdmin(item))
|
|
|
|
}
|
|
|
|
this.updateDetailsViewer(detailsViewer)
|
2023-06-15 17:08:41 +02:00
|
|
|
m.redraw()
|
|
|
|
})
|
|
|
|
return listModel
|
|
|
|
}
|
|
|
|
|
|
|
|
private queryFilter(gi: GroupInfo) {
|
2023-06-30 15:01:05 +02:00
|
|
|
const lowercaseSearch = this.searchQuery.toLowerCase()
|
2023-06-15 17:08:41 +02:00
|
|
|
return (
|
2023-06-30 15:01:05 +02:00
|
|
|
gi.name.toLowerCase().includes(lowercaseSearch) ||
|
|
|
|
(!!gi.mailAddress && gi.mailAddress?.toLowerCase().includes(lowercaseSearch)) ||
|
|
|
|
gi.mailAddressAliases.some((mai) => mai.mailAddress.toLowerCase().includes(lowercaseSearch))
|
2023-06-15 17:08:41 +02:00
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
private groupFilter = (gi: GroupInfo) => {
|
|
|
|
if (locator.logins.getUserController().isGlobalAdmin()) {
|
|
|
|
return true
|
|
|
|
} else {
|
|
|
|
return !!gi.localAdmin && this.localAdminGroups(locator.logins.getUserController().user).includes(gi.localAdmin)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private updateQuery(query: string) {
|
|
|
|
this.searchQuery = query
|
|
|
|
this.listModel.reapplyFilter()
|
2022-01-07 15:58:30 +01:00
|
|
|
}
|
2021-12-23 14:03:23 +01:00
|
|
|
}
|
2022-01-07 15:58:30 +01:00
|
|
|
|
2021-11-04 13:35:55 +01:00
|
|
|
export class UserRow implements VirtualRow<GroupInfo> {
|
2023-03-29 16:29:16 +02:00
|
|
|
top: number = 0
|
2022-01-13 11:57:55 +01:00
|
|
|
domElement: HTMLElement | null = null // set from List
|
2023-03-29 16:29:16 +02:00
|
|
|
entity: GroupInfo | null = null
|
|
|
|
private nameDom!: HTMLElement
|
|
|
|
private addressDom!: HTMLElement
|
|
|
|
private adminIconDom!: HTMLElement
|
|
|
|
private deletedIconDom!: HTMLElement
|
2023-04-21 16:58:36 +02:00
|
|
|
private selectionUpdater!: SelectableRowSelectedSetter
|
2017-08-15 13:54:22 +02:00
|
|
|
|
2023-03-29 16:29:16 +02:00
|
|
|
constructor(private readonly isAdmin: (groupInfo: GroupInfo) => boolean) {}
|
2017-08-15 13:54:22 +02:00
|
|
|
|
2022-01-07 15:58:30 +01:00
|
|
|
update(groupInfo: GroupInfo, selected: boolean): void {
|
2023-06-15 17:08:41 +02:00
|
|
|
this.entity = groupInfo
|
2017-08-15 13:54:22 +02:00
|
|
|
|
2023-04-21 16:58:36 +02:00
|
|
|
this.selectionUpdater(selected, false)
|
2021-12-23 14:03:23 +01:00
|
|
|
|
2023-03-29 16:29:16 +02:00
|
|
|
this.nameDom.textContent = groupInfo.name
|
|
|
|
this.addressDom.textContent = groupInfo.mailAddress ? groupInfo.mailAddress : ""
|
2021-12-23 14:03:23 +01:00
|
|
|
|
2023-03-29 16:29:16 +02:00
|
|
|
setVisibility(this.adminIconDom, this.isAdmin(groupInfo))
|
|
|
|
setVisibility(this.deletedIconDom, groupInfo.deleted != null)
|
2022-01-07 15:58:30 +01:00
|
|
|
}
|
2021-12-23 14:03:23 +01:00
|
|
|
|
2022-01-07 15:58:30 +01:00
|
|
|
/**
|
|
|
|
* Only the structure is managed by mithril. We set all contents on our own (see update) in order to avoid the vdom overhead (not negligible on mobiles)
|
|
|
|
*/
|
2023-03-29 16:29:16 +02:00
|
|
|
render(): Children {
|
|
|
|
return m(
|
|
|
|
SelectableRowContainer,
|
|
|
|
{
|
2023-04-21 16:58:36 +02:00
|
|
|
onSelectedChangeRef: (updater) => (this.selectionUpdater = updater),
|
2023-03-29 16:29:16 +02:00
|
|
|
},
|
|
|
|
m(".flex.col.flex-grow", [
|
2023-04-18 15:52:33 +02:00
|
|
|
m(".badge-line-height", [
|
2023-03-29 16:29:16 +02:00
|
|
|
m("", {
|
|
|
|
oncreate: (vnode) => (this.nameDom = vnode.dom as HTMLElement),
|
2022-01-07 15:58:30 +01:00
|
|
|
}),
|
2023-03-29 16:29:16 +02:00
|
|
|
]),
|
2023-04-24 14:44:02 +02:00
|
|
|
m(".flex-space-between.mt-xxs", [
|
2023-03-29 16:29:16 +02:00
|
|
|
m(".smaller", {
|
|
|
|
oncreate: (vnode) => (this.addressDom = vnode.dom as HTMLElement),
|
2022-01-07 15:58:30 +01:00
|
|
|
}),
|
2023-03-29 16:29:16 +02:00
|
|
|
m(".icons.flex", [
|
|
|
|
m(Icon, {
|
|
|
|
icon: BootIcons.Settings,
|
|
|
|
oncreate: (vnode) => (this.adminIconDom = vnode.dom as HTMLElement),
|
|
|
|
class: "svg-list-accent-fg",
|
|
|
|
style: {
|
|
|
|
display: "none",
|
|
|
|
},
|
|
|
|
}),
|
|
|
|
m(Icon, {
|
|
|
|
icon: Icons.Trash,
|
|
|
|
oncreate: (vnode) => (this.deletedIconDom = vnode.dom as HTMLElement),
|
|
|
|
class: "svg-list-accent-fg",
|
|
|
|
style: {
|
|
|
|
display: "none",
|
|
|
|
},
|
|
|
|
}),
|
|
|
|
]),
|
2022-01-07 15:58:30 +01:00
|
|
|
]),
|
|
|
|
]),
|
2023-03-29 16:29:16 +02:00
|
|
|
)
|
2022-01-07 15:58:30 +01:00
|
|
|
}
|
2022-12-27 15:37:40 +01:00
|
|
|
}
|