From fe130b5989ca07f42024d794ae9f0c3f77398a0a Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Mon, 15 Jun 2020 16:56:05 +0200 Subject: [PATCH] Permission editor reworking --- ChangeLog.md | 11 + package.json | 3 +- shared/css/static/modal-permissions.scss | 54 +- ...ate.svg => icon_group_permission_copy.svg} | 0 shared/js/connection/CommandHelper.ts | 5 +- shared/js/events.ts | 14 +- .../js/events/ClientGlobalControlHandler.ts | 11 +- shared/js/main.tsx | 7 +- shared/js/permission/GroupManager.ts | 58 +- shared/js/permission/PermissionManager.ts | 16 +- shared/js/proto.ts | 3 + shared/js/settings.ts | 4 + shared/js/ui/channel.ts | 18 + shared/js/ui/client.ts | 6 +- shared/js/ui/frames/MenuBar.ts | 14 +- shared/js/ui/modal/ModalGroupCreate.scss | 57 + shared/js/ui/modal/ModalGroupCreate.tsx | 349 +++++ .../js/ui/modal/ModalGroupPermissionCopy.scss | 41 + .../js/ui/modal/ModalGroupPermissionCopy.tsx | 223 +++ .../modal/permission/ModalPermissionEdit.ts | 6 +- .../modal/permission/SenselessPermissions.ts | 2 + .../permissionv2/ModalPermissionEditor.scss | 193 +++ .../permissionv2/ModalPermissionEditor.tsx | 1128 +++++++++++++++ .../modal/permissionv2/PermissionEditor.scss | 458 +++++++ .../modal/permissionv2/PermissionEditor.tsx | 1206 +++++++++++++++++ .../js/ui/modal/permissionv2/TabHandler.scss | 312 +++++ .../js/ui/modal/permissionv2/TabHandler.tsx | 1050 ++++++++++++++ shared/js/ui/react-elements/Button.tsx | 3 +- .../js/ui/react-elements/ContextDivider.scss | 36 + .../js/ui/react-elements/ContextDivider.tsx | 158 +++ shared/js/ui/react-elements/Icon.tsx | 11 +- shared/js/ui/react-elements/InputField.scss | 178 ++- shared/js/ui/react-elements/InputField.tsx | 217 ++- shared/js/ui/react-elements/Modal.tsx | 8 +- shared/js/ui/react-elements/Switch.scss | 139 ++ shared/js/ui/react-elements/Switch.tsx | 55 + shared/js/ui/react-elements/i18n/index.tsx | 8 +- shared/js/ui/tree/Client.tsx | 5 +- shared/js/ui/view.tsx | 23 +- 39 files changed, 5946 insertions(+), 144 deletions(-) rename shared/img/{icon_group_duplicate.svg => icon_group_permission_copy.svg} (100%) create mode 100644 shared/js/ui/modal/ModalGroupCreate.scss create mode 100644 shared/js/ui/modal/ModalGroupCreate.tsx create mode 100644 shared/js/ui/modal/ModalGroupPermissionCopy.scss create mode 100644 shared/js/ui/modal/ModalGroupPermissionCopy.tsx create mode 100644 shared/js/ui/modal/permissionv2/ModalPermissionEditor.scss create mode 100644 shared/js/ui/modal/permissionv2/ModalPermissionEditor.tsx create mode 100644 shared/js/ui/modal/permissionv2/PermissionEditor.scss create mode 100644 shared/js/ui/modal/permissionv2/PermissionEditor.tsx create mode 100644 shared/js/ui/modal/permissionv2/TabHandler.scss create mode 100644 shared/js/ui/modal/permissionv2/TabHandler.tsx create mode 100644 shared/js/ui/react-elements/ContextDivider.scss create mode 100644 shared/js/ui/react-elements/ContextDivider.tsx create mode 100644 shared/js/ui/react-elements/Switch.scss create mode 100644 shared/js/ui/react-elements/Switch.tsx diff --git a/ChangeLog.md b/ChangeLog.md index ba638103..ea0c03b0 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -1,4 +1,15 @@ # Changelog: +* **15.06.20** + - Recoded the permission editor with react + - Fixed sever permission editor display bugs + - Recoded the group add editor with react + - Added the ability to duplicate groups and copy their permissions + - The permission editor now uses by default the highest permission value instead of 1 + - Added options to enable/disable a whole permission group + +* **14.06.20** + - Fixed local icon display element not updating when the icon has been loaded + * **13.06.20** - Started to extract all color values and put them into css variables - Fixed a minor issue related to server/channel groups diff --git a/package.json b/package.json index 405dfe83..510ab91f 100644 --- a/package.json +++ b/package.json @@ -70,8 +70,7 @@ "webpack": "^4.42.1", "webpack-bundle-analyzer": "^3.6.1", "webpack-cli": "^3.3.11", - "worker-plugin": "^4.0.2", - "tsd": "latest" + "worker-plugin": "^4.0.2" }, "repository": { "type": "git", diff --git a/shared/css/static/modal-permissions.scss b/shared/css/static/modal-permissions.scss index 43ebf757..7536dc5a 100644 --- a/shared/css/static/modal-permissions.scss +++ b/shared/css/static/modal-permissions.scss @@ -1,47 +1,6 @@ @import "mixin"; @import "properties"; -html:root { - --modal-permissions-header-text: #e1e1e1; - --modal-permissions-header-background: #19191b; - --modal-permissions-header-hover: #4e4e4e; - --modal-permissions-header-selected: #0073d4; - - --modal-permission-right: #303036; - --modal-permission-left: #222226; - - --modal-permissions-entry-hover: #28282c; - --modal-permissions-entry-selected: #111111; - --modal-permissions-current-group: #101012; - - --modal-permissions-buttons-background: #1b1b1b; - --modal-permissions-buttons-hover: #262626; - --modal-permissions-buttons-disabled: hsla(0, 0%, 9%, 1); - - --modal-permissions-seperator: #1e1e1e; /* the seperator for the "enter a unique id" and "client info" part */ - --modal-permissions-container-seperator: #222224; /* the seperator between left and right */ - - --modal-permissions-icon-select: #121213; - --modal-permissions-icon-select-border: #0d0d0d; - --modal-permissions-icon-select-hover: #17171a; - --modal-permissions-icon-select-hover-border: #333333; - - --modal-permissions-table-border: #1e2025; - - --modal-permissions-table-header: #303036; - --modal-permissions-table-entry-odd: #303036; - --modal-permissions-table-entry-even: #25252a; - --modal-permissions-table-entry-hover: #343a47; - - --modal-permissions-table-header-text: #e1e1e1; - --modal-permissions-table-entry-text: #535455; - --modal-permissions-table-entry-active-text: #e1e1e1; - --modal-permissions-table-entry-group-text: #e1e1e1; - - --modal-permissions-table-input: #e1e1e1; - --modal-permissions-table-input-focus: #3f7dbf; -} - .modal-body.modal-permission-editor { padding: 0!important; @@ -69,8 +28,8 @@ html:root { .header { height: 4em; - background-color: var(--modal-permissions-header-text); - color: var(--modal-permissions-header-background); + background-color: var(--modal-permissions-header-background); + color: var(--modal-permissions-header-text); display: flex; flex-direction: row; @@ -116,6 +75,7 @@ html:root { background-color: var(--modal-permission-right); + /* DONE! */ .header { > .entry { position: relative; @@ -736,9 +696,9 @@ html:root { height: 2em; border: none; border-bottom: 1px solid var(--modal-permissions-table-border); - background-color: var(--modal-permissions-table-entry-odd); + background-color: var(--modal-permissions-table-row-odd); - color: var(--modal-permissions-table-entry-text); + color: var(--modal-permissions-table-row-text); @mixin fixed-column($name, $width) { .column-#{$name} { @@ -845,11 +805,11 @@ html:root { .entry { &.even { - background-color: var(--modal-permissions-table-entry-even); + background-color: var(--modal-permissions-table-row-even); } &:hover { - background-color: var(--modal-permissions-table-entry-hover); + background-color: var(--modal-permissions-table-row-hover); } /* We cant use this effect here because the odd/even effect would be a bit crazy then */ //@include transition(background-color $button_hover_animation_time ease-in-out); diff --git a/shared/img/icon_group_duplicate.svg b/shared/img/icon_group_permission_copy.svg similarity index 100% rename from shared/img/icon_group_duplicate.svg rename to shared/img/icon_group_permission_copy.svg diff --git a/shared/js/connection/CommandHelper.ts b/shared/js/connection/CommandHelper.ts index 45571b7d..cecd8519 100644 --- a/shared/js/connection/CommandHelper.ts +++ b/shared/js/connection/CommandHelper.ts @@ -375,12 +375,15 @@ export class CommandHelper extends AbstractCommandHandler { try { const result: ServerGroupClient[] = []; - for(const entry of command.arguments) + for(const entry of command.arguments) { + if(!('cldbid' in entry)) + continue; result.push({ client_database_id: parseInt(entry["cldbid"]), client_nickname: entry["client_nickname"], client_unique_identifier: entry["client_unique_identifier"] }); + } resolve(result); } catch (error) { log.error(LogCategory.NETWORKING, tr("Failed to parse server group client list: %o"), error); diff --git a/shared/js/events.ts b/shared/js/events.ts index 571c23c8..cc0ccf94 100644 --- a/shared/js/events.ts +++ b/shared/js/events.ts @@ -44,9 +44,9 @@ export class Registry { enable_warn_unhandled_events() { this.warn_unhandled_events = true; } disable_warn_unhandled_events() { this.warn_unhandled_events = false; } - on(event: T, handler: (event?: Events[T] & Event) => void); - on(events: (keyof Events)[], handler: (event?: Event) => void); - on(events, handler) { + on(event: T, handler: (event?: Events[T] & Event) => void) : () => void; + on(events: (keyof Events)[], handler: (event?: Event) => void) : () => void; + on(events, handler) : () => void { if(!Array.isArray(events)) events = [events]; @@ -57,12 +57,13 @@ export class Registry { const handlers = this.handler[event] || (this.handler[event] = []); handlers.push(handler); } + return () => this.off(events, handler); } /* one */ - one(event: T, handler: (event?: Events[T] & Event) => void); - one(events: (keyof Events)[], handler: (event?: Event) => void); - one(events, handler) { + one(event: T, handler: (event?: Events[T] & Event) => void) : () => void; + one(events: (keyof Events)[], handler: (event?: Event) => void) : () => void; + one(events, handler) : () => void { if(!Array.isArray(events)) events = [events]; @@ -72,6 +73,7 @@ export class Registry { handler[this.registry_uuid] = { singleshot: true }; handlers.push(handler); } + return () => this.off(events, handler); } off(handler: (event?) => void); diff --git a/shared/js/events/ClientGlobalControlHandler.ts b/shared/js/events/ClientGlobalControlHandler.ts index bd509bec..3374408f 100644 --- a/shared/js/events/ClientGlobalControlHandler.ts +++ b/shared/js/events/ClientGlobalControlHandler.ts @@ -1,21 +1,18 @@ import {Registry} from "tc-shared/events"; import {ClientGlobalControlEvents} from "tc-shared/events/GlobalEvents"; -import {control_bar_instance, ControlBarEvents} from "tc-shared/ui/frames/control-bar"; -import {manager, Sound} from "tc-shared/sound/Sounds"; +import {Sound} from "tc-shared/sound/Sounds"; import {ConnectionHandler} from "tc-shared/ConnectionHandler"; import {server_connections} from "tc-shared/ui/frames/connection_handlers"; import {createErrorModal, createInfoModal, createInputModal} from "tc-shared/ui/elements/Modal"; -import {default_recorder} from "tc-shared/voice/RecorderProfile"; -import {Settings, settings} from "tc-shared/settings"; -import {add_server_to_bookmarks} from "tc-shared/bookmarks"; +import {settings} from "tc-shared/settings"; import {spawnConnectModal} from "tc-shared/ui/modal/ModalConnect"; import PermissionType from "tc-shared/permission/PermissionType"; import {spawnQueryCreate} from "tc-shared/ui/modal/ModalQuery"; import {openBanList} from "tc-shared/ui/modal/ModalBanList"; -import {spawnPermissionEdit} from "tc-shared/ui/modal/permission/ModalPermissionEdit"; import {formatMessage} from "tc-shared/ui/frames/chat"; import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration"; import {spawnSettingsModal} from "tc-shared/ui/modal/ModalSettings"; +import {spawnPermissionEditorModal} from "tc-shared/ui/modal/permissionv2/ModalPermissionEditor"; /* function initialize_sounds(event_registry: Registry) { @@ -114,7 +111,7 @@ export function initialize(event_registry: Registry) } if(connection_handler) - spawnPermissionEdit(connection_handler).open(); + spawnPermissionEditorModal(connection_handler); else createErrorModal(tr("You have to be connected"), tr("You have to be connected!")).open(); break; diff --git a/shared/js/main.tsx b/shared/js/main.tsx index 09f0c04a..d1252a6c 100644 --- a/shared/js/main.tsx +++ b/shared/js/main.tsx @@ -34,6 +34,8 @@ import {spawnFileTransferModal} from "tc-shared/ui/modal/transfer/ModalFileTrans import {MenuEntryType, spawn_context_menu} from "tc-shared/ui/elements/ContextMenu"; import {copy_to_clipboard} from "tc-shared/utils/helpers"; import ContextMenuEvent = JQuery.ContextMenuEvent; +import {spawnPermissionEditorModal} from "tc-shared/ui/modal/permissionv2/ModalPermissionEditor"; +import {spawnGroupCreate} from "tc-shared/ui/modal/ModalGroups"; /* required import for init */ require("./proto").initialize(); @@ -496,7 +498,8 @@ function main() { modal.open(); } */ - + //setTimeout(() => spawnPermissionEditorModal(server_connections.active_connection()), 3000); + //setTimeout(() => spawnGroupCreate(server_connections.active_connection(), "server"), 3000); if(settings.static_global(Settings.KEY_USER_IS_NEW)) { const modal = openModalNewcomer(); @@ -547,7 +550,7 @@ const task_connect_handler: loader.Task = { } }; - if(chandler) { + if(chandler && !settings.static(Settings.KEY_CONNECT_NO_SINGLE_INSTANCE)) { try { await chandler.post_connect_request(connect_data, () => new Promise((resolve, reject) => { spawnYesNo(tr("Another TeaWeb instance is already running"), tra("Another TeaWeb instance is already running.{:br:}Would you like to connect there?"), response => { diff --git a/shared/js/permission/GroupManager.ts b/shared/js/permission/GroupManager.ts index 45a7aa1b..b3e71cf9 100644 --- a/shared/js/permission/GroupManager.ts +++ b/shared/js/permission/GroupManager.ts @@ -44,12 +44,12 @@ export interface GroupManagerEvents { } } +export type GroupProperty = "name" | "icon" | "sort-id" | "save-db" | "name-mode"; export interface GroupEvents { notify_group_deleted: { }, notify_properties_updated: { - updated_properties: {[Key in keyof GroupProperties]: GroupProperties[Key]}; - group_properties: GroupProperties + updated_properties: GroupProperty[]; }, notify_needed_powers_updated: { } @@ -81,25 +81,41 @@ export class Group { this.name = name; } - updateProperties(properties: {key: string, value: string}[]) { - let updates = {}; - for(const { key, value } of properties) { - if(!JSON.map_field_to(this.properties, value, key)) - continue; /* no updates */ + updatePropertiesFromGroupList(data: any) { + const updates: GroupProperty[] = []; - if(key === "iconid") - this.properties.iconid = this.properties.iconid >>> 0; - - updates[key] = this.properties[key]; + if(this.name !== data["name"]) { + this.name = data["name"]; + updates.push("name"); } - if(Object.keys(updates).length === 0) - return; + /* icon */ + let value = parseInt(data["iconid"]) >>> 0; + if(value !== this.properties.iconid) { + this.properties.iconid = value; + updates.push("icon"); + } - this.events.fire("notify_properties_updated", { - group_properties: this.properties, - updated_properties: updates as any - }); + value = parseInt(data["sortid"]); + if(value !== this.properties.sortid) { + this.properties.sortid = value; + updates.push("sort-id"); + } + + let flag = parseInt(data["savedb"]) >= 1; + if(flag !== this.properties.savedb) { + this.properties.savedb = flag; + updates.push("save-db"); + } + + value = parseInt(data["namemode"]); + if(value !== this.properties.namemode) { + this.properties.namemode = value; + updates.push("name-mode"); + } + + if(updates.length > 0) + this.events.fire("notify_properties_updated", { updated_properties: updates }); } } @@ -240,16 +256,10 @@ export class GroupManager extends AbstractCommandHandler { group = deleteGroups.splice(groupIndex, 1)[0]; } - const property_blacklist = [ - "sgid", "cgid", "type", "name", - - "n_member_removep", "n_member_addp", "n_modifyp" - ]; - group.requiredMemberRemovePower = parseInt(groupData["n_member_removep"]); group.requiredMemberAddPower = parseInt(groupData["n_member_addp"]); group.requiredModifyPower = parseInt(groupData["n_modifyp"]); - group.updateProperties(Object.keys(groupData).filter(e => property_blacklist.findIndex(a => a === e) === -1).map(e => { return { key: e, value: groupData[e] } })); + group.updatePropertiesFromGroupList(groupData); group.events.fire("notify_needed_powers_updated"); } diff --git a/shared/js/permission/PermissionManager.ts b/shared/js/permission/PermissionManager.ts index 9a8aa60e..b2dcd501 100644 --- a/shared/js/permission/PermissionManager.ts +++ b/shared/js/permission/PermissionManager.ts @@ -6,6 +6,7 @@ import {ServerCommand} from "tc-shared/connection/ConnectionBase"; import {CommandResult, ErrorID} from "tc-shared/connection/ServerConnectionDeclaration"; import {ConnectionHandler} from "tc-shared/ConnectionHandler"; import {AbstractCommandHandler} from "tc-shared/connection/AbstractCommandHandler"; +import {Registry} from "tc-shared/events"; export class PermissionInfo { name: string; @@ -34,7 +35,7 @@ export class GroupedPermissions { export class PermissionValue { readonly type: PermissionInfo; - value: number; + value: number | undefined; /* undefined if no permission is given */ flag_skip: boolean; flag_negate: boolean; granted_value: number; @@ -61,9 +62,14 @@ export class PermissionValue { hasValue() : boolean { return typeof(this.value) !== "undefined" && this.value != -2; } + hasGrant() : boolean { return typeof(this.granted_value) !== "undefined" && this.granted_value != -2; } + + valueOr(fallback: number) { + return this.hasValue() ? this.value : fallback; + } } export class NeededPermissionValue extends PermissionValue { @@ -126,6 +132,10 @@ export namespace find { } } +export interface PermissionManagerEvents { + client_permissions_changed: {} +} + export type RequestLists = "requests_channel_permissions" | "requests_client_permissions" | @@ -134,6 +144,8 @@ export type RequestLists = "requests_playlist_client_permissions"; export class PermissionManager extends AbstractCommandHandler { + readonly events = new Registry(); + readonly handle: ConnectionHandler; permissionList: PermissionInfo[] = []; @@ -397,6 +409,8 @@ export class PermissionManager extends AbstractCommandHandler { for(const listener of this.needed_permission_change_listener[e.type.name] || []) listener(); } + + this.events.fire("client_permissions_changed"); } register_needed_permission(key: PermissionType, listener: () => any) { diff --git a/shared/js/proto.ts b/shared/js/proto.ts index c59f34af..ae4ff3ab 100644 --- a/shared/js/proto.ts +++ b/shared/js/proto.ts @@ -289,6 +289,9 @@ if (!String.prototype.format) { }; } +if(!Object.values) + Object.values = object => Object.keys(object).map(e => object[e]); + function concatenate(resultConstructor, ...arrays) { let totalLength = 0; for (const arr of arrays) { diff --git a/shared/js/settings.ts b/shared/js/settings.ts index 080009e9..dff34e14 100644 --- a/shared/js/settings.ts +++ b/shared/js/settings.ts @@ -238,6 +238,10 @@ export class Settings extends StaticSettings { static readonly KEY_CONNECT_HISTORY: SettingsKey = { key: 'connect_history' }; + static readonly KEY_CONNECT_NO_SINGLE_INSTANCE: SettingsKey = { + key: 'connect_no_single_instance', + default_value: false + }; static readonly KEY_CONNECT_NO_DNSPROXY: SettingsKey = { key: 'connect_no_dnsproxy', diff --git a/shared/js/ui/channel.ts b/shared/js/ui/channel.ts index 2b78df48..bc8a825b 100644 --- a/shared/js/ui/channel.ts +++ b/shared/js/ui/channel.ts @@ -201,6 +201,14 @@ export class ChannelEntry extends ChannelTreeEntry { if(typeof event.updated_properties.client_nickname !== "undefined" || typeof event.updated_properties.client_talk_power !== "undefined") this.reorderClientList(true); }; + + this.events.on("notify_properties_updated", event => { + this.channelTree?.events.fire("notify_channel_updated", { + channel: this, + channelProperties: event.channel_properties, + updatedProperties: event.updated_properties + }); + }); } destroy() { @@ -223,6 +231,16 @@ export class ChannelEntry extends ChannelTreeEntry { return this.properties.channel_name; } + channelDepth() { + let depth = 0; + let parent = this.parent; + while(parent) { + depth++; + parent = parent.parent; + } + return depth; + } + formattedChannelName() { return this.parsed_channel_name.text; } diff --git a/shared/js/ui/client.ts b/shared/js/ui/client.ts index aaf7da26..1b46112d 100644 --- a/shared/js/ui/client.ts +++ b/shared/js/ui/client.ts @@ -15,7 +15,6 @@ import {ChannelEntry} from "tc-shared/ui/channel"; import {ConnectionHandler, ViewReasonId} from "tc-shared/ConnectionHandler"; import {voice} from "tc-shared/connection/ConnectionBase"; import VoiceClient = voice.VoiceClient; -import {spawnPermissionEdit} from "tc-shared/ui/modal/permission/ModalPermissionEdit"; import {createServerGroupAssignmentModal} from "tc-shared/ui/modal/ModalGroupAssignment"; import {openClientInfo} from "tc-shared/ui/modal/ModalClientInfo"; import {spawnBanClient} from "tc-shared/ui/modal/ModalBanClient"; @@ -28,6 +27,7 @@ import { ClientEntry as ClientEntryView } from "./tree/Client"; import * as React from "react"; import {ChannelTreeEntry, ChannelTreeEntryEvents} from "tc-shared/ui/TreeEntry"; import {spawnClientVolumeChange, spawnMusicBotVolumeChange} from "tc-shared/ui/modal/ModalChangeVolumeNew"; +import {spawnPermissionEditorModal} from "tc-shared/ui/modal/permissionv2/ModalPermissionEditor"; export enum ClientType { CLIENT_VOICE, @@ -455,13 +455,13 @@ export class ClientEntry extends ChannelTreeEntry { type: contextmenu.MenuEntryType.ENTRY, icon_class: "client-permission_client", name: tr("Client permissions"), - callback: () => spawnPermissionEdit(this.channelTree.client, "clp", {unique_id: this.clientUid()}).open() + callback: () => spawnPermissionEditorModal(this.channelTree.client, "client", { clientDatabaseId: this.properties.client_database_id }) }, { type: contextmenu.MenuEntryType.ENTRY, icon_class: "client-permission_client", name: tr("Client channel permissions"), - callback: () => spawnPermissionEdit(this.channelTree.client, "clchp", {unique_id: this.clientUid(), channel_id: this._channel ? this._channel.channelId : undefined }).open() + callback: () => spawnPermissionEditorModal(this.channelTree.client, "client-channel", { clientDatabaseId: this.properties.client_database_id }) } ] }]; diff --git a/shared/js/ui/frames/MenuBar.ts b/shared/js/ui/frames/MenuBar.ts index 85166219..18e43d4b 100644 --- a/shared/js/ui/frames/MenuBar.ts +++ b/shared/js/ui/frames/MenuBar.ts @@ -7,10 +7,9 @@ import { boorkmak_connect, DirectoryBookmark } from "tc-shared/bookmarks"; -import {ConnectionHandler, DisconnectReason} from "tc-shared/ConnectionHandler"; +import {ConnectionHandler} from "tc-shared/ConnectionHandler"; import {Sound} from "tc-shared/sound/Sounds"; import {spawnConnectModal} from "tc-shared/ui/modal/ModalConnect"; -import {spawnPermissionEdit} from "tc-shared/ui/modal/permission/ModalPermissionEdit"; import {createErrorModal, createInfoModal, createInputModal} from "tc-shared/ui/elements/Modal"; import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration"; import {PermissionType} from "tc-shared/permission/PermissionType"; @@ -24,6 +23,7 @@ import * as loader from "tc-loader"; import {formatMessage} from "tc-shared/ui/frames/chat"; import {control_bar_instance} from "tc-shared/ui/frames/control-bar"; import {icon_cache_loader, IconManager, LocalIcon} from "tc-shared/file/Icons"; +import {spawnPermissionEditorModal} from "tc-shared/ui/modal/permissionv2/ModalPermissionEditor"; export interface HRItem { } @@ -390,35 +390,35 @@ export function initialize() { item = menu.append_item(tr("Server Groups")); item.icon("client-permission_server_groups"); item.click(() => { - spawnPermissionEdit(server_connections.active_connection(), "sg").open(); + spawnPermissionEditorModal(server_connections.active_connection(), "groups-server"); }); _state_updater["permission.sg"] = { item: item, conditions: [condition_connected]}; item = menu.append_item(tr("Client Permissions")); item.icon("client-permission_client"); item.click(() => { - spawnPermissionEdit(server_connections.active_connection(), "clp").open(); + spawnPermissionEditorModal(server_connections.active_connection(), "client"); }); _state_updater["permission.clp"] = { item: item, conditions: [condition_connected]}; item = menu.append_item(tr("Channel Client Permissions")); item.icon("client-permission_client"); item.click(() => { - spawnPermissionEdit(server_connections.active_connection(), "clchp").open(); + spawnPermissionEditorModal(server_connections.active_connection(), "client-channel"); }); _state_updater["permission.chclp"] = { item: item, conditions: [condition_connected]}; item = menu.append_item(tr("Channel Groups")); item.icon("client-permission_channel"); item.click(() => { - spawnPermissionEdit(server_connections.active_connection(), "cg").open(); + spawnPermissionEditorModal(server_connections.active_connection(), "groups-channel"); }); _state_updater["permission.cg"] = { item: item, conditions: [condition_connected]}; item = menu.append_item(tr("Channel Permissions")); item.icon("client-permission_channel"); item.click(() => { - spawnPermissionEdit(server_connections.active_connection(), "chp").open(); + spawnPermissionEditorModal(server_connections.active_connection(), "channel"); }); _state_updater["permission.cp"] = { item: item, conditions: [condition_connected]}; diff --git a/shared/js/ui/modal/ModalGroupCreate.scss b/shared/js/ui/modal/ModalGroupCreate.scss new file mode 100644 index 00000000..e2070bd7 --- /dev/null +++ b/shared/js/ui/modal/ModalGroupCreate.scss @@ -0,0 +1,57 @@ +.container { + display: flex; + flex-direction: column; + justify-content: flex-start; + + padding: 0 1em 1em; + + width: 35em; + min-width: 10em; + max-width: 100%; + + .row { + display: flex; + flex-direction: row; + justify-content: stretch; + + } + + select { + .hiddenOption { + display: none; + } + } + + .groupType, .groupSource { + flex-grow: 1; + flex-shrink: 1; + min-width: 6em; + } + + .groupType { + margin-right: 1em; + + flex-grow: 0; + width: 12em; + } + .groupSource {} + + .buttons { + display: flex; + flex-direction: row; + justify-content: space-between; + } +} + +@media all and (max-width: 30em){ + .container { + .row { + flex-direction: column; + justify-content: flex-start; + } + + .groupType, .groupSource { + width: 100%; + } + } +} \ No newline at end of file diff --git a/shared/js/ui/modal/ModalGroupCreate.tsx b/shared/js/ui/modal/ModalGroupCreate.tsx new file mode 100644 index 00000000..15aa1a10 --- /dev/null +++ b/shared/js/ui/modal/ModalGroupCreate.tsx @@ -0,0 +1,349 @@ +import {Modal, spawnReactModal} from "tc-shared/ui/react-elements/Modal"; +import {ConnectionHandler} from "tc-shared/ConnectionHandler"; +import {Registry} from "tc-shared/events"; +import {FlatInputField, FlatSelect} from "tc-shared/ui/react-elements/InputField"; +import * as React from "react"; +import {useEffect, useRef, useState} from "react"; +import {GroupType} from "tc-shared/permission/GroupManager"; +import {Translatable} from "tc-shared/ui/react-elements/i18n"; +import {Button} from "tc-shared/ui/react-elements/Button"; +import PermissionType from "tc-shared/permission/PermissionType"; +import {CommandResult, ErrorID} from "tc-shared/connection/ServerConnectionDeclaration"; +import {createErrorModal, createInfoModal} from "tc-shared/ui/elements/Modal"; +import {tra} from "tc-shared/i18n/localize"; + +const cssStyle = require("./ModalGroupCreate.scss"); + +export type GroupInfo = { + id: number, + name: string, + type: "query" | "template" | "normal" +}; + +export interface GroupCreateModalEvents { + action_set_name: { name: string | undefined }, + action_set_type: { target: "query" | "template" | "normal" }, + action_set_source: { group: number }, + + action_cancel: {}, + action_create: { + name: string, + target: "query" | "template" | "normal", + source: number /* if zero than no template */ + } + + query_available_groups: { }, + query_available_groups_result: { + groups: GroupInfo[] + }, + + query_client_permissions: {}, + notify_client_permissions: { + createTemplateGroup: boolean, + createQueryGroup: boolean + }, + + notify_destroy: {} +} + +const GroupNameInput = (props: { events: Registry, defaultSource: number }) => { + const [ initialLoad, setInitialLoad ] = useState(true); + const [ existingGroups, setExistingGroups ] = useState<"loading" | GroupInfo[]>("loading"); + const [ selectedType, setSelectedType ] = useState<"query" | "template" | "normal" | "loading">("loading"); + + const refInput = useRef(); + + useEffect(() => { + if(!initialLoad || !refInput.current) + return; + + if(selectedType === "loading" || existingGroups === "loading") + return; + + setInitialLoad(false); + refInput.current.focus(); + + const defaultGroup = existingGroups.find(e => e.id === props.defaultSource); + if(defaultGroup) { + let name = defaultGroup.name + " (" + tr("Copy") + ")"; + let index = 1; + while(existingGroups.findIndex(e => e.name === name) !== -1) + name = defaultGroup.name + " (" + tr("Copy")+ " " + index++ + ")"; + + refInput.current.setValue(name); + props.events.fire("action_set_name", { name: updateGroupNameState(name) ? name : undefined }); + } + }); + + const updateGroupNameState = (input: string) => { + if(!refInput.current) + return false; + + if(input.length === 0 || input.length > 30) { + refInput.current.setState({ isInvalid: true, invalidMessage: tr("Invalid group name length") }); + return false; + } + + if(existingGroups === "loading") + return false; + + if(existingGroups.findIndex(e => e.name === input && e.type === selectedType) !== -1) { + refInput.current.setState({ isInvalid: true, invalidMessage: tr("A group with this name already exists") }); + return false; + } + + refInput.current.setState({ isInvalid: false }); + return true; + }; + + props.events.reactUse("query_available_groups_result", event => setExistingGroups(event.groups)); + props.events.reactUse("action_set_type", event => setSelectedType(event.target)); + + return ( + Group name} + finishOnEnter={true} + + disabled={existingGroups === "loading" || selectedType === "loading"} + placeholder={existingGroups === "loading" || selectedType === "loading" ? tr("loading data...") : undefined} + onInput={() => props.events.fire("action_set_name", { name: updateGroupNameState(refInput.current.value()) ? refInput.current.value() : undefined })} + onBlur={() => props.events.fire("action_set_name", { name: updateGroupNameState(refInput.current.value()) ? refInput.current.value() : undefined })} + /> + ) +}; + +const GroupTypeSelector = (props: { events: Registry }) => { + const [ selectedType, setSelectedType ] = useState<"query" | "template" | "normal" | "loading">("loading"); + const [ permissions, setPermissions ] = useState<"loading" | { createTemplate, createQuery }>("loading"); + const refSelect = useRef(); + + props.events.reactUse("notify_client_permissions", event => { + setPermissions({ + createQuery: event.createQueryGroup, + createTemplate: event.createTemplateGroup + }); + + /* the default type */ + props.events.fire("action_set_type", { target: "normal" }); + }); + + props.events.reactUse("action_set_type", event => setSelectedType(event.target)); + + return ( + Target group type} + className={cssStyle.groupType} + disabled={permissions === "loading"} + value={selectedType} + onChange={event => event.target.value !== "loading" && props.events.fire("action_set_type", { target: event.target.value as any })} + > + + + + + + ) +}; + +const SourceGroupSelector = (props: { events: Registry, defaultSource: number }) => { + const [ selectedGroup, setSelectedGroup ] = useState(undefined); + const [ permissions, setPermissions ] = useState<"loading" | { createTemplate, createQuery }>("loading"); + const [ exitingGroups, setExitingGroups ] = useState<"loading" | GroupInfo[]>("loading"); + + const refSelect = useRef(); + + props.events.reactUse("notify_client_permissions", event => setPermissions({ + createQuery: event.createQueryGroup, + createTemplate: event.createTemplateGroup + })); + props.events.reactUse("query_available_groups_result", event => setExitingGroups(event.groups)); + props.events.reactUse("action_set_source", event => setSelectedGroup(event.group)); + + const groupName = (group: GroupInfo) => { + let prefix = group.type === "template" ? "[T] " : group.type === "query" ? "[Q] " : ""; + return prefix + group.name + " (" + group.id + ")"; + }; + + const isLoading = exitingGroups === "loading" || permissions === "loading"; + if(!isLoading && selectedGroup === undefined) + props.events.fire_async("action_set_source", { + group: (exitingGroups as GroupInfo[]).findIndex(e => e.id === props.defaultSource) === -1 ? 0 : props.defaultSource + }); + + return ( + Create group using this template} + className={cssStyle.groupSource} + disabled={isLoading} + value={isLoading || selectedGroup === undefined ? "-1" : selectedGroup.toString()} + onChange={event => props.events.fire("action_set_source", { group: parseInt(event.target.value) })} + > + + + + {exitingGroups === "loading" ? undefined : + exitingGroups.filter(e => e.type === "query").map(e => ( + + )) + } + + + {exitingGroups === "loading" ? undefined : + exitingGroups.filter(e => e.type === "template").map(e => ( + + )) + } + + + {exitingGroups === "loading" ? undefined : + exitingGroups.filter(e => e.type === "normal").map(e => ( + + )) + } + + + ) +}; + +const CreateButton = (props: { events: Registry }) => { + const [ sourceGroup, setSourceGroup ] = useState(undefined); + const [ groupType, setGroupType ] = useState<"query" | "template" | "normal" | undefined>(undefined); + const [ groupName, setGroupName ] = useState(undefined); + + props.events.reactUse("action_set_name", event => setGroupName(event.name)); + props.events.reactUse("action_set_type", event => setGroupType(event.target)); + props.events.reactUse("action_set_source", event => setSourceGroup(event.group)); + + return +}; + +class ModalGroupCreate extends Modal { + readonly target: "server" | "channel"; + readonly events = new Registry(); + readonly defaultSourceGroup: number; + + constructor(connection: ConnectionHandler, target: "server" | "channel", defaultSourceGroup: number) { + super(); + + this.events.enable_debug("group-create"); + this.defaultSourceGroup = defaultSourceGroup; + this.target = target; + initializeGroupCreateController(connection, this.events, this.target); + } + + protected onInitialize() { + this.modalController().events.on("destroy", () => this.events.fire("notify_destroy")); + + this.events.fire_async("query_available_groups"); + this.events.fire_async("query_client_permissions"); + + this.events.on(["action_cancel", "action_create"], () => this.modalController().destroy()); + } + + renderBody() { + return
+ +
+ + +
+
+ + +
+
; + } + + title(): string { + return this.target === "server" ? tr("Create a new server group") : tr("Create a new channel group"); + } + +} + +export function spawnGroupCreate(connection: ConnectionHandler, target: "server" | "channel", sourceGroup: number = 0) { + const modal = spawnReactModal(ModalGroupCreate, connection, target, sourceGroup); + modal.show(); +} + +const stringifyError = error => { + if(error instanceof CommandResult) { + if(error.id === ErrorID.PERMISSION_ERROR) + return tr("insufficient permissions"); + else + return error.message + (error.extra_message ? " (" + error.extra_message + ")" : ""); + } else if(error instanceof Error) { + return error.message; + } else if(typeof error !== "string") { + return tr("Lookup the console"); + } + return error; +}; + +function initializeGroupCreateController(connection: ConnectionHandler, events: Registry, target: "server" | "channel") { + events.on("query_available_groups", event => { + const groups = target === "server" ? connection.groups.serverGroups : connection.groups.channelGroups; + + events.fire_async("query_available_groups_result", { + groups: groups.map(e => { + return { + name: e.name, + id: e.id, + type: e.type === GroupType.TEMPLATE ? "template" : e.type === GroupType.QUERY ? "query" : "normal" + } + }) + }); + }); + + const notifyClientPermissions = () => events.fire_async("notify_client_permissions", { + createQueryGroup: connection.permissions.neededPermission(PermissionType.B_SERVERINSTANCE_MODIFY_QUERYGROUP).granted(1), + createTemplateGroup: connection.permissions.neededPermission(PermissionType.B_SERVERINSTANCE_MODIFY_TEMPLATES).granted(1) + }); + events.on("query_client_permissions", notifyClientPermissions); + events.on("notify_destroy", connection.permissions.events.on("client_permissions_changed", notifyClientPermissions)); + + events.on("action_create", event => { + let promise: Promise; + if(event.source <= 0) { + /* real group create */ + promise = connection.serverConnection.send_command("servergroupadd", { + name: event.name, + type: event.target === "query" ? 2 : event.target === "template" ? 0 : 1 + }); + } else { + /* group copy */ + promise = connection.serverConnection.send_command("servergroupcopy", { + ssgid: event.source, + name: event.name, + type: event.target === "query" ? 2 : event.target === "template" ? 0 : 1 + }); + } + promise.then(() => { + createInfoModal(tr("Group has been created"), tr("The group has been successfully created.")).open(); + }).catch(error => { + if(error instanceof CommandResult && error.id === ErrorID.PERMISSION_ERROR) { + createErrorModal(tr("Failed to create group"), + tra("Failed to create group.\nMissing permission {}", connection.permissions.resolveInfo(parseInt(error.json["failed_permid"]))?.name || tr("unknwon"))).open(); + return; + } + + console.warn(tr("Failed to create group: %o"), error); + createErrorModal(tr("Failed to create group"), + tra("Failed to create group.\n{}", stringifyError(error))).open(); + }); + }); +} \ No newline at end of file diff --git a/shared/js/ui/modal/ModalGroupPermissionCopy.scss b/shared/js/ui/modal/ModalGroupPermissionCopy.scss new file mode 100644 index 00000000..5571750e --- /dev/null +++ b/shared/js/ui/modal/ModalGroupPermissionCopy.scss @@ -0,0 +1,41 @@ +.container { + display: flex; + flex-direction: column; + justify-content: flex-start; + + padding: 0 1em 1em; + + width: 40em; + min-width: 10em; + max-width: 100%; + + .row { + display: flex; + flex-direction: row; + justify-content: stretch; + } + + select { + .hiddenOption { + display: none; + } + } + + .sourceGroup, .targetGroup { + flex-grow: 1; + flex-shrink: 1; + + min-width: 5em; + width: 10em; + } + + .sourceGroup { + margin-right: 1em; + } + + .buttons { + display: flex; + flex-direction: row; + justify-content: space-between; + } +} \ No newline at end of file diff --git a/shared/js/ui/modal/ModalGroupPermissionCopy.tsx b/shared/js/ui/modal/ModalGroupPermissionCopy.tsx new file mode 100644 index 00000000..f6e23f59 --- /dev/null +++ b/shared/js/ui/modal/ModalGroupPermissionCopy.tsx @@ -0,0 +1,223 @@ +import {Modal, spawnReactModal} from "tc-shared/ui/react-elements/Modal"; +import {ConnectionHandler} from "tc-shared/ConnectionHandler"; +import {Registry} from "tc-shared/events"; +import {useRef, useState} from "react"; +import {FlatSelect} from "tc-shared/ui/react-elements/InputField"; +import {Translatable} from "tc-shared/ui/react-elements/i18n"; +import * as React from "react"; +import {Button} from "tc-shared/ui/react-elements/Button"; +import {GroupType} from "tc-shared/permission/GroupManager"; +import PermissionType from "tc-shared/permission/PermissionType"; +import {CommandResult, ErrorID} from "tc-shared/connection/ServerConnectionDeclaration"; +import {createErrorModal, createInfoModal} from "tc-shared/ui/elements/Modal"; +import {tra} from "tc-shared/i18n/localize"; + +const cssStyle = require("./ModalGroupPermissionCopy.scss"); + +export type GroupInfo = { + id: number, + name: string, + type: "query" | "template" | "normal" +}; + +export interface GroupPermissionCopyModalEvents { + action_set_source: { group: number }, + action_set_target: { group: number } + + action_cancel: {}, + action_copy: { + source: number; + target: number; + } + + query_available_groups: { }, + query_available_groups_result: { + groups: GroupInfo[] + }, + + query_client_permissions: {}, + notify_client_permissions: { + createTemplateGroup: boolean, + createQueryGroup: boolean + }, + + notify_destroy: {} +} + +const GroupSelector = (props: { events: Registry, defaultGroup: number, updateEvent: "action_set_source" | "action_set_target", label: string, className: string}) => { + const [ selectedGroup, setSelectedGroup ] = useState(undefined); + const [ permissions, setPermissions ] = useState<"loading" | { createTemplate, createQuery }>("loading"); + const [ exitingGroups, setExitingGroups ] = useState<"loading" | GroupInfo[]>("loading"); + + const refSelect = useRef(); + + props.events.reactUse("notify_client_permissions", event => setPermissions({ + createQuery: event.createQueryGroup, + createTemplate: event.createTemplateGroup + })); + props.events.reactUse("query_available_groups_result", event => setExitingGroups(event.groups)); + props.events.reactUse(props.updateEvent, event => setSelectedGroup(event.group)); + + const groupName = (group: GroupInfo) => { + let prefix = group.type === "template" ? "[T] " : group.type === "query" ? "[Q] " : ""; + return prefix + group.name + " (" + group.id + ")"; + }; + + const isLoading = exitingGroups === "loading" || permissions === "loading"; + if(!isLoading && selectedGroup === undefined) + props.events.fire_async(props.updateEvent, { + group: (exitingGroups as GroupInfo[]).findIndex(e => e.id === props.defaultGroup) === -1 ? 0 : props.defaultGroup + }); + + return ( + {props.label}} + className={props.className} + disabled={isLoading} + value={isLoading || selectedGroup === undefined ? "-1" : selectedGroup.toString()} + onChange={event => props.events.fire(props.updateEvent, { group: parseInt(event.target.value) })} + > + + + + {exitingGroups === "loading" ? undefined : + exitingGroups.filter(e => e.type === "query").map(e => ( + + )) + } + + + {exitingGroups === "loading" ? undefined : + exitingGroups.filter(e => e.type === "template").map(e => ( + + )) + } + + + {exitingGroups === "loading" ? undefined : + exitingGroups.filter(e => e.type === "normal").map(e => ( + + )) + } + + + ) +}; + +const CopyButton = (props: { events: Registry }) => { + const [ sourceGroup, setSourceGroup ] = useState(0); + const [ targetGroup, setTargetGroup ] = useState(0); + + props.events.reactUse("action_set_source", event => setSourceGroup(event.group)); + props.events.reactUse("action_set_target", event => setTargetGroup(event.group)); + + return +}; + +class ModalGroupPermissionCopy extends Modal { + readonly events = new Registry(); + + readonly defaultSource: number; + readonly defaultTarget: number; + + constructor(connection: ConnectionHandler, target: "server" | "channel", sourceGroup?: number, targetGroup?: number) { + super(); + + this.defaultSource = sourceGroup; + this.defaultTarget = targetGroup; + + initializeGroupPermissionCopyController(connection, this.events, target); + } + + protected onInitialize() { + this.modalController().events.on("destroy", () => this.events.fire("notify_destroy")); + + this.events.fire_async("query_available_groups"); + this.events.fire_async("query_client_permissions"); + + this.events.on(["action_cancel", "action_copy"], () => this.modalController().destroy()); + } + + renderBody() { + return
+
+ + +
+
+ + +
+
; + } + + title(): string { + return tr("Copy group permissions"); + } +} + +export function spawnModalGroupPermissionCopy(connection: ConnectionHandler, target: "channel" | "server", sourceGroup?: number, targetGroup?: number) { + const modal = spawnReactModal(ModalGroupPermissionCopy, connection, target, sourceGroup, targetGroup); + modal.show(); +} + +const stringifyError = error => { + if(error instanceof CommandResult) { + if(error.id === ErrorID.PERMISSION_ERROR) + return tr("insufficient permissions"); + else + return error.message + (error.extra_message ? " (" + error.extra_message + ")" : ""); + } else if(error instanceof Error) { + return error.message; + } else if(typeof error !== "string") { + return tr("Lookup the console"); + } + return error; +}; + +function initializeGroupPermissionCopyController(connection: ConnectionHandler, events: Registry, target: "server" | "channel") { + events.on("query_available_groups", event => { + const groups = target === "server" ? connection.groups.serverGroups : connection.groups.channelGroups; + + events.fire_async("query_available_groups_result", { + groups: groups.map(e => { + return { + name: e.name, + id: e.id, + type: e.type === GroupType.TEMPLATE ? "template" : e.type === GroupType.QUERY ? "query" : "normal" + } + }) + }); + }); + + const notifyClientPermissions = () => events.fire_async("notify_client_permissions", { + createQueryGroup: connection.permissions.neededPermission(PermissionType.B_SERVERINSTANCE_MODIFY_QUERYGROUP).granted(1), + createTemplateGroup: connection.permissions.neededPermission(PermissionType.B_SERVERINSTANCE_MODIFY_TEMPLATES).granted(1) + }); + events.on("query_client_permissions", notifyClientPermissions); + events.on("notify_destroy", connection.permissions.events.on("client_permissions_changed", notifyClientPermissions)); + + events.on("action_copy", event => { + connection.serverConnection.send_command("servergroupcopy", { + ssgid: event.source, + tsgid: event.target + }).then(() => { + createInfoModal(tr("Group permissions have been copied"), tr("The group permissions have been successfully copied.")).open(); + }).catch(error => { + if(error instanceof CommandResult && error.id === ErrorID.PERMISSION_ERROR) { + createErrorModal(tr("Failed to copy group permissions"), + tra("Failed to copy group permissions.\nMissing permission {}", connection.permissions.resolveInfo(parseInt(error.json["failed_permid"]))?.name || tr("unknwon"))).open(); + return; + } + + console.warn(tr("Failed to copy group permissions: %o"), error); + createErrorModal(tr("Failed to copy group permissions"), + tra("Failed to copy group permissions.\n{}", stringifyError(error))).open(); + }); + }); +} \ No newline at end of file diff --git a/shared/js/ui/modal/permission/ModalPermissionEdit.ts b/shared/js/ui/modal/permission/ModalPermissionEdit.ts index a2c6546f..9a3ddf28 100644 --- a/shared/js/ui/modal/permission/ModalPermissionEdit.ts +++ b/shared/js/ui/modal/permission/ModalPermissionEdit.ts @@ -1319,7 +1319,7 @@ function apply_server_groups(connection: ConnectionHandler, editor: AbstractPerm if(!!text.match(/^[0-9]+$/)) return true; try { - return atob(text).length >= 20; + return atob(text).length == 20; } catch(error) { return false; } @@ -1349,7 +1349,6 @@ function apply_server_groups(connection: ConnectionHandler, editor: AbstractPerm return; } - connection.serverConnection.send_command("servergroupaddclient", { sgid: current_group.id, cldbid: dbid @@ -1411,7 +1410,8 @@ function apply_server_groups(connection: ConnectionHandler, editor: AbstractPerm } } -function spawnGroupAdd(server_group: boolean, permissions: PermissionManager, valid_name: (name: string, group_type: number) => boolean, callback: (group_name: string, group_type: number) => any) { +/* Attention: This is used by the new permission editor! */ +export function spawnGroupAdd(server_group: boolean, permissions: PermissionManager, valid_name: (name: string, group_type: number) => boolean, callback: (group_name: string, group_type: number) => any) { let modal: Modal; modal = createModal({ header: tr("Create a new group"), diff --git a/shared/js/ui/modal/permission/SenselessPermissions.ts b/shared/js/ui/modal/permission/SenselessPermissions.ts index 248521af..d0dde38b 100644 --- a/shared/js/ui/modal/permission/SenselessPermissions.ts +++ b/shared/js/ui/modal/permission/SenselessPermissions.ts @@ -4,6 +4,8 @@ export const senseless_server_group_permissions: PermissionType[] = [ PermissionType.B_CHANNEL_GROUP_INHERITANCE_END ]; +/* TODO: All the needed permissions! */ + const filter = (text, ignore_type) => Object.keys(PermissionType) .filter(e => e.toLowerCase().substr(ignore_type ? 1 : 0).startsWith(text)).map(e => PermissionType[e]); diff --git a/shared/js/ui/modal/permissionv2/ModalPermissionEditor.scss b/shared/js/ui/modal/permissionv2/ModalPermissionEditor.scss new file mode 100644 index 00000000..c860402d --- /dev/null +++ b/shared/js/ui/modal/permissionv2/ModalPermissionEditor.scss @@ -0,0 +1,193 @@ +@import "../../../../css/static/mixin"; +@import "../../../../css/static/properties"; + +html:root { + --modal-permissions-header-text: #e1e1e1; + --modal-permissions-header-background: #19191b; + --modal-permissions-header-hover: #4e4e4e; + --modal-permissions-header-selected: #0073d4; + + --modal-permission-right: #303036; + --modal-permission-left: #222226; + + --modal-permissions-entry-hover: #28282c; + --modal-permissions-entry-selected: #111111; + --modal-permissions-current-group: #101012; + + --modal-permissions-buttons-background: #0f0f0f; + --modal-permissions-buttons-hover: #262626; + --modal-permissions-buttons-disabled: #1b1b1b; + + --modal-permissions-seperator: #1e1e1e; /* the seperator for the "enter a unique id" and "client info" part */ + --modal-permissions-container-seperator: #222224; /* the seperator between left and right */ + + --modal-permissions-icon-select: #121213; + --modal-permissions-icon-select-border: #0d0d0d; + --modal-permissions-icon-select-hover: #17171a; + --modal-permissions-icon-select-hover-border: #333333; + + --modal-permission-no-permnissions:#18171c; + --modal-permissions-table-border: #1e2025; + + --modal-permissions-table-header: #303036; + --modal-permissions-table-row-odd: #303036; + --modal-permissions-table-row-even: #25252a; + --modal-permissions-table-row-hover: #343a47; + + --modal-permissions-table-header-text: #e1e1e1; + --modal-permissions-table-row-text: #535455; + --modal-permissions-table-entry-active-text: #e1e1e1; + --modal-permissions-table-entry-group-text: #e1e1e1; + + --modal-permissions-table-input: #e1e1e1; + --modal-permissions-table-input-focus: #3f7dbf; +} + +.container { + @include user-select(none); + + display: flex; + flex-direction: row; + justify-content: stretch; + + width: 1000em; + min-width: 20em; + max-width: 100%; + + flex-shrink: 1; + flex-grow: 1; + + + .contextContainer { + display: flex; + flex-direction: column; + justify-content: stretch; + + &.left { + min-width: 10em; + min-height: 10em; + overflow: hidden; + background-color: var(--modal-permission-left); + } + + &.right { + min-width: 30em; + background-color: var(--modal-permission-right); + } + } + + .header { + flex-shrink: 0; + flex-grow: 0; + + height: 4em; + background-color: var(--modal-permissions-header-background); + color: var(--modal-permissions-header-text); + + display: flex; + flex-direction: row; + justify-content: stretch; + + .entry { + flex-grow: 1; + flex-shrink: 1; + + text-align: center; + + height: 100%; + + padding-left: .5em; + padding-right: .5em; + + display: flex; + flex-direction: column; + justify-content: space-around; + } + + &.tabSelector { + min-width: 8em; + + .entry { + position: relative; + overflow: hidden; + + cursor: pointer; + padding-bottom: 2px; + + a { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + &:hover { + border: none; + border-bottom: 2px solid var(--modal-permissions-header-hover); + + padding-bottom: 0; + + &:before { + position: absolute; + content: ''; + + margin-right: -10em; + margin-left: -10em; + margin-bottom: -.2em; + bottom: 0; + + height: 100%; + width: calc(100% + 20em); + + box-shadow: inset 0px -1.2em 3em -20px var(--modal-permissions-header-hover); + } + } + + &.selected { + border: none; + border-bottom: 2px solid var(--modal-permissions-header-selected); + + padding-bottom: 0; + + &:before { + position: absolute; + content: ''; + + margin-right: -10em; + margin-left: -10em; + margin-bottom: -.2em; + bottom: 0; + + height: 100%; + width: calc(100% + 20em); + + box-shadow: inset 0px -1.2em 3em -20px var(--modal-permissions-header-selected); + } + } + } + } + + &.activeTabInfo { + min-width: 6em; + font-weight: bold; + + .entry { + overflow: hidden; + + a { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + } + + > * { + font-size: 1.5em; + } + } + } + + .body { + flex-grow: 1; + flex-shrink: 1; + } +} \ No newline at end of file diff --git a/shared/js/ui/modal/permissionv2/ModalPermissionEditor.tsx b/shared/js/ui/modal/permissionv2/ModalPermissionEditor.tsx new file mode 100644 index 00000000..6b68d564 --- /dev/null +++ b/shared/js/ui/modal/permissionv2/ModalPermissionEditor.tsx @@ -0,0 +1,1128 @@ +import {Modal, spawnReactModal} from "tc-shared/ui/react-elements/Modal"; +import {ConnectionHandler} from "tc-shared/ConnectionHandler"; +import * as React from "react"; +import {useState} from "react"; +import {ContextDivider} from "tc-shared/ui/react-elements/ContextDivider"; +import {server_connections} from "tc-shared/ui/frames/connection_handlers"; +import {Translatable} from "tc-shared/ui/react-elements/i18n"; +import {Registry} from "tc-shared/events"; +import { + EditorGroupedPermissions, + PermissionEditor, + PermissionEditorEvents +} from "tc-shared/ui/modal/permissionv2/PermissionEditor"; +import {SideBar} from "tc-shared/ui/modal/permissionv2/TabHandler"; +import {Group, GroupTarget, GroupType} from "tc-shared/permission/GroupManager"; +import {createErrorModal, createInfoModal} from "tc-shared/ui/elements/Modal"; +import {ClientNameInfo, CommandResult, ErrorID} from "tc-shared/connection/ServerConnectionDeclaration"; +import {formatMessage} from "tc-shared/ui/frames/chat"; +import {spawnYesNo} from "tc-shared/ui/modal/ModalYesNo"; +import {tra} from "tc-shared/i18n/localize"; +import {PermissionType} from "tc-shared/permission/PermissionType"; +import {GroupedPermissions, PermissionValue} from "tc-shared/permission/PermissionManager"; +import {spawnIconSelect} from "tc-shared/ui/modal/ModalIconSelect"; +import {Settings, settings} from "tc-shared/settings"; +import { + senseless_channel_group_permissions, + senseless_channel_permissions, + senseless_client_channel_permissions, + senseless_client_permissions, + senseless_server_group_permissions +} from "tc-shared/ui/modal/permission/SenselessPermissions"; +import {spawnGroupCreate} from "tc-shared/ui/modal/ModalGroupCreate"; +import {spawnModalGroupPermissionCopy} from "tc-shared/ui/modal/ModalGroupPermissionCopy"; + +const cssStyle = require("./ModalPermissionEditor.scss"); + +export type PermissionEditorTab = "groups-server" | "groups-channel" | "channel" | "client" | "client-channel"; +export type PermissionEditorSubject = "groups-server" | "groups-channel" | "channel" | "client" | "client-channel" | "none"; +export const PermissionTabName: {[T in PermissionEditorTab]: { name: string, translated: string }} = { + "groups-server": { name: "Server Groups", translated: tr("Server Groups") }, + "groups-channel": { name: "Channel Groups", translated: tr("Channel Groups") }, + "channel": { name: "Channel Permissions", translated: tr("Channel Permissions") }, + "client": { name: "Client Permissions", translated: tr("Client Permissions") }, + "client-channel": { name: "Client Channel Permissions", translated: tr("Client Channel Permissions") }, +}; + +export type GroupProperties = { + id: number, + type: "query" | "template" | "normal"; + + name: string, + iconId: number, + + sortId: number; + saveDB: boolean; + + needed_modify_power: number; + needed_member_add: number; + needed_member_remove: number; +}; +export type GroupUpdateEntry = { + property: "name" | "icon" | "sort" | "save"; + value: any +}; +export type ChannelInfo = { + id: number; + iconId: number; + + name: string; + depth: number; +} + +export interface PermissionModalEvents { + action_activate_tab: { + tab: PermissionEditorTab, + + activeGroupId?: number; + activeChannelId?: number; + activeClientDatabaseId?: number; + }, + + action_select_group: { + target: "server" | "channel", + id: number + }, + + action_select_channel: { + target: "channel" | "client-channel"; + id: number + }, + + action_select_client: { + target: "client" | "client-channel"; + id: number | string | undefined; + } + + action_set_permission_editor_subject: { + mode: PermissionEditorSubject; + + groupId?: number; + channelId?: number; + clientDatabaseId?: number; + } + + action_create_group: { target: "server" | "channel", sourceGroup?: number }, + + action_rename_group: { target: "server" | "channel", id: number | "selected", newName: string }, + action_rename_group_result: { + target: "server" | "channel"; + id: number; + + status: "success" | "error"; + error?: string; + } + + action_delete_group: { target: "server" | "channel", id: number | "selected", mode: "ask" | "force" }, + action_delete_group_result: { + target: "server" | "channel"; + id: number; + + status: "success" | "error"; + error?: string; + }, + + action_group_copy_permissions: { target: "server" | "channel", sourceGroup: number }, + + action_server_group_add_client: { + id: number; + client: number | string; /* string would be the unique id */ + }, + action_server_group_add_client_result: { + id: number; + client: number | string; + + status: "success" | "error" | "no-permissions"; + error?: string; + } + + action_server_group_remove_client: { + id: number; + client: number; + }, + action_server_group_remove_client_result: { + id: number; + client: number; + + status: "success" | "error" | "no-permissions"; + error?: string; + } + + query_groups: { + target: "server" | "channel", + }, + query_groups_result: { + target: "server" | "channel", + groups: GroupProperties[] + }, + query_group_clients: { + id: number + }, + query_group_clients_result: { + id: number, + status: "success" | "error" | "no-permissions", + error?: string; + clients?: { + name: string; + databaseId: number; + uniqueId: string; + }[] + }, + + query_channels: {}, + query_channels_result: { + channels: ChannelInfo[] + } + + query_client_permissions: {}, /* will cause the notify_client_permissions */ + query_client_info: { + client: number | string; /* client database id or unique id */ + }, + query_client_info_result: { + client: number | string; + state: "success" | "error" | "no-such-client" | "no-permission"; + + error?: string; + info?: { name: string, uniqueId: string, databaseId: number }, + failedPermission?: string; + } + + notify_group_updated: { + target: "server" | "channel"; + id: number; + + properties: GroupUpdateEntry[]; + }, + notify_groups_created: { + target: "server" | "channel"; + groups: GroupProperties[] + }, + notify_groups_deleted: { + target: "server" | "channel"; + groups: number[] + }, + + notify_groups_reset: {} + + notify_client_permissions: { + permissionModifyPower: number; + + serverGroupCreate: boolean, + channelGroupCreate: boolean, + + serverGroupModifyPower: number, + channelGroupModifyPower: number, + + modifyQueryGroups: boolean, + modifyTemplateGroups: boolean + + serverGroupMemberAddPower: number, + serverGroupMemberRemovePower: number, + + serverGroupPermissionList: boolean, + channelGroupPermissionList: boolean, + channelPermissionList: boolean, + clientPermissionList: boolean, + clientChannelPermissionList: boolean + }, + + notify_client_list_toggled: { visible: boolean }, + notify_channel_updated: { id: number, property: "name" | "icon", value: any }, + + notify_destroy: {} +} +const ActiveTabInfo = (props: { events: Registry }) => { + const [ activeTab, setActiveTab ] = useState("groups-server"); + props.events.reactUse("action_activate_tab", event => setActiveTab(event.tab)); + + return +}; + +const TabSelectorEntry = (props: { events: Registry, entry: PermissionEditorTab }) => { + const [ active, setActive ] = useState(props.entry === "groups-server"); + + props.events.reactUse("action_activate_tab", event => setActive(event.tab === props.entry )); + + return
!active && props.events.fire("action_activate_tab", { tab: props.entry })}> + + {PermissionTabName[props.entry].name} + +
; +}; + +const TabSelector = (props: { events: Registry }) => { + return
+ + + + + +
; +}; + +export type DefaultTabValues = { groupId?: number, channelId?: number, clientDatabaseId?: number }; +class PermissionEditorModal extends Modal { + readonly modalEvents = new Registry(); + readonly editorEvents = new Registry(); + + readonly connection: ConnectionHandler; + + readonly defaultTab: PermissionEditorTab; + readonly defaultTabValues: DefaultTabValues; + + constructor(connection: ConnectionHandler, defaultTab: PermissionEditorTab, defaultTabValues?: DefaultTabValues) { + super(); + this.defaultTab = defaultTab; + this.defaultTabValues = defaultTabValues || {}; + + this.modalEvents.enable_debug("modal-permissions"); + this.editorEvents.enable_debug("permissions-editor"); + + this.connection = connection; + initializePermissionModalResultHandlers(this.modalEvents); + initializePermissionModalController(connection, this.modalEvents); + initializePermissionEditor(connection, this.modalEvents, this.editorEvents); + + this.modalEvents.on("action_activate_tab", event => this.editorEvents.fire("action_toggle_client_button", { visible: event.tab === "groups-server" })); + this.editorEvents.on("action_toggle_client_list", event => this.modalEvents.fire("notify_client_list_toggled", { visible: event.visible })); + } + + protected onInitialize() { + this.modalController().events.on("destroy", () => this.modalEvents.fire("notify_destroy")); + this.modalEvents.fire("query_client_permissions"); + this.modalEvents.fire("action_activate_tab", { + tab: this.defaultTab, + activeChannelId: this.defaultTabValues?.channelId, + activeGroupId: this.defaultTabValues?.groupId, + activeClientDatabaseId: this.defaultTabValues?.clientDatabaseId + }); + } + + renderBody() { + return ( +
+ +
+ + +
+
+ + +
+
+
+ ); + } + + title(): string { + return tr("Server permissions"); + } + +} + +export function spawnPermissionEditorModal(connection: ConnectionHandler, defaultTab: PermissionEditorTab = "groups-server", values?: DefaultTabValues) { + const modal = spawnReactModal(PermissionEditorModal, connection, defaultTab, values); + modal.show(); +} +const spawn = () => spawnPermissionEditorModal(server_connections.active_connection()); +(window as any).spawn_permissions = spawn; + +function initializePermissionModalResultHandlers(events: Registry) { + events.on("action_rename_group_result", event => { + if(event.status === "error") { + createErrorModal(tr("Failed to rename group"), formatMessage(tr("Failed to rename group:{:br:}"), event.error)).open(); + } else { + createInfoModal(tr("Group renamed"), tr("The group has been renamed.")).open(); + } + }); + + events.on("action_delete_group", event => { + if(event.mode === "force") + return; + + spawnYesNo(tr("Are you sure?"), formatMessage(tr("Do you really want to delete this group?")), result => { + if(result !== true) + return; + + events.fire("action_delete_group", { id: event.id, mode: "force", target: event.target }); + }); + }); + + events.on("action_delete_group_result", event => { + if(event.status === "success") { + createInfoModal(tr("Group deleted"), tr("The channel group has been deleted.")).open(); + } else { + createErrorModal(tr("Failed to delete group"), tra("Failed to delete group:\n{}", event.error)).open(); + } + }); + + events.on("action_server_group_remove_client_result", event => { + if(event.status === "error") { + createErrorModal(tr("Failed to remove client"), tra("Failed to remove client from server group:\n{}", event.error)).open(); + } else if(event.status === "no-permissions") { + createErrorModal(tr("Failed to remove client"), tra("Failed to remove client from server group:\nNo permissions.")).open(); + } + }); + + events.on("action_server_group_add_client_result", event => { + if(event.status === "error") { + createErrorModal(tr("Failed to add client"), tra("Failed to add client to server group:\n{}", event.error)).open(); + } else if(event.status === "no-permissions") { + createErrorModal(tr("Failed to add client"), tra("Failed to add client to group:\nNo permissions.")).open(); + } + }); +} + +const stringifyError = error => { + if(error instanceof CommandResult) { + if(error.id === ErrorID.PERMISSION_ERROR) + return tr("insufficient permissions"); + else + return error.message + (error.extra_message ? " (" + error.extra_message + ")" : ""); + } else if(error instanceof Error) { + return error.message; + } else if(typeof error !== "string") { + return tr("Lookup the console"); + } + return error; +}; + +function initializePermissionModalController(connection: ConnectionHandler, events: Registry) { + events.on("query_groups", event => { + const groups = event.target === "server" ? connection.groups.serverGroups : connection.groups.channelGroups; + events.fire_async("query_groups_result", { target: event.target, groups: groups.map(group => { + return { + id: group.id, + name: group.name, + + iconId: group.properties.iconid, + sortId: group.properties.sortid, + saveDB: group.properties.savedb, + type: group.type === GroupType.QUERY ? "query" : group.type === GroupType.TEMPLATE ? "template" : "normal", + + needed_member_add: group.requiredMemberAddPower, + needed_member_remove: group.requiredMemberRemovePower, + needed_modify_power: group.requiredModifyPower + } as GroupProperties + })}); + }); + + /* group update listener */ + { + const initializeGroupListener = (group: Group) => { + let unregister = group.events.on("notify_properties_updated", event => { + let updates: GroupUpdateEntry[] = []; + for(const update of event.updated_properties) { + switch (update) { + case "name": + updates.push({ property: "name", value: group.name }); + break; + + case "icon": + updates.push({ property: "icon", value: group.properties.iconid }); + break; + + case "sort-id": + updates.push({ property: "sort", value: group.properties.sortid }); + break; + + case "save-db": + updates.push({ property: "save", value: group.properties.savedb }); + break; + + default: + break; + } + } + events.fire("notify_group_updated", { target: group.target === GroupTarget.SERVER ? "server" : "channel", id: group.id, properties: updates }); + }); + + const doUnregister = () => { + unregister && unregister(); + unregister = undefined; + }; + group.events.on("notify_group_deleted", doUnregister); + events.on("notify_destroy", doUnregister); + }; + + events.on("notify_destroy", connection.groups.events.on("notify_reset", () => { + events.fire("notify_groups_reset"); + })); + + events.on("notify_destroy", connection.groups.events.on("notify_groups_created", event => { + const channelGroups: GroupProperties[] = []; + const serverGroups: GroupProperties[] = []; + + for(const group of event.groups) { + (group.target === GroupTarget.SERVER ? serverGroups : channelGroups).push({ + iconId: group.properties.iconid, + id: group.id, + name: group.name, + saveDB: group.properties.savedb, + sortId: group.properties.sortid, + type: group.type === GroupType.QUERY ? "query" : group.type === GroupType.TEMPLATE ? "template" : "normal", + needed_member_add: group.requiredMemberAddPower, + needed_member_remove: group.requiredMemberRemovePower, + needed_modify_power: group.requiredModifyPower + }); + initializeGroupListener(group); + } + + if(channelGroups.length > 0) + events.fire("notify_groups_created", { groups: channelGroups, target: "channel" }); + + if(serverGroups.length > 0) + events.fire("notify_groups_created", { groups: serverGroups, target: "server" }); + })); + + events.on("notify_destroy", connection.groups.events.on("notify_groups_deleted", event => { + const channelGroups: number[] = []; + const serverGroups: number[] = []; + + for(const group of event.groups) + (group.target === GroupTarget.SERVER ? serverGroups : channelGroups).push(group.id); + + if(channelGroups.length > 0) + events.fire("notify_groups_deleted", { groups: channelGroups, target: "channel" }); + + if(serverGroups.length > 0) + events.fire("notify_groups_deleted", { groups: serverGroups, target: "server" }); + })); + + connection.groups.serverGroups.forEach(initializeGroupListener); + connection.groups.channelGroups.forEach(initializeGroupListener); + } + + { + /* group actions */ + let selectedChannelGroup = 0, selectedServerGroup = 0; + events.on("action_select_group", event => { + if(event.target === "channel") + selectedChannelGroup = event.id; + else + selectedServerGroup = event.id; + }); + + events.on("action_rename_group", event => { + const groupId = event.id === "selected" ? event.target === "channel" ? selectedChannelGroup : selectedServerGroup : event.id; + + let payload = { name: event.newName } as any; + if(event.target === "channel") + payload.cgid = groupId; + else + payload.sgid = groupId; + + connection.serverConnection.send_command(event.target + "grouprename", payload).then(() => { + events.fire("action_rename_group_result", { id: groupId, status: "success", target: event.target }); + }).catch(error => { + console.warn(tr("Failed to rename group: %o"), error); + events.fire("action_rename_group_result", { id: groupId, status: "error", target: event.target, error: stringifyError(error) }); + }); + }); + + + events.on("action_delete_group", event => { + /* ask will be handled within the modal */ + if(event.mode === "ask") + return; + + const groupId = event.id === "selected" ? event.target === "channel" ? selectedChannelGroup : selectedServerGroup : event.id; + + let payload = { force: true } as any; + if(event.target === "channel") + payload.cgid = groupId; + else + payload.sgid = groupId; + + connection.serverConnection.send_command(event.target + "groupdel", payload).then(() => { + events.fire("action_delete_group_result", { id: groupId, status: "success", target: event.target }); + }).catch(error => { + console.warn(tr("Failed to delete group: %o"), error); + events.fire("action_delete_group_result", { id: groupId, status: "error", target: event.target, error: stringifyError(error) }); + }); + }); + + events.on("action_create_group", event => spawnGroupCreate(connection, event.target, event.sourceGroup)); + events.on("action_group_copy_permissions", event => spawnModalGroupPermissionCopy(connection, event.target, event.sourceGroup)); + } + + /* general permissions */ + { + const sendClientPermissions = () => { + events.fire("notify_client_permissions", { + permissionModifyPower: connection.permissions.neededPermission(PermissionType.I_PERMISSION_MODIFY_POWER).valueOr(0), + + modifyQueryGroups: connection.permissions.neededPermission(PermissionType.B_SERVERINSTANCE_MODIFY_QUERYGROUP).granted(1), + modifyTemplateGroups: connection.permissions.neededPermission(PermissionType.B_SERVERINSTANCE_MODIFY_TEMPLATES).granted(1), + + channelGroupCreate: connection.permissions.neededPermission(PermissionType.B_VIRTUALSERVER_CHANNELGROUP_CREATE).granted(1), + serverGroupCreate: connection.permissions.neededPermission(PermissionType.B_VIRTUALSERVER_SERVERGROUP_CREATE).granted(1), + + channelGroupModifyPower: connection.permissions.neededPermission(PermissionType.I_CHANNEL_GROUP_MODIFY_POWER).valueOr(0), + serverGroupModifyPower: connection.permissions.neededPermission(PermissionType.I_SERVER_GROUP_MODIFY_POWER).valueOr(0), + + serverGroupMemberAddPower: connection.permissions.neededPermission(PermissionType.I_SERVER_GROUP_MEMBER_ADD_POWER).valueOr(0), + serverGroupMemberRemovePower: connection.permissions.neededPermission(PermissionType.I_SERVER_GROUP_MEMBER_REMOVE_POWER).valueOr(0), + + serverGroupPermissionList: connection.permissions.neededPermission(PermissionType.B_VIRTUALSERVER_SERVERGROUP_PERMISSION_LIST).granted(1), + channelGroupPermissionList: connection.permissions.neededPermission(PermissionType.B_VIRTUALSERVER_CHANNELGROUP_PERMISSION_LIST).granted(1), + channelPermissionList: connection.permissions.neededPermission(PermissionType.B_VIRTUALSERVER_CHANNEL_PERMISSION_LIST).granted(1), + clientPermissionList: connection.permissions.neededPermission(PermissionType.B_VIRTUALSERVER_CLIENT_PERMISSION_LIST).granted(1), + clientChannelPermissionList: connection.permissions.neededPermission(PermissionType.B_VIRTUALSERVER_CHANNELCLIENT_PERMISSION_LIST).granted(1), + }); + }; + + events.on("query_client_permissions", () => sendClientPermissions()); + events.on("notify_destroy", connection.permissions.events.on("client_permissions_changed", sendClientPermissions)); + } + + events.on("query_group_clients", event => { + connection.serverConnection.command_helper.request_clients_by_server_group(event.id).then(clients => { + events.fire("query_group_clients_result", { id: event.id, status: "success", clients: clients.map(e => { + return { + name: e.client_nickname, + uniqueId: e.client_unique_identifier, + databaseId: e.client_database_id + }; + })}); + }).catch(error => { + if(error instanceof CommandResult && error.id === ErrorID.PERMISSION_ERROR) { + events.fire("query_group_clients_result", { id: event.id, status: "no-permissions" }); + return; + } + + console.warn(tr("Failed to request server group client list: %o"), error); + events.fire("query_group_clients_result", { id: event.id, status: "error", error: stringifyError(error) }); + }); + }); + + events.on("action_server_group_add_client", event => { + Promise.resolve(event.client).then(client => { + if(typeof client === "number") + return Promise.resolve(client); + + return connection.serverConnection.command_helper.info_from_uid(client.trim()).then(info => info[0].client_database_id); + }).then(clientDatabaseId => connection.serverConnection.send_command("servergroupaddclient", { + sgid: event.id, + cldbid: clientDatabaseId + })).then(() => { + events.fire("action_server_group_add_client_result", { id: event.id, client: event.client, status: "success" }); + }).catch(error => { + if(error instanceof CommandResult && error.id === ErrorID.PERMISSION_ERROR) { + events.fire("action_server_group_add_client_result", { id: event.id, client: event.client, status: "no-permissions" }); + return; + } + + console.warn(tr("Failed to add client %s to server group %d: %o"), event.client.toString(), event.id, error); + events.fire("action_server_group_add_client_result", { id: event.id, client: event.client, status: "error", error: stringifyError(error) }); + }) + }); + + events.on("action_server_group_remove_client", event => { + connection.serverConnection.send_command("servergroupdelclient", { + sgid: event.id, + cldbid: event.client + }).then(() => { + events.fire("action_server_group_remove_client_result", { id: event.id, client: event.client, status: "success" }); + }).catch(error => { + console.log(tr("Failed to delete client %d from server group %d: %o"), event.client, event.id, error); + events.fire("action_server_group_remove_client_result", { id: event.id, client: event.client, status: "success" }); + }); + }); + + events.on("notify_destroy", connection.channelTree.events.on("notify_channel_updated", event => { + if('channel_icon_id' in event.updatedProperties) + events.fire("notify_channel_updated", { id: event.channel.channelId, property: "icon", value: event.updatedProperties.channel_icon_id }); + + if('channel_name' in event.updatedProperties) + events.fire("notify_channel_updated", { id: event.channel.channelId, property: "name", value: event.updatedProperties.channel_name }); + })); + + events.on("query_channels", () => { + events.fire_async("query_channels_result", { + channels: connection.channelTree.channelsOrdered().map(e => { + return { + id: e.channelId, + name: e.channelName(), + depth: e.channelDepth(), + iconId: e.properties.channel_icon_id + }; + }) + }); + }); + + events.on("query_client_info", event => { + let promise: Promise; + if(typeof event.client === "number") { + promise = connection.serverConnection.command_helper.info_from_cldbid(event.client); + } else { + promise = connection.serverConnection.command_helper.info_from_uid(event.client.trim()); + } + promise.then(result => { + if(result.length === 0) { + events.fire("query_client_info_result", { + client: event.client, + state: "no-such-client" + }); + return; + } + events.fire("query_client_info_result", { + client: event.client, + state: "success", + info: { name: result[0].client_nickname, databaseId: result[0].client_database_id, uniqueId: result[0].client_unique_id } + }); + }).catch(error => { + if(error instanceof CommandResult) { + events.fire("query_client_info_result", { + client: event.client, + state: "no-permission", + failedPermission: connection.permissions.resolveInfo(parseInt(error.json["failed_permid"]))?.name || tr("unknwon") + }); + return; + } + + console.warn(tr("Failed to query client info for %o: %o"), event.client, error); + events.fire("query_client_info_result", { + client: event.client, + state: "error", + error: stringifyError(error) + }); + }); + }); +} + + +function initializePermissionEditor(connection: ConnectionHandler, modalEvents: Registry, events: Registry) { + let clientDatabaseId = 0; + let channelId = 0; + let groupId = 0; + + let mode: PermissionEditorSubject = "groups-server"; + + let serverGroupPermissionList = false; + let channelGroupPermissionList = false; + let channelPermissionList = false; + let clientPermissionList = false; + let clientChannelPermissionList = false; + + modalEvents.on("action_activate_tab", event => { + clientDatabaseId = 0; + channelId = 0; + groupId = 0; + + events.fire("action_set_mode", { mode: "unset" }); + + switch (event.tab) { + case "groups-server": + events.fire("action_set_senseless_permissions", { permissions: senseless_server_group_permissions }); + break; + + case "groups-channel": + events.fire("action_set_senseless_permissions", { permissions: senseless_channel_group_permissions }); + break; + + case "channel": + events.fire("action_set_senseless_permissions", { permissions: senseless_channel_permissions }); + break; + + case "client": + events.fire("action_set_senseless_permissions", { permissions: senseless_client_permissions }); + break; + + case "client-channel": + events.fire("action_set_senseless_permissions", { permissions: senseless_client_channel_permissions }); + break; + } + }); + + const failedPermission = (): string => { + switch (mode) { + case "groups-server": + return serverGroupPermissionList ? undefined : PermissionType.B_VIRTUALSERVER_SERVERGROUP_PERMISSION_LIST; + case "groups-channel": + return channelGroupPermissionList ? undefined : PermissionType.B_VIRTUALSERVER_CHANNELGROUP_PERMISSION_LIST; + case "client": + return clientPermissionList ? undefined : PermissionType.B_VIRTUALSERVER_CLIENT_PERMISSION_LIST; + case "channel": + return channelPermissionList ? undefined : PermissionType.B_VIRTUALSERVER_CHANNEL_PERMISSION_LIST; + case "client-channel": + return clientChannelPermissionList ? undefined : PermissionType.B_VIRTUALSERVER_CHANNELCLIENT_PERMISSION_LIST; + + default: + return undefined; + } + }; + modalEvents.on("notify_client_permissions", event => { + serverGroupPermissionList = event.serverGroupPermissionList; + channelGroupPermissionList = event.channelGroupPermissionList; + channelPermissionList = event.channelPermissionList; + clientPermissionList = event.clientPermissionList; + clientChannelPermissionList = event.clientChannelPermissionList; + + const failed = failedPermission(); + if(failed) { + events.fire("action_set_mode", { mode: "no-permissions", failedPermission: failed }); + } + events.fire("action_set_default_value", { value: event.permissionModifyPower }); + }); + + modalEvents.on("action_set_permission_editor_subject", event => { + channelId = typeof event.channelId === "number" ? event.channelId : channelId; + clientDatabaseId = typeof event.clientDatabaseId === "number" ? event.clientDatabaseId : clientDatabaseId; + groupId = typeof event.groupId === "number" ? event.groupId : groupId; + + mode = event.mode; + + let editorMode: "unset" | "normal" = "unset"; + switch (mode) { + case "none": + editorMode = "unset"; + break; + + case "groups-server": + case "groups-channel": + editorMode = groupId === 0 ? "unset" : "normal"; + break; + + case "channel": + editorMode = channelId === 0 ? "unset" : "normal"; + break; + + case "client": + editorMode = clientDatabaseId === 0 ? "unset" : "normal"; + break; + + case "client-channel": + editorMode = clientDatabaseId === 0 || channelId === 0 ? "unset" : "normal"; + break; + } + + const failed = failedPermission(); + events.fire("action_set_mode", { mode: failed ? "no-permissions" : editorMode, failedPermission: failed }); + if(!failed && editorMode === "normal") + events.fire("query_permission_values"); + }); + + events.on("query_permission_list", () => { + const groups = connection.permissions.groupedPermissions(); + + const visitGroup = (group: GroupedPermissions): EditorGroupedPermissions => { + return { + groupId: group.group.name + " - " + group.group.begin.toString(), + groupName: group.group.name, + permissions: group.permissions.map(e => { return { id: e.id, name: e.name, description: e.description }}), + children: (group.children || []).map(visitGroup) + }; + }; + + events.fire_async("query_permission_list_result", { + hideSenselessPermissions: !settings.static_global(Settings.KEY_PERMISSIONS_SHOW_ALL), + permissions: (groups || []).map(visitGroup) + }); + }); + + events.on("query_permission_values", () => { + let promise: Promise; + switch (mode) { + case "none": + promise = Promise.reject(tr("Invalid subject")); + break; + + case "groups-server": + case "groups-channel": + if(!groupId) { + promise = Promise.reject(tr("Invalid server group")); + break; + } + + const group = mode === "groups-server" ? connection.groups.findServerGroup(groupId) : connection.groups.findChannelGroup(groupId); + if(!group) { + promise = Promise.reject(tr("Invalid server group")); + break; + } + promise = connection.groups.request_permissions(group); + break; + + case "channel": + if(!channelId) { + promise = Promise.reject(tr("Invalid channel id")); + break; + } + + promise = connection.permissions.requestChannelPermissions(channelId); + break; + case "client": + if(!clientDatabaseId) { + promise = Promise.reject(tr("Invalid client database id")); + break; + } + + promise = connection.permissions.requestClientPermissions(clientDatabaseId); + break; + + case "client-channel": + if(!clientDatabaseId) { + promise = Promise.reject(tr("Invalid client database id")); + break; + } + + if(!channelId) { + promise = Promise.reject(tr("Invalid channel id")); + break; + } + + promise = connection.permissions.requestClientChannelPermissions(clientDatabaseId, channelId); + break; + } + + promise.then(permissions => { + events.fire("query_permission_values_result", { status: "success", permissions: permissions.map(e => { + return { + value: e.value, + name: e.type.name, + granted: e.granted_value, + flagNegate: e.flag_negate, + flagSkip: e.flag_skip + }}) + }); + }).catch(error => { + if(error instanceof CommandResult && error.id === ErrorID.PERMISSION_ERROR) { + events.fire("action_set_mode", { mode: "no-permissions", failedPermission: connection.permissions.resolveInfo(parseInt(error.json["failed_permid"]))?.name || tr("unknwon") }); + return; + } + + console.warn(tr("Failed to query permissions: %o"), error); + events.fire("query_permission_values_result", { status: "error", error: stringifyError(error) }); + }); + }); + + const granted_permission_name = (name: string) => "i_needed_modify_power_" + name.substr(2); + events.on("action_set_permissions", event => { + let promise: Promise; + + let payload = []; + event.permissions.forEach(permission => payload.push({ + permsid: permission.mode === "grant" ? granted_permission_name(permission.name) : permission.name, + permvalue: permission.value | 0, /* signed values */ + permskip: permission.mode === "grant" ? false : permission.flagSkip, + permnegated: permission.mode === "grant" ? false : permission.flagNegate + })); + + switch (mode) { + case "none": + promise = Promise.reject(tr("Invalid subject")); + break; + + case "groups-server": + case "groups-channel": { + if(!groupId) { + promise = Promise.reject(tr("Invalid server group")); + break; + } + + let prefix = mode === "groups-server" ? "server" : "channel"; + if(mode === "groups-server") + payload[0].sgid = groupId; + else + payload[0].cgid = groupId; + + promise = connection.serverConnection.send_command(prefix + "groupaddperm", payload); + break; + } + case "channel": + if(!channelId) { + promise = Promise.reject(tr("Invalid channel id")); + break; + } + + payload[0].cid = channelId; + promise = connection.serverConnection.send_command("channeladdperm", payload); + break; + + case "client": + if(!clientDatabaseId) { + promise = Promise.reject(tr("Invalid client database id")); + break; + } + + payload[0].cldbid = clientDatabaseId; + promise = connection.serverConnection.send_command("clientaddperm", payload); + break; + + case "client-channel": + if(!clientDatabaseId) { + promise = Promise.reject(tr("Invalid client database id")); + break; + } + if(!channelId) { + promise = Promise.reject(tr("Invalid channel id")); + break; + } + + payload[0].cldbid = clientDatabaseId; + payload[0].cid = channelId; + promise = connection.serverConnection.send_command("channelclientaddperm", payload); + break; + } + + promise.then(result => { throw result; }).catch(error => { + if(error instanceof CommandResult) { + if(error.getBulks().length === event.permissions.length) { + events.fire("action_set_permissions_result", { + permissions: error.getBulks().map((e, index) => { + return { + name: event.permissions[index].name, + mode: event.permissions[index].mode, + + newValue: e.success ? event.permissions[index].value : undefined, + flagSkip: e.success ? event.permissions[index].flagSkip : undefined, + flagNegate: e.success ? event.permissions[index].flagNegate : undefined + } + }) + }); + return; + } + } + + console.warn(tr("Failed to set permissions: %o"), error); + events.fire("action_set_permissions_result", { + permissions: event.permissions.map(permission => { + return { + name: permission.name, + mode: permission.mode, + + newValue: undefined, + flagSkip: undefined, + flagNegate: undefined + } + }) + }); + }) + }); + + events.on("action_remove_permissions", event => { + let promise: Promise; + + let payload = []; + event.permissions.forEach(permission => payload.push({ + permsid: permission.mode === "grant" ? granted_permission_name(permission.name) : permission.name, + })); + switch (mode) { + case "none": + promise = Promise.reject(tr("Invalid subject")); + break; + + case "groups-server": + case "groups-channel": { + if(!groupId) { + promise = Promise.reject(tr("Invalid server group")); + break; + } + + let prefix = mode === "groups-server" ? "server" : "channel"; + if(mode === "groups-server") + payload[0].sgid = groupId; + else + payload[0].cgid = groupId; + + promise = connection.serverConnection.send_command(prefix + "groupdelperm", payload); + break; + } + case "channel": + if(!channelId) { + promise = Promise.reject(tr("Invalid channel id")); + break; + } + + payload[0].cid = channelId; + promise = connection.serverConnection.send_command("channeldelperm", payload); + break; + + case "client": + if(!clientDatabaseId) { + promise = Promise.reject(tr("Invalid client database id")); + break; + } + + payload[0].cldbid = clientDatabaseId; + promise = connection.serverConnection.send_command("clientdelperm", payload); + break; + + case "client-channel": + if(!clientDatabaseId) { + promise = Promise.reject(tr("Invalid client database id")); + break; + } + + if(!channelId) { + promise = Promise.reject(tr("Invalid channel id")); + break; + } + + payload[0].cid = channelId; + payload[0].cldbid = clientDatabaseId; + promise = connection.serverConnection.send_command("channelclientdelperm", payload); + break; + } + + promise.then(result => { throw result; }).catch(error => { + if(error instanceof CommandResult) { + if(error.getBulks().length === event.permissions.length) { + events.fire("action_remove_permissions_result", { + permissions: error.getBulks().map((e, index) => { + return { + name: event.permissions[index].name, + mode: event.permissions[index].mode, + success: e.success + } + }) + }); + return; + } + } + + console.warn(tr("Failed to remove permissions: %o"), error); + events.fire("action_remove_permissions_result", { + permissions: event.permissions.map(permission => { + return { + name: permission.name, + mode: permission.mode, + success: false + } + }) + }); + }) + }); + + events.on("action_open_icon_select", event => { + spawnIconSelect(connection, + id => events.fire("action_set_permissions", { permissions: [{ mode: "value", name: PermissionType.I_ICON_ID, flagSkip: false, flagNegate: false, value: id }] }), + event.iconId); + }); +} + + + + + + + + + + + + + + + + + + + diff --git a/shared/js/ui/modal/permissionv2/PermissionEditor.scss b/shared/js/ui/modal/permissionv2/PermissionEditor.scss new file mode 100644 index 00000000..493429c3 --- /dev/null +++ b/shared/js/ui/modal/permissionv2/PermissionEditor.scss @@ -0,0 +1,458 @@ +@import "../../../../css/static/mixin"; +@import "../../../../css/static/properties"; + +html:root { + --modal-permission-loading: #666; + --modal-permission-error: #666161; +} + +.containerMenuBar { + padding-top: .5em; + padding-left: .5em; + padding-right: .5em; + + flex-shrink: 0; + flex-grow: 0; + + height: 3em; + box-sizing: content-box; + + display: flex; + flex-direction: row; + justify-content: stretch; + + .clients { + width: 12em; + min-width: 3em; + + flex-shrink: 1; + flex-grow: 0; + + margin-right: 1em; + align-self: center; + + &.hidden { + display: none; + } + } + + .filter { + flex-grow: 1; + flex-shrink: 1; + + min-width: 4em; + + align-self: center; + + padding-top: 0 !important; + margin-bottom: 0 !important; + + .label { + top: 0.5em; + } + + &:focus-within .label, .labelFloating { + top: -0.4em!important; + } + } + + .options { + display: flex; + flex-direction: column; + justify-content: center; + + margin-left: .5em; + font-size: .9em; + padding-top: .8em; /* since we've only one switch currently */ + } + + .containerIconSelect { + position: relative; + + height: 2.5em; + + border-radius: .2em; + margin-left: 1em; + + display: flex; + flex-direction: row; + justify-content: flex-end; + + cursor: pointer; + background-color: var(--modal-permissions-icon-select); + border: 1px solid var(--modal-permissions-icon-select-border); + + .preview { + height: 100%; + width: 3em; + + border: none; + border-right: 1px solid var(--modal-permissions-icon-select-border); + + display: flex; + flex-direction: column; + justify-content: space-around; + + > div { + align-self: center; + } + + > img { + align-self: center; + + width: 1em; + height: 1em; + } + + @include transition(border-color $button_hover_animation_time ease-in-out); + } + + .containerDropdown { + position: relative; + cursor: pointer; + + display: flex; + flex-direction: column; + justify-content: space-around; + + height: 100%; + width: 1.5em; + + .button { + text-align: center; + + :global(.arrow) { + border-color: var(--text); + } + } + + .dropdown { + display: none; + position: absolute; + width: max-content; + + top: calc(2.5em - 2px); + + flex-direction: column; + justify-content: flex-start; + + background-color: var(--modal-permissions-icon-select); + border: 1px solid var(--modal-permissions-icon-select-border); + border-radius: .2em 0 .2em .2em; + + right: -1px; + + z-index: 10; + + .entry { + padding: .5em; + + &:not(:last-of-type) { + border: none; + border-bottom: 1px solid var(--modal-permissions-icon-select-border); + } + + &:hover { + background-color: var(--modal-permissions-icon-select-hover); + } + } + } + + &:hover { + border-bottom-right-radius: 0; + .dropdown { + display: flex; + } + } + } + + &:hover { + background-color: var(--modal-permissions-icon-select-hover); + border-color: var(--modal-permissions-icon-select-hover-border); + + .preview { + border-color: var(--modal-permissions-icon-select-hover-border); + } + } + + @include transition(border-color $button_hover_animation_time ease-in-out); + } +} + +.permissionTable { + @include user-select(none); + + position: relative; + + display: flex; + flex-direction: column; + justify-content: stretch; + + min-height: 6em; + height: 50em; + + flex-shrink: 1; + flex-grow: 1; + + padding-left: .5em; + padding-right: .5em; + + .row { + display: flex; + flex-direction: row; + justify-content: stretch; + + flex-grow: 0; + flex-shrink: 0; + + width: 100%; + height: 2em; + + border: none; + + border-bottom: 1px solid var(--modal-permissions-table-border); + + @mixin fixed-column($name, $width) { + .column#{$name} { + display: flex; + flex-direction: row; + justify-content: stretch; + + flex-grow: 0; + flex-shrink: 0; + + width: $width; + + align-items: center; + + padding-left: 1em; + + border: none; + border-right: 1px solid var(--modal-permissions-table-border); + + overflow: hidden; + + a { + max-width: 100%; + + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + } + } + + @include fixed-column(Name, 6em); + @include fixed-column(Value, 6em); + @include fixed-column(Skip, 5em); + @include fixed-column(Negate, 5em); + @include fixed-column(Granted, 6em); + + .columnName { + flex-grow: 1; + flex-shrink: 1; + + .groupName { + margin-left: .5em; + } + } + + .columnGranted { + border-right: none; + } + + + &.active { + color: var(--modal-permissions-table-entry-active-text)!important; + } + + &.group { + color: var(--modal-permissions-table-entry-group-text)!important; + font-weight: bold; + + :global(.arrow) { + cursor: pointer; + border-color: var(--modal-permissions-table-entry-active-text); + } + } + + &.permission {} + } + + .header { + .row { + background-color: var(--modal-permissions-table-header); + color: var(--modal-permissions-table-header-text); + + font-weight: bold; + + .columnGranted { + margin-right: .5em; + } + } + } + + .body { + flex-grow: 1; + flex-shrink: 1; + + position: relative; + min-height: 6em; /* TODO: Width */ + + display: flex; + flex-direction: column; + justify-content: stretch; + + overflow-y: scroll; + overflow-x: auto; + + @include chat-scrollbar-vertical(); + @include chat-scrollbar-horizontal(); + + .row { + position: absolute; + + left: 0; + right: 0; + + color: var(--modal-permissions-table-row-text); + background-color: var(--modal-permissions-table-row-odd); + + &.even { + background-color: var(--modal-permissions-table-row-even); + } + + &:hover { + background-color: var(--modal-permissions-table-row-hover); + } + + input[type="number"] { + color: var(--modal-permissions-table-input); + + outline: none; + background: transparent; + border: none; + + height: 1.5em; + width: 5em; /* the column width minus one */ + + /* fix the column padding */ + padding-left: 1em; + margin-left: -.5em; /* have a bit of space on both sides */ + + border-bottom: 2px solid transparent; + + @include transition(border-bottom-color $button_hover_animation_time ease-in-out); + + &:not(.applying):focus { + border-bottom-color: var(--modal-permissions-table-input-focus); + } + + + &.applying { + padding-left: 0; + } + } + + /* We cant use this effect here because the odd/even effect would be a bit crazy then */ + //@include transition(background-color $button_hover_animation_time ease-in-out); + } + + .spaceAllocator { + width: 100%; + flex-shrink: 0; + flex-grow: 0; + } + + .overlay { + position: absolute; + + top: 0; + left: 0; + right: 0; + bottom: 0; + + display: flex; + flex-direction: column; + justify-content: flex-start; + + z-index: 1; + background-color: var(--modal-permission-right); + + padding-top: 2em; + + a { + text-align: center; + font-size: 1.6em; + + color: var(--modal-permission-loading); + } + + &.hidden { + opacity: 0; + pointer-events: none; + } + + &.error { + a { + color: var(--modal-permission-error); + } + } + } + } + + .overlay { + position: absolute; + + top: 0; + left: 0; + right: 0; + bottom: 0; + + display: flex; + flex-direction: column; + justify-content: center; + + z-index: 1; + background: var(--modal-permission-right); + + &.hidden { + display: none; + } + + &.unset {} + &.noPermissions { + justify-content: flex-start; + padding-top: 2em; + font-size: 1em; + + a { + text-align: center; + font-size: 1.5em; + color: var(--modal-permission-no-permnissions); + } + } + } +} + +.containerFooter { + display: flex; + flex-direction: row; + justify-content: flex-end; + + padding: .5em; + + button { + display: flex; + flex-direction: row; + justify-content: center; + + * { + align-self: center; + } + + div { + margin-right: .5em; + } + } +} \ No newline at end of file diff --git a/shared/js/ui/modal/permissionv2/PermissionEditor.tsx b/shared/js/ui/modal/permissionv2/PermissionEditor.tsx new file mode 100644 index 00000000..c3485dd6 --- /dev/null +++ b/shared/js/ui/modal/permissionv2/PermissionEditor.tsx @@ -0,0 +1,1206 @@ +import * as React from "react"; +import {useEffect, useRef, useState} from "react"; +import {FlatInputField} from "tc-shared/ui/react-elements/InputField"; +import {Translatable} from "tc-shared/ui/react-elements/i18n"; +import {EventHandler, ReactEventHandler, Registry} from "tc-shared/events"; +import {Switch} from "tc-shared/ui/react-elements/Switch"; +import PermissionType from "tc-shared/permission/PermissionType"; +import * as log from "tc-shared/log"; +import {LogCategory} from "tc-shared/log"; + +import ResizeObserver from "resize-observer-polyfill"; +import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots"; +import {Button} from "tc-shared/ui/react-elements/Button"; +import {IconRenderer, LocalIconRenderer} from "tc-shared/ui/react-elements/Icon"; +import {ConnectionHandler} from "tc-shared/ConnectionHandler"; +import * as contextmenu from "tc-shared/ui/elements/ContextMenu"; +import {copy_to_clipboard} from "tc-shared/utils/helpers"; +import {createInfoModal} from "tc-shared/ui/elements/Modal"; + +const cssStyle = require("./PermissionEditor.scss"); + +export interface EditorGroupedPermissions { + groupId: string, + groupName: string, + permissions: { + id: number, + name: string; + description: string; + }[], + children: EditorGroupedPermissions[] +} + +type PermissionEditorMode = "unset" | "no-permissions" | "normal"; +export interface PermissionEditorEvents { + action_set_mode: { mode: PermissionEditorMode, failedPermission?: string } + action_toggle_client_button: { visible: boolean }, + action_toggle_client_list: { visible: boolean }, + + action_set_filter: { filter?: string } + action_set_assigned_only: { value: boolean } + + action_set_default_value: { value: number }, + + action_open_icon_select: { iconId?: number } + action_set_senseless_permissions: { permissions: string[] } + + action_remove_permissions: { + permissions: { + name: string; + mode: "value" | "grant"; + }[] + } + action_remove_permissions_result: { + permissions: { + name: string; + mode: "value" | "grant"; + success: boolean; + }[] + } + + action_set_permissions: { + permissions: { + name: string; + + mode: "value" | "grant"; + + value?: number; + flagNegate?: boolean; + flagSkip?: boolean; + }[] + } + action_set_permissions_result: { + permissions: { + name: string; + + mode: "value" | "grant"; + + newValue?: number; /* undefined if it didnt worked */ + flagNegate?: boolean; + flagSkip?: boolean; + }[] + } + + action_toggle_group: { + groupId: string | null; /* if null, all groups are affected */ + collapsed: boolean; + } + + action_start_permission_edit: { + target: "value" | "grant"; + permission: string; + defaultValue: number; + }, + + action_add_permission_group: { + groupId: string, + mode: "value" | "grant"; + }, + action_remove_permission_group: { + groupId: string + mode: "value" | "grant"; + } + + query_permission_list: {}, + query_permission_list_result: { + hideSenselessPermissions: boolean; + permissions: EditorGroupedPermissions[] + }, + + query_permission_values: {}, + query_permission_values_result: { + status: "error" | "success"; /* no perms will cause a action_set_mode event with no permissions */ + + error?: string; + permissions?: { + name: string; + value?: number; + flagNegate?: boolean; + flagSkip?: boolean; + granted?: number; + }[] + } +} + +const ButtonIconPreview = (props: { events: Registry, connection: ConnectionHandler }) => { + const [ iconId, setIconId ] = useState(0); + const [ unset, setUnset ] = useState(true); + + props.events.reactUse("action_set_mode", event => setUnset(event.mode !== "normal")); + + props.events.reactUse("action_remove_permissions_result", event => { + const iconPermission = event.permissions.find(e => e.name === PermissionType.I_ICON_ID); + if(!iconPermission || !iconPermission.success) return; + + if(iconPermission.mode === "value") + setIconId(0); + }); + + props.events.reactUse("action_set_permissions_result", event => { + const iconPermission = event.permissions.find(e => e.name === PermissionType.I_ICON_ID); + if(!iconPermission) return; + + if(typeof iconPermission.newValue === "number") + setIconId(iconPermission.newValue); + }); + + props.events.reactUse("query_permission_values_result", event => { + if(event.status !== "success") { + setIconId(0); + return; + } + + const permission = event.permissions.find(e => e.name === PermissionType.I_ICON_ID); + if(!permission) { + setIconId(0); + return; + } + + if(typeof permission.value === "number") { + setIconId(permission.value >>> 0); + } else { + setIconId(0); + } + }); + + let icon; + if(!unset && iconId > 0) + icon = ; + + return ( +
+
props.events.fire("action_open_icon_select", { iconId: iconId })}> + { icon } +
+
+
+
+
+
+ {iconId ?
props.events.fire("action_open_icon_select", { iconId: iconId })}> + Edit icon +
: undefined} + {iconId ?
props.events.fire("action_remove_permissions", { permissions: [{ name: PermissionType.I_ICON_ID, mode: "value" }] })}> + Remove icon +
: undefined} + {!iconId ?
props.events.fire("action_open_icon_select", { iconId: 0 })}> + Add icon +
: undefined} +
+
+
+ ); +}; + +const ClientListButton = (props: { events: Registry }) => { + const [ visible, setVisible ] = useState(true); + const [ toggled, setToggled ] = useState(false); + + props.events.reactUse("action_toggle_client_button", event => setVisible(event.visible)); + props.events.reactUse("action_toggle_client_list", event => setToggled(event.visible)); + + return +}; + +const MenuBar = (props: { events: Registry, connection: ConnectionHandler }) => { + return
+ + Filter permissions} + labelType={"floating"} + labelClassName={cssStyle.label} + labelFloatingClassName={cssStyle.labelFloating} + onInput={text => props.events.fire("action_set_filter", { filter: text })} + /> +
+ Assigned only} onChange={state => props.events.fire("action_set_assigned_only", { value: state })} /> + { /* Editable only} /> */ } +
+ +
; +}; + +interface LinkedGroupedPermissions { + groupId: string; + groupName: string; + + depth: number; + parent: LinkedGroupedPermissions | undefined; + children: LinkedGroupedPermissions[]; + + permissions: { + id: number; + name: string; + description: string; + + elementVisible: boolean; + }[], + anyPermissionVisible: boolean; + + nextGroup: LinkedGroupedPermissions; + nextIfCollapsed: LinkedGroupedPermissions; + + collapsed: boolean; + + elementVisible: boolean; +} + +const PermissionEntryRow = (props: { + events: Registry, + groupId: string, + permission: string, + value: PermissionValue, + isOdd: boolean, + depth: number, + offsetTop: number, + defaultValue: number, + description: string +}) => { + const [ defaultValue, setDefaultValue ] = useState(props.defaultValue); + const [ value, setValue ] = useState(props.value.value); + const [ forceValueUpdate, setForceValueUpdate ] = useState(false); + const [ valueEditing, setValueEditing ] = useState(false); + const [ valueApplying, setValueApplying ] = useState(false); + + const [ flagNegated, setFlagNegated ] = useState(props.value.flagNegate); + const [ flagSkip, setFlagSkip ] = useState(props.value.flagSkip); + + const [ granted, setGranted ] = useState(props.value.granted); + const [ grantedEditing, setGrantedEditing ] = useState(false); + const [ grantedApplying, setGrantedApplying ] = useState(false); + + const refGranted = useRef(); + const refValueI = useRef(); + const refValueB = useRef(); + + const refSkip = useRef(); + const refNegate = useRef(); + + const isActive = typeof value === "number" || typeof granted === "number"; + const isBoolPermission = props.permission.startsWith("b_"); + + let valueElement, skipElement, negateElement, grantedElement; + if(typeof value === "number") { + if(isBoolPermission) { + valueElement = = 1} disabled={valueApplying} onChange={flag => { + props.events.fire("action_set_permissions", { permissions: [{ name: props.permission, mode: "value", value: flag ? 1 : 0, flagSkip: flagSkip, flagNegate: flagNegated }]}); + }} onBlur={() => setValueEditing(false)} />; + } else if(valueApplying) { + valueElement = {}} />; + } else { + valueElement = { + setValueEditing(false); + if(!refValueI.current) + return; + + const newValue = refValueI.current.value; + if(newValue === "") { + if(typeof value !== "number" && !forceValueUpdate) { + /* no change */ + return; + } + + setForceValueUpdate(false); + props.events.fire("action_remove_permissions", { permissions: [{ name: props.permission, mode: "value" }] }); + } else { + const numberValue = parseInt(newValue); + if(isNaN(numberValue)) return; + if(numberValue === value && !forceValueUpdate) { + /* no change */ + return; + } + + setForceValueUpdate(false); + props.events.fire("action_set_permissions", { permissions: [{ name: props.permission, mode: "value", value: numberValue, flagSkip: flagSkip, flagNegate: flagNegated }]}); + } + }} onChange={() => {}} onKeyPress={e => e.key === "Enter" && e.currentTarget.blur()} />; + } + + skipElement = { + props.events.fire("action_set_permissions", { permissions: [{ name: props.permission, mode: "value", value: value, flagSkip: flag, flagNegate: flagNegated }]}); + }} />; + negateElement = { + props.events.fire("action_set_permissions", { permissions: [{ name: props.permission, mode: "value", value: value, flagSkip: flagSkip, flagNegate: flag }]}); + }} />; + } + + if(typeof granted === "number") { + if(grantedApplying) { + grantedElement = {}} />; + } else { + grantedElement = { + setGrantedEditing(false); + if(!refGranted.current) + return; + + const newValue = refGranted.current.value; + if(newValue === "") { + if(typeof granted === "undefined") + return; + + props.events.fire("action_remove_permissions", { permissions: [{ name: props.permission, mode: "grant" }] }); + } else { + const numberValue = parseInt(newValue); + if(isNaN(numberValue)) return; + if(numberValue === granted) { + /* no change */ + return; + } + + props.events.fire("action_set_permissions", { permissions: [{ name: props.permission, mode: "grant", value: numberValue }]}); + } + }} onChange={() => {}} onKeyPress={e => e.key === "Enter" && e.currentTarget.blur()} />; + } + } + + props.events.reactUse("action_start_permission_edit", event => { + if(event.permission !== props.permission) + return; + + if(event.target === "grant") { + setGranted(event.defaultValue); + setGrantedEditing(true); + } else { + if(isBoolPermission && typeof value === "undefined") { + setValue(event.defaultValue >= 1 ? 1 : 0); + props.events.fire("action_set_permissions", { permissions: [{ name: props.permission, mode: "value", value: event.defaultValue >= 1 ? 1 : 0, flagSkip: flagSkip, flagNegate: flagNegated }]}); + } else { + setValue(event.defaultValue); + setForceValueUpdate(true); + setValueEditing(true); + } + } + }); + + props.events.reactUse("action_set_permissions", event => { + const values = event.permissions.find(e => e.name === props.permission); + if(!values) return; + + if(values.mode === "value") { + setValueApplying(true); + refSkip.current?.setState({ disabled: true }); + refNegate.current?.setState({ disabled: true }); + } else { + setGrantedApplying(true); + } + }); + + props.events.reactUse("action_set_permissions_result", event => { + const result = event.permissions.find(e => e.name === props.permission); + if(!result) return; + + if(result.mode === "value") { + setValueApplying(false); + if(typeof result.newValue === "number") { + setValue(result.newValue); + setFlagSkip(result.flagSkip); + setFlagSkip(result.flagNegate); + + refValueB.current?.setState({ disabled: false, checked: result.newValue >= 1 }); + refSkip.current?.setState({ disabled: false, checked: result.flagSkip }); + refNegate.current?.setState({ disabled: false, checked: result.flagNegate }); + refValueI.current && (refValueI.current.value = result.newValue.toString()); + + props.value.value = result.newValue; + props.value.flagSkip = result.flagSkip; + props.value.flagNegate = result.flagNegate; + } else { + refValueB.current?.setState({ disabled: false, checked: props.value.value >= 1 }); + refSkip.current?.setState({ disabled: false, checked: props.value.flagSkip }); + refNegate.current?.setState({ disabled: false, checked: props.value.flagNegate }); + refValueI.current && (refValueI.current.value = props.value.value.toString()); + + setValue(props.value.value); + setFlagSkip(props.value.flagSkip); + setFlagSkip(props.value.flagNegate); + } + } else { + setGrantedApplying(false); + if(typeof result.newValue === "number") { + setGranted(result.newValue); + refGranted.current && (refGranted.current.value = result.newValue.toString()); + } else { + setGranted(props.value.granted); + refGranted.current && (refGranted.current.value = props.value.granted.toString()); + } + } + }); + + props.events.reactUse("action_remove_permissions", event => { + const modes = event.permissions.find(e => e.name === props.permission); + if(!modes) return; + + if(modes.mode === "value") { + setValueApplying(true); + refValueB.current?.setState({ disabled: true }); + refSkip.current?.setState({ disabled: true }); + refNegate.current?.setState({ disabled: true }); + } + + if(modes.mode === "grant") + setGrantedApplying(true); + }); + + props.events.reactUse("action_remove_permissions_result", event => { + const modes = event.permissions.find(e => e.name === props.permission); + if(!modes) return; + + if(modes.mode === "value") { + modes.success && setValue(undefined); + setValueApplying(false); + setValueEditing(false); + + modes.success && setFlagSkip(false); + modes.success && setFlagNegated(false); + } + + if(modes.mode === "grant") { + modes.success && setGranted(undefined); + setGrantedEditing(false); + setGrantedApplying(false); + } + }); + + props.events.reactUse("action_set_default_value", event => setDefaultValue(event.value)); + + useEffect(() => { + if(grantedEditing) + refGranted.current?.focus(); + + if(valueEditing) { + refValueI.current?.focus(); + refValueB.current?.focus(); + } + }); + + return ( +
{ + if(e.isDefaultPrevented()) + return; + + props.events.fire("action_start_permission_edit", { permission: props.permission, target: "value", defaultValue: defaultValue }); + e.preventDefault(); + }} + onContextMenu={e => { + e.preventDefault(); + + let entries: contextmenu.MenuEntry[] = []; + if(typeof value === "undefined") { + entries.push({ + type: contextmenu.MenuEntryType.ENTRY, + name: tr("Add permission"), + callback: () => props.events.fire("action_start_permission_edit", { permission: props.permission, target: "value", defaultValue: defaultValue }) + }); + } else { + entries.push({ + type: contextmenu.MenuEntryType.ENTRY, + name: tr("Remove permission"), + callback: () => props.events.fire("action_remove_permissions", { permissions: [{ name: props.permission, mode: "value" }] }) + }); + } + + if(typeof granted === "undefined") { + entries.push({ + type: contextmenu.MenuEntryType.ENTRY, + name: tr("Add grant permission"), + callback: () => props.events.fire("action_start_permission_edit", { permission: props.permission, target: "grant", defaultValue: defaultValue }) + }); + } else { + entries.push({ + type: contextmenu.MenuEntryType.ENTRY, + name: tr("Remove grant permission"), + callback: () => props.events.fire("action_remove_permissions", { permissions: [{ name: props.permission, mode: "grant" }] }) + }); + } + + entries.push(contextmenu.Entry.HR()); + entries.push({ + type: contextmenu.MenuEntryType.ENTRY, + name: tr("Collapse group"), + callback: () => props.events.fire("action_toggle_group", { groupId: props.groupId, collapsed: true }) + }); + entries.push({ + type: contextmenu.MenuEntryType.ENTRY, + name: tr("Expend all"), + callback: () => props.events.fire("action_toggle_group", { groupId: null, collapsed: false }) + }); + entries.push({ + type: contextmenu.MenuEntryType.ENTRY, + name: tr("Collapse all"), + callback: () => props.events.fire("action_toggle_group", { groupId: null, collapsed: true }) + }); + entries.push(contextmenu.Entry.HR()); + entries.push({ + type: contextmenu.MenuEntryType.ENTRY, + name: tr("Show permission description"), + callback: () => { + createInfoModal( + tr("Permission description"), + tr("Permission description for permission ") + props.permission + ":
" + props.description + ).open(); + } + }); + entries.push({ + type: contextmenu.MenuEntryType.ENTRY, + name: tr("Copy permission name"), + callback: () => copy_to_clipboard(props.permission) + }); + + contextmenu.spawn_context_menu(e.pageX, e.pageY, ...entries); + }} + > +
+ {props.permission} +
+
{valueElement}
+
{skipElement}
+
{negateElement}
+
{ + props.events.fire("action_start_permission_edit", { permission: props.permission, target: "grant", defaultValue: defaultValue }); + e.preventDefault(); + }}>{grantedElement}
+
+ ); +}; + +const PermissionGroupRow = (props: { events: Registry, group: LinkedGroupedPermissions, isOdd: boolean, offsetTop: number }) => { + const [ collapsed, setCollapsed ] = useState(props.group.collapsed); + + props.events.reactUse("action_toggle_group", event => { + if(event.groupId !== null && event.groupId !== props.group.groupId) + return; + + setCollapsed(event.collapsed); + }); + + return ( +
{ + e.preventDefault(); + + let entries = []; + entries.push({ + type: contextmenu.MenuEntryType.ENTRY, + name: tr("Add permissions to this group"), + callback: () => props.events.fire("action_add_permission_group", { groupId: props.group.groupId, mode: "value" }) + }); + entries.push({ + type: contextmenu.MenuEntryType.ENTRY, + name: tr("Remove permissions from this group"), + callback: () => props.events.fire("action_remove_permission_group", { groupId: props.group.groupId, mode: "value" }) + }); + entries.push({ + type: contextmenu.MenuEntryType.ENTRY, + name: tr("Add granted permissions to this group"), + callback: () => props.events.fire("action_add_permission_group", { groupId: props.group.groupId, mode: "grant" }) + }); + entries.push({ + type: contextmenu.MenuEntryType.ENTRY, + name: tr("Remove granted permissions from this group"), + callback: () => props.events.fire("action_remove_permission_group", { groupId: props.group.groupId, mode: "grant" }) + }); + entries.push(contextmenu.Entry.HR()); + if(collapsed) { + entries.push({ + type: contextmenu.MenuEntryType.ENTRY, + name: tr("Expend group"), + callback: () => props.events.fire("action_toggle_group", { groupId: props.group.groupId, collapsed: false }) + }); + } else { + entries.push({ + type: contextmenu.MenuEntryType.ENTRY, + name: tr("Collapse group"), + callback: () => props.events.fire("action_toggle_group", { groupId: props.group.groupId, collapsed: true }) + }); + } + entries.push({ + type: contextmenu.MenuEntryType.ENTRY, + name: tr("Expend all"), + callback: () => props.events.fire("action_toggle_group", { groupId: null, collapsed: false }) + }); + entries.push({ + type: contextmenu.MenuEntryType.ENTRY, + name: tr("Collapse all"), + callback: () => props.events.fire("action_toggle_group", { groupId: null, collapsed: true }) + }); + contextmenu.spawn_context_menu(e.pageX, e.pageY, ...entries); + }} + onDoubleClick={() => props.events.fire("action_toggle_group", { collapsed: !collapsed, groupId: props.group.groupId })} + > +
+
props.events.fire("action_toggle_group", { collapsed: !collapsed, groupId: props.group.groupId })} /> +
{props.group.groupName}
+
+
+
+
+
+
+ ); +}; + +type PermissionValue = { value?: number, flagNegate?: boolean, flagSkip?: boolean, granted?: number }; + +@ReactEventHandler(e => e.props.events) +class PermissionList extends React.Component<{ events: Registry }, { state: "loading" | "normal" | "error", viewHeight: number, scrollOffset: number, error?: string }> { + private readonly refContainer = React.createRef(); + private resizeObserver: ResizeObserver; + + private permissionsHead: LinkedGroupedPermissions; + private permissionByGroupId: {[key: string]: LinkedGroupedPermissions} = {}; + private permissionValuesByName: {[key: string]: PermissionValue} = {}; + + private hideSenselessPermissions = true; + private senselessPermissions: string[] = []; + + private currentListElements: React.ReactElement[] = []; + private heightPerElement = 28; /* default font size 28px */ + private heightPerElementInitialized = false; + + private filterText: string | undefined; + private filterAssignedOnly: boolean = false; + + private loadingPermissionList = true; + private loadingPermissionValues = false; + + private defaultPermissionValue = 1; + + constructor(props) { + super(props); + + this.state = { + viewHeight: 0, + scrollOffset: 0, + state: "loading" + } + } + + render() { + const view = this.visibleEntries(); + let elements = this.state.state === "normal" ? this.currentListElements.slice(Math.max(0, view.begin - 5), Math.min(view.end + 5, this.currentListElements.length)) : []; + + return ( +
this.state.state === "normal" && this.setState({ scrollOffset: this.refContainer.current.scrollTop })} onContextMenu={e => { + if(e.isDefaultPrevented()) + return; + + e.preventDefault(); + contextmenu.spawn_context_menu(e.pageX, e.pageY, { + type: contextmenu.MenuEntryType.ENTRY, + name: tr("Expend all"), + callback: () => this.props.events.fire("action_toggle_group", { groupId: null, collapsed: false }) + }, { + type: contextmenu.MenuEntryType.ENTRY, + name: tr("Collapse all"), + callback: () => this.props.events.fire("action_toggle_group", { groupId: null, collapsed: true }) + }); + }}> + {elements} + + ) + } + + private visibleEntries() { + let view_entry_count = Math.ceil(this.state.viewHeight / this.heightPerElement); + const view_entry_begin = Math.floor(this.state.scrollOffset / this.heightPerElement); + const view_entry_end = Math.min(this.currentListElements.length, view_entry_begin + view_entry_count); + + return { + begin: view_entry_begin, + end: view_entry_end + } + } + + componentDidMount(): void { + this.resizeObserver = new ResizeObserver(entries => { + if(entries.length !== 1) { + if(entries.length === 0) + log.warn(LogCategory.PERMISSIONS, tr("Permission editor resize observer fired resize event with no entries!")); + else + log.warn(LogCategory.PERMISSIONS, tr("Permission editor resize observer fired resize event with more than one entry which should not be possible (%d)!"), entries.length); + return; + } + const bounds = entries[0].contentRect; + if(this.state.viewHeight !== bounds.height) { + log.debug(LogCategory.PERMISSIONS, tr("Handling height update and change permission view height to %d from %d"), bounds.height, this.state.viewHeight); + this.setState({ + viewHeight: bounds.height + }); + } + }); + this.resizeObserver.observe(this.refContainer.current); + + this.props.events.fire("query_permission_list"); + } + + componentWillUnmount(): void { + this.resizeObserver.disconnect(); + this.resizeObserver = undefined; + } + + private initializeElementHeight() { + if(this.heightPerElementInitialized) + return; + + requestAnimationFrame(() => { + const firstElement = this.refContainer.current?.firstElementChild; + /* the first element might be the space allocator in cases without any shown row elements */ + if(firstElement && firstElement.classList.contains(cssStyle.row)) { + this.heightPerElementInitialized = true; + const rect = firstElement.getBoundingClientRect(); + if(this.heightPerElement !== rect.height) { + this.heightPerElement = rect.height; + this.updateReactComponents(); + } + } + }); + } + + componentDidUpdate(prevProps: Readonly<{ events: Registry }>, prevState: Readonly<{ state: "loading" | "normal" | "error", viewHeight: number, scrollOffset: number, error?: string }>, snapshot?: any): void { + if(prevState.state !== "normal" && this.state.state === "normal") + requestAnimationFrame(() => this.refContainer.current.scrollTop = this.state.scrollOffset); + if(this.state.state === "normal") + this.initializeElementHeight(); + } + + @EventHandler("query_permission_list") + private handlePermissionListQuery() { + this.loadingPermissionList = true; + this.setState({ state: "loading" }); + } + + @EventHandler("query_permission_list_result") + private handlePermissionList(event: PermissionEditorEvents["query_permission_list_result"]) { + this.loadingPermissionList = false; + + this.hideSenselessPermissions = event.hideSenselessPermissions; + const visitGroup = (group: EditorGroupedPermissions, parent: LinkedGroupedPermissions, depth: number): LinkedGroupedPermissions => { + const result: LinkedGroupedPermissions = { + groupName: group.groupName, + groupId: group.groupId, + + collapsed: false, + + permissions: group.permissions.map(e => { + return { + name: e.name, + id: e.id, + description: e.description, + + elementVisible: true + } + }), + anyPermissionVisible: true, + + depth: depth, + parent: parent, + children: [], + + nextGroup: undefined, + nextIfCollapsed: undefined, /* will be set later */ + + elementVisible: true, + }; + + if(group.children && group.children.length > 0) { + result.nextGroup = visitGroup(group.children[0], result, depth + 1); + result.children.push(result.nextGroup); + + let currentHead = result.nextGroup; + for(let index = 1; index < group.children.length; index++) { + currentHead.nextIfCollapsed = visitGroup(group.children[index], result, depth + 1); + currentHead = currentHead.nextIfCollapsed; + result.children.push(currentHead); + } + } + + return result; + }; + + this.permissionsHead = visitGroup(event.permissions[0], undefined, 0); + let currentHead = this.permissionsHead; + for(let index = 1; index < event.permissions.length; index++) { + currentHead.nextIfCollapsed = visitGroup(event.permissions[index], undefined, 0); + currentHead = currentHead.nextIfCollapsed; + } + + /* fixup the next group linkage */ + currentHead = this.permissionsHead; + while(currentHead) { + if(!currentHead.nextIfCollapsed && currentHead.parent) + currentHead.nextIfCollapsed = currentHead.parent.nextIfCollapsed; + + if(!currentHead.nextGroup) + currentHead.nextGroup = currentHead.nextIfCollapsed; + + currentHead = currentHead.nextGroup; + } + + /* build up the group key index */ + this.permissionByGroupId = {}; + currentHead = this.permissionsHead; + while(currentHead) { + this.permissionByGroupId[currentHead.groupId] = currentHead; + currentHead = currentHead.nextGroup; + } + + this.setState({ state: this.loadingPermissionList || this.loadingPermissionValues ? "loading" : this.state.error ? "error" : "normal" }); + this.updateReactComponents(); + } + + @EventHandler("action_set_senseless_permissions") + private handleSenselessPermissions(event: PermissionEditorEvents["action_set_senseless_permissions"]) { + this.senselessPermissions = event.permissions.slice(0); + this.updateReactComponents(); + } + + @EventHandler("query_permission_values") + private handleRequestPermissionValues() { + this.loadingPermissionValues = true; + this.setState({ state: "loading" }); + } + + @EventHandler("query_permission_values_result") + private handleRequestPermissionValuesResult(event: PermissionEditorEvents["query_permission_values_result"]) { + this.loadingPermissionValues = false; + this.permissionValuesByName = {}; + + Object.values(this.permissionValuesByName).forEach(e => { e.value = undefined; e.granted = undefined; }); + (event.permissions || []).forEach(permission => Object.assign(this.permissionValuesByName[permission.name] || (this.permissionValuesByName[permission.name] = {}), { + value: permission.value, + granted: permission.granted, + flagNegate: permission.flagNegate, + flagSkip: permission.flagSkip + })); + + this.setState({ + state: this.loadingPermissionList || this.loadingPermissionValues ? "loading" : event.status !== "success" ? "error" : "normal" , + error: event.error + }); + + if(event.status === "success") + this.updateReactComponents(); + } + + @EventHandler("action_set_permissions_result") + private handlePermissionSetResult(event: PermissionEditorEvents["action_set_permissions_result"]) { + event.permissions.forEach(e => { + if(typeof e.newValue !== "number") + return; + + const values = this.permissionValuesByName[e.name] || (this.permissionValuesByName[e.name] = {}); + if(e.mode === "value") { + values.value = e.newValue; + values.flagSkip = e.flagSkip; + values.flagNegate = e.flagNegate; + } else { + values.granted = e.newValue; + } + }); + } + + @EventHandler("action_remove_permissions_result") + private handlePermissionRemoveResult(event: PermissionEditorEvents["action_remove_permissions_result"]) { + event.permissions.forEach(e => { + if(!e.success) + return; + + const values = this.permissionValuesByName[e.name] || (this.permissionValuesByName[e.name] = {}); + if(e.mode === "value") { + values.value = undefined; + values.flagSkip = false; + values.flagNegate = false; + } else { + values.granted = undefined; + } + }); + } + + @EventHandler("action_toggle_group") + private handleToggleGroup(event: PermissionEditorEvents["action_toggle_group"]) { + if(event.groupId === null) { + Object.values(this.permissionByGroupId).forEach(e => e.collapsed = event.collapsed); + } else { + const group = this.permissionByGroupId[event.groupId]; + if(!group) { + console.warn(tr("Received group toogle for unknwon group: %s"), event.groupId); + return; + } + + if(group.collapsed === event.collapsed) + return; + + group.collapsed = event.collapsed; + } + this.updateReactComponents(); + } + + @EventHandler("action_set_filter") + private handleSetFilter(event: PermissionEditorEvents["action_set_filter"]) { + if(this.filterText === event.filter) + return; + + this.filterText = event.filter; + this.updateReactComponents(); + } + + @EventHandler("action_set_assigned_only") + private handleSetAssignedFilter(event: PermissionEditorEvents["action_set_assigned_only"]) { + if(this.filterAssignedOnly === event.value) + return; + + this.filterAssignedOnly = event.value; + this.updateReactComponents(); + } + + @EventHandler("action_set_default_value") + private handleSetDefaultPermissionValue(event: PermissionEditorEvents["action_set_default_value"]) { + this.defaultPermissionValue = event.value; + } + + @EventHandler("action_add_permission_group") + private handleEnablePermissionGroup(event: PermissionEditorEvents["action_add_permission_group"]) { + const group = this.permissionByGroupId[event.groupId]; + if(!group) return; + + const permissions: { id: number, name: string, elementVisible: boolean }[] = []; + const visitGroup = (group: LinkedGroupedPermissions) => { + permissions.push(...group.permissions); + group.children.forEach(visitGroup); + }; + visitGroup(group); + + this.props.events.fire("action_set_permissions", { + permissions: permissions.map(e => { + return { + name: e.name, + mode: event.mode as "value" | "grant", + + value: e.name.startsWith("b_") && event.mode === "value" ? 1 : this.defaultPermissionValue, + flagNegate: false, + flagSkip: false + } + }) + }); + } + + @EventHandler("action_remove_permission_group") + private handleDisablePermissionGroup(event: PermissionEditorEvents["action_remove_permission_group"]) { + const group = this.permissionByGroupId[event.groupId]; + if(!group) return; + + const permissions: { id: number, name: string, elementVisible: boolean }[] = []; + const visitGroup = (group: LinkedGroupedPermissions) => { + permissions.push(...group.permissions); + group.children.forEach(visitGroup); + }; + visitGroup(group); + + this.props.events.fire("action_remove_permissions", { + permissions: permissions.map(e => { + return { + name: e.name, + mode: event.mode as "value" | "grant", + } + }) + }); + } + + private updateReactComponents() { + let currentGroup = this.permissionsHead; + let visibleGroups: LinkedGroupedPermissions[] = []; + while(currentGroup) { + visibleGroups.push(currentGroup); + if(currentGroup.collapsed) { + currentGroup = currentGroup.nextIfCollapsed; + continue; + } + + currentGroup.anyPermissionVisible = false; + for(const permission of currentGroup.permissions) { + permission.elementVisible = false; + if(this.filterText && permission.name.indexOf(this.filterText) === -1) + continue; + + if(this.hideSenselessPermissions && this.senselessPermissions.findIndex(e => e === permission.name) !== -1) + continue; + + const permissionValue = this.permissionValuesByName[permission.name] || (this.permissionValuesByName[permission.name] = {}); + if(this.filterAssignedOnly) { + if(typeof permissionValue.value !== "number" && typeof permissionValue.granted !== "number") { + continue; + } + } + + permission.elementVisible = true; + currentGroup.anyPermissionVisible = true; + } + + currentGroup = currentGroup.nextGroup; + } + + /* update the visibility from the bottom to the top */ + visibleGroups.sort((a, b) => b.depth - a.depth); + visibleGroups.forEach(e => { + for(const child of e.children) { + if(child.elementVisible) { + e.elementVisible = true; + return; + } + } + + e.elementVisible = e.anyPermissionVisible; + }); + + /* lets build up the final list view */ + this.currentListElements = []; + let index = 0; + currentGroup = this.permissionsHead; + while(currentGroup) { + if(currentGroup.elementVisible) { + this.currentListElements.push(); + index++; + } + + if(currentGroup.collapsed) { + currentGroup = currentGroup.nextIfCollapsed; + continue; + } else if(!currentGroup.elementVisible) { + currentGroup = currentGroup.nextGroup; + continue; + } + + currentGroup.permissions.forEach(e => { + if(!e.elementVisible) + return; + + this.currentListElements.push(); + index++; + }); + + currentGroup = currentGroup.nextGroup; + } + + this.forceUpdate(); + } +} + +const PermissionTable = (props: { events: Registry }) => { + const [ mode, setMode ] = useState("unset"); + const [ failedPermission, setFailedPermission ] = useState(undefined); + + props.events.reactUse("action_set_mode", event => { setMode(event.mode); setFailedPermission(event.failedPermission); }); + + return ( +
+
+
+ +
+ Value +
+
+ Skip +
+
+ Negate +
+
+ Granted +
+
+
+ + + ); +}; + +const RefreshButton = (props: { events: Registry }) => { + const [ unset, setUnset ] = useState(true); + const [ nextTime, setNextTime ] = useState(0); + const refButton = useRef +}; + +interface PermissionEditorProperties { + connection: ConnectionHandler; + events: Registry; +} + +interface PermissionEditorState { + state: "no-permissions" | "unset" | "normal"; +} + +export class PermissionEditor extends React.Component { + render() { + return [ + , + , +
+ +
+ ] + } + + componentDidMount(): void { + this.props.events.fire("action_set_mode", { mode: "unset" }); + } +} \ No newline at end of file diff --git a/shared/js/ui/modal/permissionv2/TabHandler.scss b/shared/js/ui/modal/permissionv2/TabHandler.scss new file mode 100644 index 00000000..8692e94a --- /dev/null +++ b/shared/js/ui/modal/permissionv2/TabHandler.scss @@ -0,0 +1,312 @@ +@import "../../../../css/static/mixin"; +@import "../../../../css/static/properties"; + +.containerList { + color: var(--text); + + position: relative; + + display: flex; + flex-direction: column; + justify-content: flex-start; + + overflow: auto; + @include chat-scrollbar-vertical(); + @include chat-scrollbar-horizontal(); + + width: 100%; + + flex-grow: 1; + flex-shrink: 1; + + .entries { + display: flex; + flex-direction: column; + justify-content: flex-start; + + height: max-content; + + min-width: 100%; + width: max-content; + + .entry { + padding-left: .25em; + + flex-shrink: 0; + flex-grow: 0; + + display: flex; + flex-direction: row; + justify-content: flex-start; + + cursor: pointer; + + width: 100%; + + &:hover { + background-color: var(--modal-permissions-entry-hover); + } + + &.selected { + background-color: var(--modal-permissions-entry-selected); + } + + > * { + align-self: center; + } + + @include transition(background-color .25s ease-in-out); + } + } +} + +.sideContainer { + height: 0; /* will expend due to flex grow */ + width: 100%; + + flex-grow: 1; + + &.hidden { + display: none; + } +} + +.containerServerGroups, .containerChannelGroups { + position: relative; + overflow: hidden; + + display: flex; + flex-direction: column; + justify-content: stretch; + + .list { + flex-grow: 1; + flex-shrink: 1; + + min-height: 2em; + + .name { + margin-left: 0.25em; + } + + :global(.icon_em) { + font-size: 16px; + } + } + + .buttons { + position: relative; + + display: flex; + flex-direction: row; + justify-content: stretch; + + flex-grow: 0; + flex-shrink: 0; + + height: 2.5em; + width: 100%; + + .button { + display: flex; + flex-direction: row; + justify-content: space-around; + + flex-grow: 1; + flex-shrink: 1; + + cursor: pointer; + + background-color: var(--modal-permissions-buttons-background); + + &:hover { + background-color: var(--modal-permissions-buttons-hover); + } + + &.disabled { + background-color: var(--modal-permissions-buttons-disabled); + } + + @include transition(background-color .25s ease-in-out); + + img { + width: 2.2em; + height: 2.2em; + + align-self: center; + } + } + } + + $animation_length: .3s; + .containerGroupList { + flex-grow: 1; + flex-shrink: 1; + + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + + display: flex; + flex-direction: column; + justify-content: stretch; + + &.hidden { + @include transform(translateX(-100%)); + } + @include transition($animation_length ease-in-out); + } + + .containerClientList { + flex-grow: 1; + flex-shrink: 1; + + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + + display: flex; + flex-direction: column; + justify-content: stretch; + + .selectedGroup { + flex-shrink: 0; + flex-grow: 0; + + z-index: 2; + + display: flex; + flex-direction: row; + justify-content: stretch; + + background-color: var(--modal-permissions-current-group); + color: var(--text); + padding-left: .25em; + + height: 1.5em; + font-size: 1.125em; + + .icon { + display: flex; + flex-direction: column; + justify-content: space-around; + + height: 100%; + margin-right: .25em; + } + + .name { + flex-grow: 1; + flex-shrink: 1; + + height: 1.5em; + } + } + + .overlay { + z-index: 1; + + position: absolute; + + display: flex; + flex-direction: column; + justify-content: flex-start; + + padding-top: 2em; + + top: 0; + left: 0; + right: 0; + bottom: 0; + + color: #666666; + background-color: var(--modal-permission-left); + + a { + text-align: center; + font-size: 1.2em; + align-self: center; + } + + &.error { + color: #6b6161; + } + + &.hidden { + display: none; + } + } + + &.hidden { + @include transform(translateX(100%)); + } + @include transition($animation_length ease-in-out); + } +} + +.containerChannels { + position: relative; + overflow: hidden; + + display: flex; + flex-direction: column; + justify-content: stretch; +} + +.containerClient { + overflow: hidden; + + display: flex; + flex-direction: column; + justify-content: flex-start; +} + +.containerChannelClient { + position: relative; + overflow: hidden; + + display: flex; + flex-direction: column; + justify-content: stretch; +} + +.containerChannels, .containerClient, .containerChannelClient { + .listChannels { + flex-grow: 1; + flex-shrink: 1; + + min-height: 2em; + + .name { + margin-left: 0.25em; + } + } + + .clientSelect { + flex-grow: 0; + flex-shrink: 0; + + padding: .25em; + + hr { + border: none; + border-top: 2px solid var(--modal-permissions-seperator); + + margin-left: -.25em; + margin-right: -.25em; + } + + .inputField { + margin-top: -.75em; + } + + .infoField { + margin-bottom: .25em; + margin-top: -.5em; + } + } +} \ No newline at end of file diff --git a/shared/js/ui/modal/permissionv2/TabHandler.tsx b/shared/js/ui/modal/permissionv2/TabHandler.tsx new file mode 100644 index 00000000..577e29cb --- /dev/null +++ b/shared/js/ui/modal/permissionv2/TabHandler.tsx @@ -0,0 +1,1050 @@ +import * as React from "react"; +import {useRef, useState} from "react"; +import {EventHandler, ReactEventHandler, Registry} from "tc-shared/events"; +import { + ChannelInfo, + GroupProperties, + PermissionModalEvents +} from "tc-shared/ui/modal/permissionv2/ModalPermissionEditor"; +import {PermissionEditorEvents} from "tc-shared/ui/modal/permissionv2/PermissionEditor"; +import {ConnectionHandler} from "tc-shared/ConnectionHandler"; +import {LocalIconRenderer} from "tc-shared/ui/react-elements/Icon"; +import {createInputModal} from "tc-shared/ui/elements/Modal"; +import {Translatable} from "tc-shared/ui/react-elements/i18n"; +import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots"; +import * as contextmenu from "tc-shared/ui/elements/ContextMenu"; +import {MenuEntryType, spawn_context_menu} from "tc-shared/ui/elements/ContextMenu"; +import {copy_to_clipboard} from "tc-shared/utils/helpers"; +import {FlatInputField} from "tc-shared/ui/react-elements/InputField"; +import {arrayBufferBase64} from "tc-shared/utils/buffers"; +import {tra} from "tc-shared/i18n/localize"; + +const cssStyle = require("./TabHandler.scss"); + +export class SideBar extends React.Component<{ connection: ConnectionHandler, modalEvents: Registry, editorEvents: Registry }, {}> { + render() { + return [ + , + , + , + , + + ]; + } +} + +const GroupsButton = (props: { image: string, alt: string, onClick?: () => void, disabled: boolean }) => { + return ( +
!props.disabled && props.onClick()} > + {props.alt} +
+ ); +}; + + +const GroupsListEntry = (props: { connection: ConnectionHandler, group: GroupProperties, selected: boolean, callbackSelect: () => void, onContextMenu: (event: React.MouseEvent) => void }) => { + let groupTypePrefix = ""; + switch (props.group.type) { + case "query": + groupTypePrefix = "[Q] "; + break; + + case "template": + groupTypePrefix = "[T] "; + break; + } + return ( +
+ +
{ groupTypePrefix + props.group.name + " (" + props.group.id + ")" }
+
+ ) +}; + +@ReactEventHandler(e => e.props.events) +class GroupsList extends React.Component<{ connection: ConnectionHandler, events: Registry, target: "server" | "channel" }, { + selectedGroupId: number, + showQueryGroups: boolean, + showTemplateGroups: boolean, + + disableGroupAdd: boolean, + disableGroupRename: boolean, + disablePermissionCopy: boolean, + disableDelete: boolean +}> { + private readonly groups: GroupProperties[] = []; + private visibleGroups: GroupProperties[] = []; + + private updateScheduleId; + private modifyPower: number; + private isListActive: boolean = false; + + constructor(props) { + super(props); + + this.modifyPower = 0; + this.state = { + selectedGroupId: -1, + showQueryGroups: false, + showTemplateGroups: false, + + disableDelete: true, + disablePermissionCopy: true, + disableGroupAdd: true, + disableGroupRename: true + } + } + + render() { + this.updateGroups(false); + + return [ +
{ + if(event.isDefaultPrevented()) + return; + + event.preventDefault(); + spawn_context_menu(event.pageX, event.pageY, { + name: tr("Add group"), + icon_class: "client-add", + type: MenuEntryType.ENTRY, + callback: () => this.props.events.fire("action_create_group", { target: this.props.target, sourceGroup: 0 }), + invalidPermission: this.state.disableGroupAdd + }); + }}> +
+ {this.visibleGroups.map(e => this.props.events.fire("action_select_group", { id: e.id, target: this.props.target })} + onContextMenu={event => { + event.preventDefault(); + this.props.events.fire("action_select_group", { target: this.props.target, id: e.id }); + spawn_context_menu(event.pageX, event.pageY, { + name: tr("Rename group"), + type: MenuEntryType.ENTRY, + callback: () => this.onGroupRename(), + icon_class: "client-change_nickname", + invalidPermission: this.state.disableGroupRename + }, { + name: tr("Copy permissions"), + type: MenuEntryType.ENTRY, + icon_class: "client-copy", + callback: () => this.props.events.fire("action_group_copy_permissions", { target: this.props.target, sourceGroup: e.id }), + invalidPermission: this.state.disablePermissionCopy + }, { + name: tr("Delete group"), + type: MenuEntryType.ENTRY, + icon_class: "client-delete", + callback: () => this.onGroupDelete(), + invalidPermission: this.state.disableDelete + }, contextmenu.Entry.HR(), { + name: tr("Add group"), + icon_class: "client-add", + type: MenuEntryType.ENTRY, + callback: () => this.props.events.fire("action_create_group", { target: this.props.target, sourceGroup: e.id }), + invalidPermission: this.state.disableGroupAdd + }); + }} + />)} +
+
, +
+ this.props.events.fire("action_create_group", { target: this.props.target, sourceGroup: this.state.selectedGroupId })} + /> + this.onGroupRename()} + disabled={this.state.disableGroupRename} + /> + () => this.props.events.fire("action_group_copy_permissions", { target: this.props.target, sourceGroup: this.state.selectedGroupId })} + /> + this.onGroupDelete()} + /> +
+ ]; + } + + private updateGroups(updateSelectedGroup: boolean) { + /* sort groups */ + { + const typeMappings = { + "query": 3, + "template": 2, + "normal": 1 + }; + + this.groups.sort((b, a) => { + if(typeMappings[a.type] > typeMappings[b.type]) + return 1; + + if(typeMappings[a.type] < typeMappings[b.type]) + return -1; + + if(a.sortId < b.sortId) + return 1; + + if(a.sortId > b.sortId) + return -1; + + if(a.id > b.id) + return -1; + + if(a.id < b.id) + return 1; + + return 0; + }); + } + this.visibleGroups = this.groups.filter(e => e.type === "template" ? this.state.showTemplateGroups : e.type === "query" ? this.state.showQueryGroups : true); + + /* select any group */ + if(updateSelectedGroup && this.visibleGroups.findIndex(e => e.id === this.state.selectedGroupId) === -1 && this.visibleGroups.length !== 0) + this.props.events.fire("action_select_group", { target: this.props.target, id: this.visibleGroups[0].id }); + } + + private scheduleUpdate() { + clearTimeout(this.updateScheduleId); + this.updateScheduleId = setTimeout(() => this.forceUpdate(), 10); + } + + private selectedGroup() { + return this.groups.find(e => e.id === this.state.selectedGroupId); + } + + @EventHandler("action_activate_tab") + private handleGroupTabActive(events: PermissionModalEvents["action_activate_tab"]) { + this.isListActive = this.props.target === "server" ? events.tab === "groups-server" : events.tab === "groups-channel"; + if(events.tab === "groups-server" || events.tab === "groups-channel") { + if(typeof events.activeGroupId !== "undefined") + this.setState({ selectedGroupId: events.activeGroupId }); + + if(this.isListActive) + this.props.events.fire("action_set_permission_editor_subject", { mode: events.tab, groupId: events.activeGroupId || this.state.selectedGroupId, clientDatabaseId: 0, channelId: 0 }); + } + } + + @EventHandler("notify_groups_reset") + private handleReset() { + this.groups.splice(0, this.groups.length); + } + + @EventHandler("notify_client_permissions") + private handleClientPermissions(event: PermissionModalEvents["notify_client_permissions"]) { + const selectedGroup = this.selectedGroup(); + + this.modifyPower = this.props.target === "server" ? event.serverGroupModifyPower : event.channelGroupModifyPower; + this.setState({ + showTemplateGroups: event.modifyTemplateGroups, + showQueryGroups: event.modifyQueryGroups, + + disableGroupAdd: this.props.target === "server" ? !event.serverGroupCreate : !event.channelGroupCreate, + disablePermissionCopy: this.props.target === "server" ? !event.serverGroupCreate : !event.channelGroupCreate, + + disableGroupRename: !selectedGroup || this.modifyPower === 0 || this.modifyPower < selectedGroup.needed_modify_power, + disableDelete: !selectedGroup || this.modifyPower === 0 || this.modifyPower < selectedGroup.needed_modify_power, + }); + /* this.forceUpdate(); */ /* No force update needed since if the state does not change the displayed groups would not either */ + } + + @EventHandler("action_select_group") + private handleSelect(event: PermissionModalEvents["action_select_group"]) { + if(event.target !== this.props.target) + return; + + if(this.state.selectedGroupId === event.id) + return; + + const selectedGroup = this.groups.find(e => e.id === event.id); + this.setState({ + selectedGroupId: event.id, + + disableGroupRename: !selectedGroup || this.modifyPower === 0 || this.modifyPower < selectedGroup.needed_modify_power, + disableDelete: !selectedGroup || this.modifyPower === 0 || this.modifyPower < selectedGroup.needed_modify_power, + }); + + if(this.isListActive) { + this.props.events.fire("action_set_permission_editor_subject", { + mode: this.props.target === "server" ? "groups-server" : "groups-channel", + groupId: event.id, + clientDatabaseId: 0, + channelId: 0 + }); + } + } + + @EventHandler("query_groups") + private handleQuery(event: PermissionModalEvents["query_groups"]) { + if(event.target !== this.props.target) + return; + + this.groups.splice(0, this.groups.length); + } + + @EventHandler("query_groups_result") + private handleQueryResult(event: PermissionModalEvents["query_groups_result"]) { + if(event.target !== this.props.target) + return; + + this.groups.splice(0, this.groups.length); + this.groups.push(...event.groups); + this.updateGroups(true); + this.scheduleUpdate(); + } + + @EventHandler("notify_groups_created") + private handleGroupsCreated(event: PermissionModalEvents["notify_groups_created"]) { + if(event.target !== this.props.target) + return; + + this.groups.push(...event.groups); + this.updateGroups(true); + this.scheduleUpdate(); + } + + @EventHandler("notify_groups_deleted") + private handleGroupsDeleted(event: PermissionModalEvents["notify_groups_deleted"]) { + if(event.target !== this.props.target) + return; + + event.groups.forEach(id => { + const index = this.groups.findIndex(e => e.id === id); + if(index === -1) return; + + this.groups.splice(index, 1); + }); + + this.updateGroups(true); + this.scheduleUpdate(); + } + + @EventHandler("notify_group_updated") + private handleGroupUpdated(event: PermissionModalEvents["notify_group_updated"]) { + if(event.target !== this.props.target) + return; + + const group = this.groups.find(e => e.id === event.id); + if(!group) return; + + for(const update of event.properties) { + switch (update.property) { + case "name": + group.name = update.value; + break; + + case "icon": + group.iconId = update.value; + break; + + case "sort": + group.sortId = update.value; + break; + + case "save": + group.saveDB = update.value; + break; + } + } + + this.updateGroups(true); + this.scheduleUpdate(); + } + + private onGroupRename() { + const group = this.selectedGroup(); + if(!group) return; + + createInputModal(tr("Rename group"), tr("Enter the new group name"), name => { + if(name.length < 3) + return false; + + if(name.length > 64) + return false; + + return this.groups.findIndex(e => e.name === name && e.type === group.type) === -1; + }, result => { + if(typeof result !== "string") + return; + + this.props.events.fire("action_rename_group", { id: group.id, target: this.props.target, newName: result }); + }).open(); + } + + private onGroupDelete() { + const group = this.selectedGroup(); + if(!group) return; + + this.props.events.fire("action_delete_group", { id: group.id, target: this.props.target, mode: "ask" }); + } + + componentDidMount(): void { + this.props.events.fire("query_groups", { target: this.props.target }); + } +} + + +@ReactEventHandler(e => e.props.events) +class ServerClientList extends React.Component<{ connection: ConnectionHandler, events: Registry }, { + selectedGroupId: number, + selectedClientId: number, + + disableClientAdd: boolean, + disableClientRemove: boolean, + + state: "loading" | "error" | "normal" | "no-permissions", + error?: string; +}> { + private readonly groups: GroupProperties[] = []; + private clients: { + name: string; + databaseId: number; + uniqueId: string; + }[] = []; + private clientMemberAddPower: number = 0; + private clientMemberRemovePower: number = 0; + + constructor(props) { + super(props); + + this.state = { + selectedGroupId: 0, + selectedClientId: 0, + + disableClientAdd: true, + disableClientRemove: true, + + state: "loading" + } + } + + render() { + const selectedGroup = this.selectedGroup(); + let groupTypePrefix = ""; + switch (selectedGroup?.type) { + case "query": + groupTypePrefix = "[Q] "; + break; + + case "template": + groupTypePrefix = "[T] "; + break; + } + + return [ +
this.onListContextMenu(e)}> + {selectedGroup ? +
+
+
{ groupTypePrefix + selectedGroup.name + " (" + selectedGroup.id + ")" }
+
+ : undefined + } +
+ {this.clients.map(client =>
this.setState({ + selectedClientId: client.databaseId, + disableClientRemove: !selectedGroup || this.clientMemberRemovePower === 0 || selectedGroup.needed_member_remove > this.clientMemberRemovePower + })} + onContextMenu={e => { + e.preventDefault(); + + this.setState({ selectedClientId: client.databaseId }); + contextmenu.spawn_context_menu(e.pageX, e.pageY, { + type: contextmenu.MenuEntryType.ENTRY, + name: tr("Add client"), + icon_class: 'client-add', + callback: () => this.onClientAdd(), + invalidPermission: this.clientMemberAddPower === 0 || selectedGroup.needed_member_remove > this.clientMemberAddPower + }, { + type: contextmenu.MenuEntryType.ENTRY, + name: tr("Remove client"), + icon_class: 'client-delete', + callback: () => this.onClientRemove(), + invalidPermission: !selectedGroup || this.clientMemberRemovePower === 0 || selectedGroup.needed_member_remove > this.clientMemberRemovePower + }, { + type: contextmenu.MenuEntryType.ENTRY, + name: tr("Copy unique id"), + icon_class: 'client-copy', + callback: () => copy_to_clipboard(client.uniqueId) + }, contextmenu.Entry.HR(), { + type: contextmenu.MenuEntryType.ENTRY, + name: tr("Refresh"), + icon_class: 'client-refresh', + callback: () => this.onRefreshList() + }) + }} + >{client.name || client.uniqueId}
)} +
+
0 ? cssStyle.hidden : "")}> + This group contains no clients. +
+ + + + + +
, +
+ this.onClientAdd()} + /> + this.onClientRemove()} + /> +
+ ]; + } + + private selectedClient() { + return this.clients.find(e => e.databaseId === this.state.selectedClientId); + } + + private selectedGroup() { + return this.groups.find(e => e.id === this.state.selectedGroupId); + } + + @EventHandler("action_activate_tab") + private handleGroupTabActive(events: PermissionModalEvents["action_activate_tab"]) { + if(events.tab === "groups-server" || events.tab === "groups-channel") { + if(typeof events.activeGroupId !== "undefined") + this.setState({ selectedGroupId: events.activeGroupId }); + } + } + + @EventHandler("query_group_clients") + private handleQueryClients(events: PermissionModalEvents["query_group_clients"]) { + if(events.id !== this.state.selectedGroupId) + return; + + this.setState({ state: "loading" }); + } + + @EventHandler("query_group_clients_result") + private handleQueryClientsResult(event: PermissionModalEvents["query_group_clients_result"]) { + if(event.id !== this.state.selectedGroupId) + return; + + this.clients = event.clients.slice(0); + this.setState({ + state: event.status === "success" ? "normal" : event.status === "error" ? "error" : event.status === "no-permissions" ? "no-permissions" : "error", + error: event.error || tr("unknown error") + }); + } + + @EventHandler("notify_client_list_toggled") + private handleNotifyShow(events: PermissionModalEvents["notify_client_list_toggled"]) { + if(!events.visible) + return; + + this.props.events.fire("query_group_clients", { id: this.state.selectedGroupId }); + } + + @EventHandler("notify_groups_reset") + private handleReset() { + this.groups.splice(0, this.groups.length); + this.setState({ selectedClientId: 0, selectedGroupId: 0 }) + } + + @EventHandler("notify_client_permissions") + private handleClientPermissions(event: PermissionModalEvents["notify_client_permissions"]) { + this.clientMemberAddPower = event.serverGroupMemberAddPower; + this.clientMemberRemovePower = event.serverGroupMemberRemovePower; + + const group = this.selectedGroup(); + const client = this.selectedClient(); + this.setState({ + disableClientRemove: !client || this.clientMemberRemovePower === 0 || group.needed_member_remove > this.clientMemberRemovePower, + disableClientAdd: !group || this.clientMemberAddPower === 0 || group.needed_member_add > this.clientMemberAddPower + }); + } + + @EventHandler("action_select_group") + private handleSelect(event: PermissionModalEvents["action_select_group"]) { + if(event.target !== "server") + return; + + if(this.state.selectedGroupId === event.id) + return; + + const selectedGroup = this.groups.find(e => e.id === event.id); + const client = this.selectedClient(); + this.setState({ + selectedGroupId: event.id, + + disableClientRemove: !client || this.clientMemberRemovePower === 0 || selectedGroup.needed_member_remove > this.clientMemberRemovePower, + disableClientAdd: !selectedGroup || this.clientMemberAddPower === 0 || selectedGroup.needed_member_add > this.clientMemberAddPower + }); + } + + @EventHandler("query_groups_result") + private handleQueryResult(event: PermissionModalEvents["query_groups_result"]) { + if(event.target !== "server") + return; + + this.groups.splice(0, this.groups.length); + this.groups.push(...event.groups); + if(!this.selectedGroup()) + this.setState({ selectedGroupId: 0, selectedClientId: 0 }); + } + + @EventHandler("notify_groups_created") + private handleGroupsCreated(event: PermissionModalEvents["notify_groups_created"]) { + if(event.target !== "server") + return; + + this.groups.push(...event.groups); + } + + @EventHandler("notify_groups_deleted") + private handleGroupsDeleted(event: PermissionModalEvents["notify_groups_deleted"]) { + if(event.target !== "server") + return; + + event.groups.forEach(id => { + const index = this.groups.findIndex(e => e.id === id); + if(index === -1) return; + + this.groups.splice(index, 1); + }); + + this.forceUpdate(); + } + + @EventHandler("notify_group_updated") + private handleGroupUpdated(event: PermissionModalEvents["notify_group_updated"]) { + if(event.target !== "server") + return; + + const group = this.groups.find(e => e.id === event.id); + if(!group) return; + + for(const update of event.properties) { + switch (update.property) { + case "name": + group.name = update.value; + break; + + case "icon": + group.iconId = update.value; + break; + + case "sort": + group.sortId = update.value; + break; + + case "save": + group.saveDB = update.value; + break; + } + } + + if(this.state.selectedGroupId === event.id) + this.forceUpdate(); + } + + @EventHandler("action_server_group_add_client_result") + private handleServerGroupAddClientResult(event: PermissionModalEvents["action_server_group_add_client_result"]) { + if(event.id !== this.state.selectedGroupId || event.status !== "success") + return; + + this.props.events.fire("query_group_clients", { id: this.state.selectedGroupId }); + } + + @EventHandler("action_server_group_remove_client_result") + private handleServerGroupRemoveClientResult(event: PermissionModalEvents["action_server_group_remove_client_result"]) { + if(event.id !== this.state.selectedGroupId || event.status !== "success") + return; + + this.props.events.fire("query_group_clients", { id: this.state.selectedGroupId }); + } + + private onRefreshList() { + this.props.events.fire("query_group_clients", { id: this.state.selectedGroupId }); + } + + private onListContextMenu(event: React.MouseEvent) { + if(event.isDefaultPrevented()) + return; + + contextmenu.spawn_context_menu(event.pageX, event.pageY, { + type: contextmenu.MenuEntryType.ENTRY, + name: tr("Add client"), + icon_class: 'client-add', + callback: () => this.onClientAdd() + }, { + type: contextmenu.MenuEntryType.ENTRY, + name: tr("Refresh"), + icon_class: 'client-refresh', + callback: () => this.onRefreshList() + }); + } + private onClientAdd() { + createInputModal(tr("Add client to server group"), tr("Enter the client unique id or database id"), text => { + if(!text) return false; + if(!!text.match(/^[0-9]+$/)) + return true; + try { + return atob(text).length == 20; + } catch(error) { + return false; + } + }, async text => { + if(typeof(text) !== "string") + return; + + let targetClient; + if(!!text.match(/^[0-9]+$/)) { + targetClient = parseInt(text); + } else { + targetClient = text.trim(); + } + + this.props.events.fire("action_server_group_add_client", { client: targetClient, id: this.state.selectedGroupId }); + }).open(); + } + + private onClientRemove() { + this.props.events.fire("action_server_group_remove_client", { id: this.state.selectedGroupId, client: this.state.selectedClientId }); + } +} + +const ServerGroupsSideBar = (props: { connection: ConnectionHandler, modalEvents: Registry }) => { + const [ active, setActive ] = useState(true); + const [ clientList, setClientList ] = useState(false); + + props.modalEvents.reactUse("action_activate_tab", event => setActive(event.tab === "groups-server")); + props.modalEvents.reactUse("notify_client_list_toggled", event => setClientList(event.visible)); + + return ( +
+
+ +
+
+ +
+
+ ); +}; + +const ChannelGroupsSideBar = (props: { connection: ConnectionHandler, modalEvents: Registry }) => { + const [ active, setActive ] = useState(false); + + props.modalEvents.reactUse("action_activate_tab", event => setActive(event.tab === "groups-channel")); + + return ( +
+ +
+ ); +}; + +@ReactEventHandler(e => e.props.events) +class ChannelList extends React.Component<{ connection: ConnectionHandler, events: Registry, tabTarget: "channel" | "client-channel" }, { selectedChanelId: number }> { + private channels: ChannelInfo[] = []; + private isActiveTab = false; + + constructor(props) { + super(props); + + this.state = { + selectedChanelId: 0 + } + } + + render() { + return ( +
{ + e.preventDefault(); + + spawn_context_menu(e.pageX, e.pageY, { + type: MenuEntryType.ENTRY, + icon_class: "client-check_update", + name: tr("Refresh"), + callback: () => this.props.events.fire("query_channels") + }); + }}> +
+ {this.channels.map(e => ( +
this.props.events.fire("action_select_channel", { target: this.props.tabTarget, id: e.id })} + > + + {e.name + " (" + e.id + ")"} +
+ ))} +
+
+ ) + } + + componentDidMount(): void { + this.props.events.fire("query_channels"); + } + + @EventHandler("query_channels_result") + private handleQueryChannelsResult(event: PermissionModalEvents["query_channels_result"]) { + this.channels = event.channels.slice(0); + if(this.channels.length > 0 && this.channels.findIndex(e => e.id === this.state.selectedChanelId) === -1) + this.setState({ selectedChanelId: this.channels[0].id }); + else + this.forceUpdate(); + } + + @EventHandler("notify_channel_updated") + private handleChannelUpdated(event: PermissionModalEvents["notify_channel_updated"]) { + const channel = this.channels.find(e => e.id === event.id); + if(!channel) return; + + switch (event.property) { + case "icon": + channel.iconId = event.value; + break; + + case "name": + channel.name = event.value; + break; + + default: + return; + } + + this.forceUpdate(); + } + + @EventHandler("action_select_channel") + private handleActionSelectChannel(event: PermissionModalEvents["action_select_channel"]) { + if(event.target !== this.props.tabTarget) + return; + + this.setState({ selectedChanelId: event.id }); + if(this.isActiveTab) { + this.props.events.fire("action_set_permission_editor_subject", { + mode: this.props.tabTarget, + channelId: event.id }); + } + } + + @EventHandler("action_activate_tab") + private handleActionTabSelect(event: PermissionModalEvents["action_activate_tab"]) { + this.isActiveTab = event.tab === this.props.tabTarget; + if(!this.isActiveTab) + return; + + if(typeof event.activeChannelId === "number") + this.setState({ selectedChanelId: event.activeChannelId }); + + this.props.events.fire("action_set_permission_editor_subject", { + mode: this.props.tabTarget, + channelId: typeof event.activeChannelId === "number" ? event.activeChannelId : this.state.selectedChanelId }); + } +} + +const ChannelSideBar = (props: { connection: ConnectionHandler, modalEvents: Registry }) => { + const [ active, setActive ] = useState(false); + + props.modalEvents.reactUse("action_activate_tab", event => setActive(event.tab === "channel")); + + return ( +
+ +
+ ); +}; + +const ClientSelect = (props: { events: Registry, tabTarget: "client" | "client-channel" }) => { + const [ clientIdentifier, setClientIdentifier ] = useState(undefined); + const [ clientInfo, setClientInfo ] = useState<{ name: string, uniqueId: string, databaseId: number }>(undefined); + + const refInput = useRef(); + const refNickname = useRef(); + const refUniqueIdentifier = useRef(); + const refDatabaseId = useRef(); + + props.events.reactUse("action_activate_tab", event => { + if(event.tab !== props.tabTarget) + return; + + if(typeof event.activeClientDatabaseId !== "undefined") { + props.events.fire("action_select_client", { target: props.tabTarget, id: event.activeClientDatabaseId === 0 ? "undefined" : event.activeClientDatabaseId }); + } else { + if(clientInfo && clientInfo.databaseId) + props.events.fire("action_set_permission_editor_subject", { mode: props.tabTarget, clientDatabaseId: clientInfo.databaseId }); + else + props.events.fire("action_set_permission_editor_subject", { mode: props.tabTarget, clientDatabaseId: 0 }); + } + }); + + props.events.reactUse("query_client_info", event => { + if(event.client !== clientIdentifier) + return; + + refNickname.current?.setValue(undefined); + refUniqueIdentifier.current?.setValue(undefined); + refDatabaseId.current?.setValue(undefined); + + refInput.current?.setState({ disabled: true }); + refNickname.current?.setState({ placeholder: tr("loading...") }); + refUniqueIdentifier.current?.setState({ placeholder: tr("loading...") }); + refDatabaseId.current?.setState({ placeholder: tr("loading...") }); + + props.events.fire("action_set_permission_editor_subject", { mode: props.tabTarget, clientDatabaseId: 0 }); + }); + + props.events.reactUse("query_client_info_result", event => { + if(event.client !== clientIdentifier) + return; + + refInput.current?.setState({ disabled: false }); + if(event.state === "success") { + setClientInfo(event.info); + + refNickname.current?.setValue(event.info.name); + refUniqueIdentifier.current?.setValue(event.info.uniqueId); + refDatabaseId.current?.setValue(event.info.databaseId + ""); + props.events.fire("action_set_permission_editor_subject", { mode: props.tabTarget, clientDatabaseId: event.info.databaseId }); + return; + } else if(event.state === "error") { + refInput.current.setState({ disabled: false, isInvalid: true, invalidMessage: event.error }); + } else if(event.state === "no-permission") { + refInput.current.setState({ disabled: false, isInvalid: true, invalidMessage: tra("failed on permission {0}", event.failedPermission) }); + } else if(event.state === "no-such-client") { + refInput.current.setState({ disabled: false, isInvalid: true, invalidMessage: tr("no client found") }); + refInput.current.focus(); + } + + refNickname.current?.setState({ placeholder: undefined }); + refUniqueIdentifier.current?.setState({ placeholder: undefined }); + refDatabaseId.current?.setState({ placeholder: undefined }); + }); + + props.events.reactUse("action_select_client", event => { + if(event.target !== props.tabTarget) + return; + + setClientIdentifier(event.id); + refInput.current.setValue(typeof event.id === "undefined" ? "" : event.id.toString()); + if(typeof event.id === "number" || typeof event.id === "string") { + /* first do the state update */ + props.events.fire_async("query_client_info", { client: event.id }); + } else { + refInput.current?.setValue(undefined); + refNickname.current?.setState({ placeholder: undefined }); + refUniqueIdentifier.current?.setState({ placeholder: undefined }); + refDatabaseId.current?.setState({ placeholder: undefined }); + props.events.fire("action_set_permission_editor_subject", { mode: props.tabTarget, clientDatabaseId: 0 }); + } + }); + + return ( +
+ { + if(value.match(/^[0-9]{1,8}$/)) { + refInput.current?.setState({ isInvalid: false }); + } else if(value.length === 0) { + refInput.current?.setState({ isInvalid: false }); + } else { + try { + if(arrayBufferBase64(value).byteLength !== 20) + throw void 0; + + refInput.current?.setState({ isInvalid: false }); + } catch (e) { + refInput.current?.setState({ isInvalid: true, invalidMessage: undefined }); + } + } + }} + onBlur={() => { + const value = refInput.current.value(); + let client; + if(value.match(/^[0-9]{1,8}$/)) { + client = parseInt(value); + } else if(value.length === 0) { + client = undefined; + } else { + try { + if(arrayBufferBase64(value).byteLength !== 20) { + refInput.current?.setState({ isInvalid: true, invalidMessage: tr("Invalid UUID length") }); + return; + } + } catch (e) { + refInput.current?.setState({ isInvalid: true, invalidMessage: tr("Invalid UUID") }); + return; + } + } + refInput.current?.setState({ isInvalid: false }); + props.events.fire("action_select_client", { id: client, target: props.tabTarget }); + }} + /> +
+ + + +
+ ); +}; + +const ClientSideBar = (props: { connection: ConnectionHandler, modalEvents: Registry }) => { + const [ active, setActive ] = useState(false); + + props.modalEvents.reactUse("action_activate_tab", event => setActive(event.tab === "client")); + + return ( +
+ +
+ ); +}; + +const ClientChannelSideBar = (props: { connection: ConnectionHandler, modalEvents: Registry }) => { + const [ active, setActive ] = useState(false); + + props.modalEvents.reactUse("action_activate_tab", event => setActive(event.tab === "client-channel")); + + return ( +
+ + +
+ ); +}; \ No newline at end of file diff --git a/shared/js/ui/react-elements/Button.tsx b/shared/js/ui/react-elements/Button.tsx index 00475caa..30e26cca 100644 --- a/shared/js/ui/react-elements/Button.tsx +++ b/shared/js/ui/react-elements/Button.tsx @@ -27,6 +27,7 @@ export class Button extends ReactComponentBase { render() { if(this.props.hidden) return null; + return (