diff --git a/js/src/admin/Admin.ts b/js/src/admin/Admin.ts index a5262e450..7ced37bf5 100644 --- a/js/src/admin/Admin.ts +++ b/js/src/admin/Admin.ts @@ -8,6 +8,9 @@ import AdminNav from './components/AdminNav'; export type AdminData = ApplicationData & { mysqlVersion: string; phpVersion: string; + permissions: { + [key: string]: string[]; + }; settings: { [key: string]: string; }; diff --git a/js/src/admin/components/AdminNav.tsx b/js/src/admin/components/AdminNav.tsx index a6496844c..c814c8189 100644 --- a/js/src/admin/components/AdminNav.tsx +++ b/js/src/admin/components/AdminNav.tsx @@ -50,12 +50,15 @@ export default class AdminNav extends Component { }) ); - // items.add('permissions', AdminLinkButton.component({ - // href: app.route('permissions'), - // icon: 'fas fa-key', - // children: app.translator.trans('core.admin.nav.permissions_button'), - // description: app.translator.trans('core.admin.nav.permissions_text') - // })); + items.add( + 'permissions', + AdminLinkButton.component({ + href: app.route('permissions'), + icon: 'fas fa-key', + children: app.translator.trans('core.admin.nav.permissions_button'), + description: app.translator.trans('core.admin.nav.permissions_text'), + }) + ); // items.add('appearance', AdminLinkButton.component({ // href: app.route('appearance'), diff --git a/js/src/admin/components/EditGroupModal.tsx b/js/src/admin/components/EditGroupModal.tsx new file mode 100644 index 000000000..8d1b2245c --- /dev/null +++ b/js/src/admin/components/EditGroupModal.tsx @@ -0,0 +1,157 @@ +import app from '../app'; +import { ComponentProps } from '../../common/Component'; +import Modal from '../../common/components/Modal'; +import Button from '../../common/components/Button'; +import Badge from '../../common/components/Badge'; +import Group from '../../common/models/Group'; +import ItemList from '../../common/utils/ItemList'; + +import Stream from 'mithril/stream'; + +/** + * The `EditGroupModal` component shows a modal dialog which allows the user + * to create or edit a group. + */ +export default class EditGroupModal extends Modal { + group: Group; + + nameSingular: Stream; + namePlural: Stream; + icon: Stream; + color: Stream; + + oninit(vnode) { + super.oninit(vnode); + + this.group = this.props.group || app.store.createRecord('groups'); + + this.nameSingular = m.prop(this.group.nameSingular() || ''); + this.namePlural = m.prop(this.group.namePlural() || ''); + this.icon = m.prop(this.group.icon() || ''); + this.color = m.prop(this.group.color() || ''); + } + + className() { + return 'EditGroupModal Modal--small'; + } + + title() { + return [ + this.color() || this.icon() + ? Badge.component({ + icon: this.icon(), + style: { backgroundColor: this.color() }, + }) + : '', + ' ', + this.namePlural() || app.translator.trans('core.admin.edit_group.title'), + ]; + } + + content() { + return ( +
+
{this.fields().toArray()}
+
+ ); + } + + fields() { + const items = new ItemList(); + + items.add( + 'name', +
+ +
+ + +
+
, + 30 + ); + + items.add( + 'color', +
+ + +
, + 20 + ); + + items.add( + 'icon', +
+ +
+ {app.translator.trans('core.admin.edit_group.icon_text', { a: })} +
+ +
, + 10 + ); + + items.add( + 'submit', +
+ {Button.component({ + type: 'submit', + className: 'Button Button--primary EditGroupModal-save', + loading: this.loading, + children: app.translator.trans('core.admin.edit_group.submit_button'), + })} + {this.group.exists && this.group.id() !== Group.ADMINISTRATOR_ID ? ( + + ) : ( + '' + )} +
, + -10 + ); + + return items; + } + + submitData() { + return { + nameSingular: this.nameSingular(), + namePlural: this.namePlural(), + color: this.color(), + icon: this.icon(), + }; + } + + onsubmit(e) { + e.preventDefault(); + + this.loading = true; + + this.group + .save(this.submitData(), { errorHandler: this.onerror.bind(this) }) + .then(this.hide.bind(this)) + .catch(() => { + this.loading = false; + m.redraw(); + }); + } + + deleteGroup() { + if (confirm(app.translator.transText('core.admin.edit_group.delete_confirmation'))) { + this.group.delete().then(() => m.redraw()); + this.hide(); + } + } +} diff --git a/js/src/admin/components/MailPage.tsx b/js/src/admin/components/MailPage.tsx index b35d291d7..cdc783deb 100644 --- a/js/src/admin/components/MailPage.tsx +++ b/js/src/admin/components/MailPage.tsx @@ -152,8 +152,6 @@ export default class MailPage extends Page { const prop = this.values[name]; if (prop == undefined) { - console.log(field) - console.log(this.values) } if (typeof field === 'string') { diff --git a/js/src/admin/components/PermissionDropdown.tsx b/js/src/admin/components/PermissionDropdown.tsx new file mode 100644 index 000000000..bee651566 --- /dev/null +++ b/js/src/admin/components/PermissionDropdown.tsx @@ -0,0 +1,159 @@ +import app from '../app'; + +import Dropdown, {DropdownProps} from '../../common/components/Dropdown'; +import Button from '../../common/components/Button'; +import Separator from '../../common/components/Separator'; +import Group from '../../common/models/Group'; +import Badge from '../../common/components/Badge'; +import GroupBadge from '../../common/components/GroupBadge'; + +function badgeForId(id) { + const group = app.store.getById('groups', id); + + return group ? GroupBadge.component({ group, label: null }) : ''; +} + +function filterByRequiredPermissions(groupIds, permission) { + app.getRequiredPermissions(permission).forEach((required) => { + const restrictToGroupIds = app.data.permissions[required] || []; + + if (restrictToGroupIds.indexOf(Group.GUEST_ID) !== -1) { + // do nothing + } else if (restrictToGroupIds.indexOf(Group.MEMBER_ID) !== -1) { + groupIds = groupIds.filter((id) => id !== Group.GUEST_ID); + } else if (groupIds.indexOf(Group.MEMBER_ID) !== -1) { + groupIds = restrictToGroupIds; + } else { + groupIds = restrictToGroupIds.filter((id) => groupIds.indexOf(id) !== -1); + } + + groupIds = filterByRequiredPermissions(groupIds, required); + }); + + return groupIds; +} + +export interface PermissionDropdownProps extends DropdownProps { + label?: Badge[]; +} + +export default class PermissionDropdown extends Dropdown { + static initProps(props) { + super.initProps(props); + + props.className = 'PermissionDropdown'; + props.buttonClassName = 'Button Button--text'; + } + + view() { + this.props.children = []; + + let groupIds = app.data.permissions[this.props.permission] || []; + + groupIds = filterByRequiredPermissions(groupIds, this.props.permission); + + const everyone = groupIds.indexOf(Group.GUEST_ID) !== -1; + const members = groupIds.indexOf(Group.MEMBER_ID) !== -1; + const adminGroup: Group = app.store.getById('groups', Group.ADMINISTRATOR_ID); + + if (everyone) { + this.props.label = Badge.component({ icon: 'fas fa-globe' }); + } else if (members) { + this.props.label = Badge.component({ icon: 'fas fa-user' }); + } else { + this.props.label = [badgeForId(Group.ADMINISTRATOR_ID), groupIds.map(badgeForId)]; + } + + if (this.showing) { + if (this.props.allowGuest) { + this.props.children.push( + Button.component({ + children: [ + Badge.component({ icon: 'fas fa-globe' }), + ' ', + app.translator.trans('core.admin.permissions_controls.everyone_button'), + ], + icon: everyone ? 'fas fa-check' : true, + onclick: () => this.save([Group.GUEST_ID]), + disabled: this.isGroupDisabled(Group.GUEST_ID), + }) + ); + } + + this.props.children.push( + Button.component({ + children: [Badge.component({ icon: 'fas fa-user' }), ' ', app.translator.trans('core.admin.permissions_controls.members_button')], + icon: members ? 'fas fa-check' : true, + onclick: () => this.save([Group.MEMBER_ID]), + disabled: this.isGroupDisabled(Group.MEMBER_ID), + }), + + Separator.component(), + + Button.component({ + children: [badgeForId(adminGroup.id()), ' ', adminGroup.namePlural()], + icon: !everyone && !members ? 'fas fa-check' : true, + disabled: !everyone && !members, + onclick: (e) => { + if (e.shiftKey) e.stopPropagation(); + this.save([]); + }, + }) + ); + + [].push.apply( + this.props.children, + app.store + .all('groups') + .filter((group) => [Group.ADMINISTRATOR_ID, Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1) + .map((group: Group) => + Button.component({ + children: [badgeForId(group.id()), ' ', group.namePlural()], + icon: groupIds.indexOf(group.id()) !== -1 ? 'fas fa-check' : true, + onclick: (e) => { + if (e.shiftKey) e.stopPropagation(); + this.toggle(group.id()); + }, + disabled: + this.isGroupDisabled(group.id()) && this.isGroupDisabled(Group.MEMBER_ID) && this.isGroupDisabled(Group.GUEST_ID), + }) + ) + ); + } + + return super.view(); + } + + save(groupIds) { + const permission = this.props.permission; + + app.data.permissions[permission] = groupIds; + + app.request({ + method: 'POST', + url: app.forum.attribute('apiUrl') + '/permission', + body: { permission, groupIds }, + }); + } + + toggle(groupId) { + const permission = this.props.permission; + + let groupIds = app.data.permissions[permission] || []; + + const index = groupIds.indexOf(groupId); + + if (index !== -1) { + groupIds.splice(index, 1); + } else { + groupIds.push(groupId); + groupIds = groupIds.filter((id) => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(id) === -1); + } + + this.save(groupIds); + } + + isGroupDisabled(id) { + return filterByRequiredPermissions([id], this.props.permission).indexOf(id) === -1; + } +} diff --git a/js/src/admin/components/PermissionGrid.tsx b/js/src/admin/components/PermissionGrid.tsx new file mode 100644 index 000000000..ca57db527 --- /dev/null +++ b/js/src/admin/components/PermissionGrid.tsx @@ -0,0 +1,361 @@ +import app from '../app'; + +import Component from '../../common/Component'; +import PermissionDropdown from './PermissionDropdown'; +import SettingDropdown from './SettingDropdown'; +import Button from '../../common/components/Button'; +import ItemList from '../../common/utils/ItemList'; +import icon from '../../common/helpers/icon'; + +export default class PermissionGrid extends Component { + view() { + const scopes = this.scopeItems().toArray(); + + const permissionCells = (permission) => { + return scopes.map((scope) => {scope.render(permission)}); + }; + + return ( + + + + + {scopes.map((scope) => ( + + ))} + + + + {this.permissionItems() + .toArray() + .map((section) => ( + + + + {permissionCells(section)} + + {section.children.map((child) => ( + + + {permissionCells(child)} + + ))} + + ))} +
+ {scope.label}{' '} + {scope.onremove + ? Button.component({ + icon: 'fas fa-times', + className: 'Button Button--text PermissionGrid-removeScope', + onclick: scope.onremove, + }) + : ''} + {this.scopeControlItems().toArray()}
{section.label} +
+ {icon(child.icon)} + {child.label} + +
+ ); + } + + permissionItems() { + const items = new ItemList(); + + items.add( + 'view', + { + label: app.translator.trans('core.admin.permissions.read_heading'), + children: this.viewItems().toArray(), + }, + 100 + ); + + items.add( + 'start', + { + label: app.translator.trans('core.admin.permissions.create_heading'), + children: this.startItems().toArray(), + }, + 90 + ); + + items.add( + 'reply', + { + label: app.translator.trans('core.admin.permissions.participate_heading'), + children: this.replyItems().toArray(), + }, + 80 + ); + + items.add( + 'moderate', + { + label: app.translator.trans('core.admin.permissions.moderate_heading'), + children: this.moderateItems().toArray(), + }, + 70 + ); + + return items; + } + + viewItems() { + const items = new ItemList(); + + items.add( + 'viewDiscussions', + { + icon: 'fas fa-eye', + label: app.translator.trans('core.admin.permissions.view_discussions_label'), + permission: 'viewDiscussions', + allowGuest: true, + }, + 100 + ); + + items.add( + 'viewUserList', + { + icon: 'fas fa-users', + label: app.translator.trans('core.admin.permissions.view_user_list_label'), + permission: 'viewUserList', + allowGuest: true, + }, + 100 + ); + + items.add( + 'signUp', + { + icon: 'fas fa-user-plus', + label: app.translator.trans('core.admin.permissions.sign_up_label'), + setting: () => + SettingDropdown.component({ + key: 'allow_sign_up', + options: [ + { value: '1', label: app.translator.transText('core.admin.permissions_controls.signup_open_button') }, + { value: '0', label: app.translator.transText('core.admin.permissions_controls.signup_closed_button') }, + ], + }), + }, + 90 + ); + + items.add('viewLastSeenAt', { + icon: 'far fa-clock', + label: app.translator.trans('core.admin.permissions.view_last_seen_at_label'), + permission: 'user.viewLastSeenAt', + }); + + return items; + } + + startItems() { + const items = new ItemList(); + + items.add( + 'start', + { + icon: 'fas fa-edit', + label: app.translator.trans('core.admin.permissions.start_discussions_label'), + permission: 'startDiscussion', + }, + 100 + ); + + items.add( + 'allowRenaming', + { + icon: 'fas fa-i-cursor', + label: app.translator.trans('core.admin.permissions.allow_renaming_label'), + setting: () => { + const minutes = parseInt(app.data.settings.allow_renaming, 10); + + return SettingDropdown.component({ + defaultLabel: minutes + ? app.translator.transChoice('core.admin.permissions_controls.allow_some_minutes_button', minutes, { count: minutes }) + : app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button'), + key: 'allow_renaming', + options: [ + { value: '-1', label: app.translator.transText('core.admin.permissions_controls.allow_indefinitely_button') }, + { value: '10', label: app.translator.transText('core.admin.permissions_controls.allow_ten_minutes_button') }, + { value: 'reply', label: app.translator.transText('core.admin.permissions_controls.allow_until_reply_button') }, + ], + }); + }, + }, + 90 + ); + + return items; + } + + replyItems() { + const items = new ItemList(); + + items.add( + 'reply', + { + icon: 'fas fa-reply', + label: app.translator.trans('core.admin.permissions.reply_to_discussions_label'), + permission: 'discussion.reply', + }, + 100 + ); + + items.add( + 'allowPostEditing', + { + icon: 'fas fa-pencil-alt', + label: app.translator.trans('core.admin.permissions.allow_post_editing_label'), + setting: () => { + const minutes = parseInt(app.data.settings.allow_post_editing, 10); + + return SettingDropdown.component({ + defaultLabel: minutes + ? app.translator.transChoice('core.admin.permissions_controls.allow_some_minutes_button', minutes, { count: minutes }) + : app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button'), + key: 'allow_post_editing', + options: [ + { value: '-1', label: app.translator.transText('core.admin.permissions_controls.allow_indefinitely_button') }, + { value: '10', label: app.translator.transText('core.admin.permissions_controls.allow_ten_minutes_button') }, + { value: 'reply', label: app.translator.transText('core.admin.permissions_controls.allow_until_reply_button') }, + ], + }); + }, + }, + 90 + ); + + return items; + } + + moderateItems() { + const items = new ItemList(); + + items.add( + 'viewIpsPosts', + { + icon: 'fas fa-bullseye', + label: app.translator.trans('core.admin.permissions.view_post_ips_label'), + permission: 'discussion.viewIpsPosts', + }, + 110 + ); + + items.add( + 'renameDiscussions', + { + icon: 'fas fa-i-cursor', + label: app.translator.trans('core.admin.permissions.rename_discussions_label'), + permission: 'discussion.rename', + }, + 100 + ); + + items.add( + 'hideDiscussions', + { + icon: 'far fa-trash-alt', + label: app.translator.trans('core.admin.permissions.delete_discussions_label'), + permission: 'discussion.hide', + }, + 90 + ); + + items.add( + 'deleteDiscussions', + { + icon: 'fas fa-times', + label: app.translator.trans('core.admin.permissions.delete_discussions_forever_label'), + permission: 'discussion.delete', + }, + 80 + ); + + items.add( + 'postWithoutThrottle', + { + icon: 'fas fa-swimmer', + label: app.translator.trans('core.admin.permissions.post_without_throttle_label'), + permission: 'postWithoutThrottle', + }, + 70 + ); + + items.add( + 'editPosts', + { + icon: 'fas fa-pencil-alt', + label: app.translator.trans('core.admin.permissions.edit_posts_label'), + permission: 'discussion.editPosts', + }, + 70 + ); + + items.add( + 'hidePosts', + { + icon: 'far fa-trash-alt', + label: app.translator.trans('core.admin.permissions.delete_posts_label'), + permission: 'discussion.hidePosts', + }, + 60 + ); + + items.add( + 'deletePosts', + { + icon: 'fas fa-times', + label: app.translator.trans('core.admin.permissions.delete_posts_forever_label'), + permission: 'discussion.deletePosts', + }, + 60 + ); + + items.add( + 'userEdit', + { + icon: 'fas fa-user-cog', + label: app.translator.trans('core.admin.permissions.edit_users_label'), + permission: 'user.edit', + }, + 60 + ); + + return items; + } + + scopeItems() { + const items = new ItemList(); + + items.add( + 'global', + { + label: app.translator.trans('core.admin.permissions.global_heading'), + render: (item) => { + if (item.setting) { + return item.setting(); + } else if (item.permission) { + return PermissionDropdown.component({ + permission: item.permission, + allowGuest: item.allowGuest, + }); + } + + return ''; + }, + }, + 100 + ); + + return items; + } + + scopeControlItems() { + return new ItemList(); + } +} diff --git a/js/src/admin/components/PermissionsPage.tsx b/js/src/admin/components/PermissionsPage.tsx new file mode 100644 index 000000000..94be06761 --- /dev/null +++ b/js/src/admin/components/PermissionsPage.tsx @@ -0,0 +1,42 @@ +import app from '../app'; + +import Page from './Page'; +import GroupBadge from '../../common/components/GroupBadge'; +import EditGroupModal from './EditGroupModal'; +import Group from '../../common/models/Group'; +import icon from '../../common/helpers/icon'; +import PermissionGrid from './PermissionGrid'; + +export default class PermissionsPage extends Page { + view() { + return ( +
+
+
+ {app.store + .all('groups') + .filter((group) => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1) + .map((group: Group) => ( + + ))} + +
+
+ +
+
{PermissionGrid.component()}
+
+
+ ); + } +} diff --git a/js/src/admin/routes.ts b/js/src/admin/routes.ts index 32ffd76f1..875c9bf1d 100644 --- a/js/src/admin/routes.ts +++ b/js/src/admin/routes.ts @@ -1,11 +1,13 @@ import BasicsPage from './components/BasicsPage'; import DashboardPage from './components/DashboardPage'; import MailPage from './components/MailPage'; +import PermissionsPage from './components/PermissionsPage'; export default (app) => { app.routes = { dashboard: { path: '/', component: DashboardPage }, basics: { path: '/basics', component: BasicsPage }, mail: { path: '/mail', component: MailPage }, + permissions: { path: '/permissions', component: PermissionsPage }, }; }; diff --git a/js/src/common/Application.ts b/js/src/common/Application.ts index 7b335f60a..6e8fd5af6 100644 --- a/js/src/common/Application.ts +++ b/js/src/common/Application.ts @@ -113,7 +113,7 @@ export default abstract class Application { } boot() { - this.initializers.toArray().forEach((initializer) => initializer(this)); + //this.initializers.toArray().forEach((initializer) => initializer(this)); this.store.pushPayload({ data: this.data.resources }); diff --git a/js/src/common/components/Dropdown.tsx b/js/src/common/components/Dropdown.tsx index bc3928ff3..67178fb0e 100644 --- a/js/src/common/components/Dropdown.tsx +++ b/js/src/common/components/Dropdown.tsx @@ -5,7 +5,7 @@ import listItems from '../helpers/listItems'; export interface DropdownProps extends ComponentProps { buttonClassName?: string; menuClassName?: string; - label?: string; + label?: string | any[]; icon?: string; caretIcon?: undefined | string; diff --git a/js/src/common/components/Modal.tsx b/js/src/common/components/Modal.tsx index 0c3ebcb5f..6039ce48e 100644 --- a/js/src/common/components/Modal.tsx +++ b/js/src/common/components/Modal.tsx @@ -65,7 +65,7 @@ export default abstract class Modal e /** * Get the title of the modal dialog. */ - abstract title(): string; + abstract title(); /** * Get the content of the modal.