From 50215cedfc43a0c4ff38f30340ec7923abf3c8e9 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Fri, 31 Jul 2015 20:16:47 +0930 Subject: [PATCH] Finish admin permissions page and clean up everything --- .../js/admin/src/components/ConfigDropdown.js | 25 ++ .../js/admin/src/components/EditGroupModal.js | 105 +++++++ .../src/components/PermissionDropdown.js | 115 ++++++++ .../js/admin/src/components/PermissionGrid.js | 217 +++++++++++++++ .../admin/src/components/PermissionsPage.js | 262 ++---------------- .../forum/src/components/ChangeEmailModal.js | 12 +- .../core/js/forum/src/components/Post.js | 3 +- .../forum/src/components/SessionDropdown.js | 2 +- framework/core/js/lib/Model.js | 9 +- framework/core/js/lib/Store.js | 9 + framework/core/js/lib/components/Badge.js | 8 +- framework/core/js/lib/components/Button.js | 2 +- framework/core/js/lib/components/Dropdown.js | 34 ++- .../core/js/lib/components/GroupBadge.js | 16 ++ .../core/js/lib/components/SelectDropdown.js | 10 +- framework/core/js/lib/models/Group.js | 6 +- framework/core/less/admin/AdminNav.less | 4 +- framework/core/less/admin/EditGroupModal.less | 23 ++ .../core/less/admin/PermissionsPage.less | 45 ++- framework/core/less/admin/app.less | 1 + framework/core/less/lib/Alert.less | 8 + framework/core/less/lib/Badge.less | 4 +- framework/core/less/lib/Button.less | 8 +- framework/core/less/lib/Dropdown.less | 15 +- framework/core/less/lib/Form.less | 8 + framework/core/less/lib/Modal.less | 12 +- framework/core/less/lib/scaffolding.less | 1 + framework/core/less/lib/variables.less | 3 + .../src/Admin/Actions/UpdateConfigAction.php | 42 +++ .../Admin/Actions/UpdatePermissionAction.php | 29 ++ .../core/src/Admin/AdminServiceProvider.php | 12 + .../src/Api/Serializers/ForumSerializer.php | 1 + .../Settings/DatabaseSettingsRepository.php | 6 +- 33 files changed, 766 insertions(+), 291 deletions(-) create mode 100644 framework/core/js/admin/src/components/ConfigDropdown.js create mode 100644 framework/core/js/admin/src/components/EditGroupModal.js create mode 100644 framework/core/js/admin/src/components/PermissionDropdown.js create mode 100644 framework/core/js/admin/src/components/PermissionGrid.js create mode 100644 framework/core/js/lib/components/GroupBadge.js create mode 100644 framework/core/less/admin/EditGroupModal.less create mode 100644 framework/core/src/Admin/Actions/UpdateConfigAction.php create mode 100644 framework/core/src/Admin/Actions/UpdatePermissionAction.php diff --git a/framework/core/js/admin/src/components/ConfigDropdown.js b/framework/core/js/admin/src/components/ConfigDropdown.js new file mode 100644 index 000000000..5e46f1953 --- /dev/null +++ b/framework/core/js/admin/src/components/ConfigDropdown.js @@ -0,0 +1,25 @@ +import SelectDropdown from 'flarum/components/SelectDropdown'; +import Button from 'flarum/components/Button'; +import saveConfig from 'flarum/utils/saveConfig'; + +export default class ConfigDropdown extends SelectDropdown { + static initProps(props) { + super.initProps(props); + + props.className = 'ConfigDropdown'; + props.buttonClassName = 'Button Button--text'; + props.caretIcon = 'caret-down'; + props.defaultLabel = 'Custom'; + + props.children = props.options.map(({value, label}) => { + const active = app.config[props.key] === value; + + return Button.component({ + children: label, + icon: active ? 'check' : true, + onclick: saveConfig.bind(this, {[props.key]: value}), + active + }); + }); + } +} diff --git a/framework/core/js/admin/src/components/EditGroupModal.js b/framework/core/js/admin/src/components/EditGroupModal.js new file mode 100644 index 000000000..6ad030c36 --- /dev/null +++ b/framework/core/js/admin/src/components/EditGroupModal.js @@ -0,0 +1,105 @@ +import Modal from 'flarum/components/Modal'; +import Button from 'flarum/components/Button'; +import Badge from 'flarum/components/Badge'; +import Group from 'flarum/models/Group'; + +/** + * The `EditGroupModal` component shows a modal dialog which allows the user + * to create or edit a group. + */ +export default class EditGroupModal extends Modal { + constructor(...args) { + super(...args); + + 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() || 'Create Group' + ]; + } + + content() { + return ( +
+
+
+ +
+ + +
+
+ +
+ + +
+ +
+ +
+ Enter the name of any FontAwesome icon class, without the fa- prefix. +
+ +
+ +
+ {Button.component({ + type: 'submit', + className: 'Button Button--primary EditGroupModal-save', + loading: this._loading, + children: 'Save Changes' + })} + {this.group.exists && this.group.id() !== Group.ADMINISTRATOR_ID ? ( + + ) : ''} +
+
+
+ ); + } + + onsubmit(e) { + e.preventDefault(); + + this._loading = true; + + this.group.save({ + nameSingular: this.nameSingular(), + namePlural: this.namePlural(), + color: this.color(), + icon: this.icon() + }).then( + () => this.hide(), + () => { + this._loading = false; + m.redraw(); + } + ); + } + + delete() { + if (confirm('Are you sure you want to delete this group? The group members will NOT be deleted.')) { + this.group.delete().then(() => m.redraw()); + this.hide(); + } + } +} diff --git a/framework/core/js/admin/src/components/PermissionDropdown.js b/framework/core/js/admin/src/components/PermissionDropdown.js new file mode 100644 index 000000000..84f005b7c --- /dev/null +++ b/framework/core/js/admin/src/components/PermissionDropdown.js @@ -0,0 +1,115 @@ +import Dropdown from 'flarum/components/Dropdown'; +import Button from 'flarum/components/Button'; +import Separator from 'flarum/components/Separator'; +import Group from 'flarum/models/Group'; +import GroupBadge from 'flarum/components/GroupBadge'; + +function badgeForId(id) { + const group = app.store.getById('groups', id); + + return group ? GroupBadge.component({group, label: null}) : ''; +} + +export default class PermissionDropdown extends Dropdown { + static initProps(props) { + super.initProps(props); + + props.className = 'PermissionDropdown'; + props.buttonClassName = 'Button Button--text'; + } + + view() { + this.props.children = []; + + const groupIds = app.permissions[this.props.permission] || []; + const everyone = groupIds.indexOf(Group.GUEST_ID) !== -1; + const members = groupIds.indexOf(Group.MEMBER_ID) !== -1; + const adminGroup = app.store.getById('groups', Group.ADMINISTRATOR_ID); + + if (everyone) { + this.props.label = 'Everyone'; + } else if (members) { + this.props.label = 'Members'; + } else { + this.props.label = [ + badgeForId(Group.ADMINISTRATOR_ID), + groupIds.map(badgeForId) + ]; + } + + if (this.props.allowGuest) { + this.props.children.push( + Button.component({ + children: 'Everyone', + icon: everyone ? 'check' : true, + onclick: () => this.save([Group.GUEST_ID]) + }) + ); + } + + this.props.children.push( + Button.component({ + children: 'Members', + icon: members ? 'check' : true, + onclick: () => this.save([Group.MEMBER_ID]) + }), + + Separator.component(), + + Button.component({ + children: [GroupBadge.component({group: adminGroup, label: null}), ' ', adminGroup.namePlural()], + icon: !everyone && !members ? 'check' : true, + disabled: !everyone && !members, + onclick: e => { + 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 => Button.component({ + children: [GroupBadge.component({group, label: null}), ' ', group.namePlural()], + icon: groupIds.indexOf(group.id()) !== -1 ? 'check' : true, + onclick: (e) => { + e.stopPropagation(); + this.toggle(group.id()); + } + })) + ); + + return super.view(); + } + + save(groupIds) { + const permission = this.props.permission; + + app.permissions[permission] = groupIds; + + app.request({ + method: 'POST', + url: app.forum.attribute('adminUrl') + '/permission', + data: {permission, groupIds} + }); + } + + toggle(groupId) { + const permission = this.props.permission; + + let groupIds = app.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); + } +} diff --git a/framework/core/js/admin/src/components/PermissionGrid.js b/framework/core/js/admin/src/components/PermissionGrid.js new file mode 100644 index 000000000..6449dd37a --- /dev/null +++ b/framework/core/js/admin/src/components/PermissionGrid.js @@ -0,0 +1,217 @@ +import Component from 'flarum/Component'; +import PermissionDropdown from 'flarum/components/PermissionDropdown'; +import ConfigDropdown from 'flarum/components/ConfigDropdown'; +import Button from 'flarum/components/Button'; +import ItemList from 'flarum/utils/ItemList'; + +export default class PermissionGrid extends Component { + constructor(...args) { + super(...args); + + this.permissions = this.permissionItems().toArray(); + } + + view() { + const scopes = this.scopeItems().toArray(); + + const permissionCells = permission => { + return scopes.map(scope => ( + + {scope.render(permission)} + + )); + }; + + return ( + + + + + {scopes.map(scope => ( + + ))} + + + + {this.permissions.map(section => ( + + + + {permissionCells(section)} + + {section.children.map(child => ( + + + {permissionCells(child)} + + ))} + + ))} +
+ {scope.label}{' '} + {scope.onremove ? Button.component({icon: 'times', className: 'Button Button--text PermissionGrid-removeScope', onclick: scope.onremove}) : ''} + {this.scopeControlItems().toArray()}
{section.label} +
{child.label} +
+ ); + } + + permissionItems() { + const items = new ItemList(); + + items.add('view', { + label: 'View the forum', + children: this.viewItems().toArray() + }); + + items.add('start', { + label: 'Start discussions', + children: this.startItems().toArray() + }); + + items.add('reply', { + label: 'Reply to discussions', + children: this.replyItems().toArray() + }); + + items.add('moderate', { + label: 'Moderate', + children: this.moderateItems().toArray() + }); + + return items; + } + + viewItems() { + const items = new ItemList(); + + items.add('view', { + label: 'View discussions', + permission: 'forum.view', + allowGuest: true + }); + + items.add('signUp', { + label: 'Sign up', + setting: () => ConfigDropdown.component({ + key: 'allow_sign_up', + options: [ + {value: '1', label: 'Open'}, + {value: '0', label: 'Closed'} + ] + }) + }); + + return items; + } + + startItems() { + const items = new ItemList(); + + items.add('start', { + label: 'Start discussions', + permission: 'forum.startDiscussion' + }); + + items.add('allowRenaming', { + label: 'Allow renaming', + setting: () => { + const minutes = parseInt(app.config.allow_renaming, 10); + + return ConfigDropdown.component({ + defaultLabel: minutes ? `For ${minutes} minutes` : 'Indefinitely', + key: 'allow_renaming', + options: [ + {value: '-1', label: 'Indefinitely'}, + {value: '10', label: 'For 10 minutes'}, + {value: 'reply', label: 'Until next reply'} + ] + }); + } + }); + + return items; + } + + replyItems() { + const items = new ItemList(); + + items.add('reply', { + label: 'Reply to discussions', + permission: 'discussion.reply' + }); + + items.add('allowPostEditing', { + label: 'Allow post editing', + setting: () => { + const minutes = parseInt(app.config.allow_post_editing, 10); + + return ConfigDropdown.component({ + defaultLabel: minutes ? `For ${minutes} minutes` : 'Indefinitely', + key: 'allow_post_editing', + options: [ + {value: '-1', label: 'Indefinitely'}, + {value: '10', label: 'For 10 minutes'}, + {value: 'reply', label: 'Until next reply'} + ] + }); + } + }); + + return items; + } + + moderateItems() { + const items = new ItemList(); + + items.add('editPosts', { + label: 'Edit posts', + permission: 'discussion.editPosts' + }); + + items.add('deletePosts', { + label: 'Delete posts', + permission: 'discussion.deletePosts' + }); + + items.add('renameDiscussions', { + label: 'Rename discussions', + permission: 'discussion.rename' + }); + + items.add('deleteDiscussions', { + label: 'Delete discussions', + permission: 'discussion.delete' + }); + + items.add('suspendUsers', { + label: 'Suspend users', + permission: 'user.suspend' + }); + + return items; + } + + scopeItems() { + const items = new ItemList(); + + items.add('global', { + label: 'Global', + render: item => { + if (item.setting) { + return item.setting(); + } else if (item.permission) { + return PermissionDropdown.component(Object.assign({}, item)); + } + + return ''; + } + }); + + return items; + } + + scopeControlItems() { + return new ItemList(); + } +} diff --git a/framework/core/js/admin/src/components/PermissionsPage.js b/framework/core/js/admin/src/components/PermissionsPage.js index e594df3a8..d4a205af9 100644 --- a/framework/core/js/admin/src/components/PermissionsPage.js +++ b/framework/core/js/admin/src/components/PermissionsPage.js @@ -1,47 +1,29 @@ import Component from 'flarum/Component'; -import Badge from 'flarum/components/Badge'; -import Select from 'flarum/components/Select'; -import Button from 'flarum/components/Button'; +import GroupBadge from 'flarum/components/GroupBadge'; +import EditGroupModal from 'flarum/components/EditGroupModal'; import Group from 'flarum/models/Group'; import icon from 'flarum/helpers/icon'; -import ItemList from 'flarum/utils/ItemList'; +import PermissionGrid from 'flarum/components/PermissionGrid'; export default class PermissionsPage extends Component { - constructor(...args) { - super(...args); - - this.groups = app.store.all('groups') - .filter(group => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(Number(group.id())) === -1); - - this.permissions = this.permissionItems().toArray(); - this.scopes = this.scopeItems().toArray(); - this.scopeControls = this.scopeControlItems().toArray(); - } - view() { - const permissionCells = permission => { - return this.scopes.map(scope => ( - - {scope.render(permission)} - - )); - }; - return (
- {this.groups.map(group => ( - - ))} - + ))} + @@ -50,220 +32,10 @@ export default class PermissionsPage extends Component {
- - - - - {this.scopes.map(scope => )} - - - - {this.permissions.map(section => ( - - - - {permissionCells(section)} - - {section.children.map(child => ( - - - {permissionCells(child)} - - ))} - - ))} -
{scope.label}{this.scopeControls}
{section.label} -
{child.label} -
+ {PermissionGrid.component()}
); } - - permissionItems() { - const items = new ItemList(); - - items.add('view', { - label: 'View the forum', - children: this.viewItems().toArray() - }); - - items.add('start', { - label: 'Start discussions', - children: this.startItems().toArray() - }); - - items.add('reply', { - label: 'Reply to discussions', - children: this.replyItems().toArray() - }); - - items.add('moderate', { - label: 'Moderate', - children: this.moderateItems().toArray() - }); - - return items; - } - - viewItems() { - const items = new ItemList(); - - items.add('view', { - label: 'View discussions', - permission: 'forum.view', - allowGuest: true - }); - - items.add('signUp', { - label: 'Sign up', - setting: Select.component({options: ['Open']}) - }); - - return items; - } - - startItems() { - const items = new ItemList(); - - items.add('start', { - label: 'Start discussions', - permission: 'forum.startDiscussion' - }); - - items.add('allowRenaming', { - label: 'Allow renaming', - setting: Select.component({options: ['Indefinitely']}) - }); - - return items; - } - - replyItems() { - const items = new ItemList(); - - items.add('reply', { - label: 'Reply to discussions', - permission: 'discussion.reply' - }); - - items.add('allowPostEditing', { - label: 'Allow post editing', - setting: Select.component({options: ['Indefinitely']}) - }); - - return items; - } - - moderateItems() { - const items = new ItemList(); - - items.add('editPosts', { - label: 'Edit posts', - permission: 'discussion.editPosts' - }); - - items.add('deletePosts', { - label: 'Delete posts', - permission: 'discussion.deletePosts' - }); - - items.add('renameDiscussions', { - label: 'Rename discussions', - permission: 'discussion.rename' - }); - - items.add('deleteDiscussions', { - label: 'Delete discussions', - permission: 'discussion.delete' - }); - - items.add('suspendUsers', { - label: 'Suspend users', - permission: 'user.suspend' - }); - - return items; - } - - scopeItems() { - const items = new ItemList(); - - const groupBadge = id => { - const group = app.store.getById('groups', id); - - return Badge.component({ - icon: group.icon(), - style: {backgroundColor: group.color()}, - label: group.namePlural() - }); - }; - - const groupBadges = groupIds => { - let content; - - if (groupIds.indexOf(String(Group.GUEST_ID)) !== -1) { - content = 'Everyone'; - } else if (groupIds.indexOf(String(Group.MEMBER_ID)) !== -1) { - content = 'Members'; - } else { - content = [ - groupBadge(Group.ADMINISTRATOR_ID), - groupIds.map(groupBadge) - ]; - } - - return ( - - ); - }; - - items.add('global', { - label: 'Global', - render: permission => { - if (permission.setting) { - return permission.setting; - } else if (permission.permission) { - const groupIds = app.forum.attribute('permissions')[permission.permission] || []; - - return groupBadges(groupIds); - } - - return ''; - } - }); - - items.add('tag1', { - label: 'Blog', - render: permission => { - if (permission.setting) { - return ''; - } else if (permission.permission) { - const groupIds = app.forum.attribute('permissions')[permission.permission] || []; - - return groupBadges(groupIds); - } - - return ''; - } - }); - - return items; - } - - scopeControlItems() { - const items = new ItemList(); - - items.add('addTag', Button.component({ - children: 'Restrict by Tag', - icon: 'plus', - className: 'Button Button--text' - })) - - return items; - } } diff --git a/framework/core/js/forum/src/components/ChangeEmailModal.js b/framework/core/js/forum/src/components/ChangeEmailModal.js index f69ce20de..36e769c1f 100644 --- a/framework/core/js/forum/src/components/ChangeEmailModal.js +++ b/framework/core/js/forum/src/components/ChangeEmailModal.js @@ -37,9 +37,9 @@ export default class ChangeEmailModal extends Modal { return (
-
-

{m.trust(app.trans('core.confirmation_email_sent', {email: {this.email()}}))}

-
+
+

{m.trust(app.trans('core.confirmation_email_sent', {email: {this.email()}}))}

+
{app.trans('core.go_to', {location: emailProviderName})} @@ -51,15 +51,15 @@ export default class ChangeEmailModal extends Modal { return (
-
-
+
+
-
+
diff --git a/framework/core/js/forum/src/components/Post.js b/framework/core/js/forum/src/components/Post.js index d6b72089a..d86151089 100644 --- a/framework/core/js/forum/src/components/Post.js +++ b/framework/core/js/forum/src/components/Post.js @@ -49,7 +49,8 @@ export default class Post extends Component { children: controls, className: 'Post-controls', buttonClassName: 'Button Button--icon Button--flat', - menuClassName: 'Dropdown-menu--right' + menuClassName: 'Dropdown-menu--right', + icon: 'ellipsis-v' }) : ''} {this.content()} diff --git a/framework/core/js/forum/src/components/SessionDropdown.js b/framework/core/js/forum/src/components/SessionDropdown.js index e0f5ceeb1..a7f8b8b58 100644 --- a/framework/core/js/forum/src/components/SessionDropdown.js +++ b/framework/core/js/forum/src/components/SessionDropdown.js @@ -62,7 +62,7 @@ export default class SessionDropdown extends Dropdown { 50 ); - if (user.groups().some(group => Number(group.id()) === Group.ADMINISTRATOR_ID)) { + if (user.groups().some(group => group.id() === Group.ADMINISTRATOR_ID)) { items.add('administration', LinkButton.component({ icon: 'wrench', diff --git a/framework/core/js/lib/Model.js b/framework/core/js/lib/Model.js index df16a5e63..327e569b5 100644 --- a/framework/core/js/lib/Model.js +++ b/framework/core/js/lib/Model.js @@ -189,9 +189,10 @@ export default class Model { method: 'DELETE', url: app.forum.attribute('apiUrl') + this.apiEndpoint(), data - }).then( - () => this.exists = false - ); + }).then(() => { + this.exists = false; + this.store.remove(this); + }); } /** @@ -214,7 +215,7 @@ export default class Model { */ static attribute(name, transform) { return function() { - const value = this.data.attributes[name]; + const value = this.data.attributes && this.data.attributes[name]; return transform ? transform(value) : value; }; diff --git a/framework/core/js/lib/Store.js b/framework/core/js/lib/Store.js index 18fbb6c0f..ddef9d480 100644 --- a/framework/core/js/lib/Store.js +++ b/framework/core/js/lib/Store.js @@ -139,6 +139,15 @@ export default class Store { return records ? Object.keys(records).map(id => records[id]) : []; } + /** + * Remove the given model from the store. + * + * @param {Model} model + */ + remove(model) { + delete this.data[model.data.type][model.id()]; + } + /** * Create a new record of the given type. * diff --git a/framework/core/js/lib/components/Badge.js b/framework/core/js/lib/components/Badge.js index 6e071a6bf..142a1ef0a 100644 --- a/framework/core/js/lib/components/Badge.js +++ b/framework/core/js/lib/components/Badge.js @@ -20,17 +20,17 @@ export default class Badge extends Component { const type = extract(attrs, 'type'); const iconName = extract(attrs, 'icon'); - attrs.className = 'Badge Badge--' + type + ' ' + (attrs.className || ''); - attrs.title = extract(attrs, 'label'); + attrs.className = 'Badge ' + (type ? 'Badge--' + type : '') + ' ' + (attrs.className || ''); + attrs.title = extract(attrs, 'label') || ''; // Give the badge a unique key so that when badges are displayed together, // and then one is added/removed, Mithril will correctly redraw the series // of badges. - attrs.key = attrs.className; + attrs.key = attrs.type; return ( - {iconName ? icon(iconName, {className: 'Badge-icon'}) : ''} + {iconName ? icon(iconName, {className: 'Badge-icon'}) : m.trust(' ')} ); } diff --git a/framework/core/js/lib/components/Button.js b/framework/core/js/lib/components/Button.js index 5991cfe27..a2ae2d7de 100644 --- a/framework/core/js/lib/components/Button.js +++ b/framework/core/js/lib/components/Button.js @@ -49,7 +49,7 @@ export default class Button extends Component { const iconName = this.props.icon; return [ - iconName ? icon(iconName, {className: 'Button-icon'}) : '', ' ', + iconName && iconName !== true ? icon(iconName, {className: 'Button-icon'}) : '', this.props.children ? {this.props.children} : '', this.props.loading ? LoadingIndicator.component({size: 'tiny', className: 'LoadingIndicator--inline'}) : '' ]; diff --git a/framework/core/js/lib/components/Dropdown.js b/framework/core/js/lib/components/Dropdown.js index e93eb4ca4..c17d96745 100644 --- a/framework/core/js/lib/components/Dropdown.js +++ b/framework/core/js/lib/components/Dropdown.js @@ -10,9 +10,10 @@ import listItems from 'flarum/helpers/listItems'; * * - `buttonClassName` A class name to apply to the dropdown toggle button. * - `menuClassName` A class name to apply to the dropdown menu. - * - `icon` The name of an icon to show in the dropdown toggle button. Defaults - * to 'ellipsis-v'. + * - `icon` The name of an icon to show in the dropdown toggle button. + * - `caretIcon` The name of an icon to show on the right of the button. * - `label` The label of the dropdown toggle button. Defaults to 'Controls'. + * - `onhide` * * The children will be displayed as a list inside of the dropdown menu. */ @@ -23,8 +24,8 @@ export default class Dropdown extends Component { props.className = props.className || ''; props.buttonClassName = props.buttonClassName || ''; props.contentClassName = props.contentClassName || ''; - props.icon = props.icon || 'ellipsis-v'; props.label = props.label || app.trans('core.controls'); + props.caretIcon = typeof props.caretIcon !== 'undefined' ? props.caretIcon : 'caret-down'; } view() { @@ -40,6 +41,29 @@ export default class Dropdown extends Component { ); } + config(isInitialized) { + if (isInitialized) return; + + // When opening the dropdown menu, work out if the menu goes beyond the + // bottom of the viewport. If it does, we will apply class to make it show + // above the toggle button instead of below it. + this.$().on('shown.bs.dropdown', () => { + const $menu = this.$('.Dropdown-menu').removeClass('Dropdown-menu--top'); + + $menu.toggleClass( + 'Dropdown-menu--top', + $menu.offset().top + $menu.height() > $(window).scrollTop() + $(window).height() + ); + }); + + this.$().on('hide.bs.dropdown', () => { + if (this.props.onhide) { + this.props.onhide(); + m.redraw(); + } + }); + } + /** * Get the template for the button. * @@ -65,9 +89,9 @@ export default class Dropdown extends Component { */ getButtonContent() { return [ - icon(this.props.icon, {className: 'Button-icon'}), + this.props.icon ? icon(this.props.icon, {className: 'Button-icon'}) : '', {this.props.label}, ' ', - icon('caret-down', {className: 'Button-caret'}) + this.props.caretIcon ? icon(this.props.caretIcon, {className: 'Button-caret'}) : '' ]; } } diff --git a/framework/core/js/lib/components/GroupBadge.js b/framework/core/js/lib/components/GroupBadge.js new file mode 100644 index 000000000..fc2ca1cc6 --- /dev/null +++ b/framework/core/js/lib/components/GroupBadge.js @@ -0,0 +1,16 @@ +import Badge from 'flarum/components/Badge'; + +export default class GroupBadge extends Badge { + static initProps(props) { + super.initProps(props); + + if (props.group) { + props.icon = props.group.icon(); + props.style = {backgroundColor: props.group.color()}; + props.label = typeof props.label === 'undefined' ? props.group.nameSingular() : props.label; + props.type = 'group--' + props.group.nameSingular(); + + delete props.group; + } + } +} diff --git a/framework/core/js/lib/components/SelectDropdown.js b/framework/core/js/lib/components/SelectDropdown.js index 4230db414..ec785c70b 100644 --- a/framework/core/js/lib/components/SelectDropdown.js +++ b/framework/core/js/lib/components/SelectDropdown.js @@ -5,23 +5,29 @@ import icon from 'flarum/helpers/icon'; * The `SelectDropdown` component is the same as a `Dropdown`, except the toggle * button's label is set as the label of the first child which has a truthy * `active` prop. + * + * ### Props + * + * - `caretIcon` + * - `defaultLabel` */ export default class SelectDropdown extends Dropdown { static initProps(props) { super.initProps(props); props.className += ' Dropdown--select'; + props.caretIcon = props.caretIcon || 'sort'; } getButtonContent() { const activeChild = this.props.children.filter(child => child.props.active)[0]; - let label = activeChild && activeChild.props.children; + let label = activeChild && activeChild.props.children || this.props.defaultLabel; if (label instanceof Array) label = label[0]; return [ {label}, ' ', - icon('sort', {className: 'Button-caret'}) + icon(this.props.caretIcon, {className: 'Button-caret'}) ]; } } diff --git a/framework/core/js/lib/models/Group.js b/framework/core/js/lib/models/Group.js index 1fb1c616f..31be54173 100644 --- a/framework/core/js/lib/models/Group.js +++ b/framework/core/js/lib/models/Group.js @@ -8,8 +8,8 @@ class Group extends mixin(Model, { icon: Model.attribute('icon') }) {} -Group.ADMINISTRATOR_ID = 1; -Group.GUEST_ID = 2; -Group.MEMBER_ID = 3; +Group.ADMINISTRATOR_ID = '1'; +Group.GUEST_ID = '2'; +Group.MEMBER_ID = '3'; export default Group; diff --git a/framework/core/less/admin/AdminNav.less b/framework/core/less/admin/AdminNav.less index 21736abe5..b9d3e1714 100644 --- a/framework/core/less/admin/AdminNav.less +++ b/framework/core/less/admin/AdminNav.less @@ -70,9 +70,11 @@ } .container { width: 100%; - padding: 0 30px; margin: 0; + .App-content & { + padding: 0 30px; + } .App-content > & { padding: 0; } diff --git a/framework/core/less/admin/EditGroupModal.less b/framework/core/less/admin/EditGroupModal.less new file mode 100644 index 000000000..e68293d5e --- /dev/null +++ b/framework/core/less/admin/EditGroupModal.less @@ -0,0 +1,23 @@ +.EditGroupModal { + .Form-group:not(:last-child) { + margin-bottom: 30px; + } + .Badge { + margin-right: 5px; + vertical-align: 2px; + } +} +.EditGroupModal-name-input { + :first-child { + margin-bottom: 1px; + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; + } + :last-child { + border-top-right-radius: 0; + border-top-left-radius: 0; + } +} +.EditGroupModal-delete { + float: right; +} diff --git a/framework/core/less/admin/PermissionsPage.less b/framework/core/less/admin/PermissionsPage.less index 70acd0125..92f74ab33 100644 --- a/framework/core/less/admin/PermissionsPage.less +++ b/framework/core/less/admin/PermissionsPage.less @@ -8,28 +8,39 @@ text-align: center; color: @text-color; font-weight: bold; + padding-left: 10px; + padding-right: 10px; } .Group-name { display: block; margin-top: 5px; + overflow: hidden; + text-overflow: ellipsis; } .Group-icon { font-size: 14px; + margin-top: 2px; } .Group--add { - border: 1px dashed @muted-color; color: @muted-color; width: auto; margin-left: 10px; font-weight: normal; + + .Group-icon { + margin-top: 8px; + } } .PermissionsPage-permissions { - padding: 30px 0; + padding: 30px 0 200px; + overflow: auto; } .PermissionGrid { + white-space: nowrap; + td, th { padding: 10px 0; text-align: left; @@ -42,7 +53,11 @@ font-weight: bold; font-size: 12px; color: @muted-color; - width: 140px; + min-width: 140px; + + &:not(:hover) .PermissionGrid-removeScope { + display: none; + } } tbody { th { @@ -62,12 +77,28 @@ } .Button { text-decoration: none; - } - td:not(:hover) { - .Select-caret, .GroupsButton-caret { - display: none; + + .Badge { + margin: -3px 0; + vertical-align: 0; } } + td:not(:hover) .Select-caret, + td:not(:hover) .Dropdown:not(.open) .Button-caret { + display: none; + } + .open .Dropdown-toggle { + .box-shadow(none); + } + } +} +.PermissionGrid-removeScope { + margin: -1px 0; +} +.PermissionDropdown { + .Badge { + margin: -3px 3px -3px 0; + vertical-align: 1px; } } .PermissionGrid-section { diff --git a/framework/core/less/admin/app.less b/framework/core/less/admin/app.less index ba08c19e1..058b10f4a 100644 --- a/framework/core/less/admin/app.less +++ b/framework/core/less/admin/app.less @@ -4,3 +4,4 @@ @import "DashboardPage.less"; @import "BasicsPage.less"; @import "PermissionsPage.less"; +@import "EditGroupModal.less"; diff --git a/framework/core/less/lib/Alert.less b/framework/core/less/lib/Alert.less index c03b90745..73c731baa 100755 --- a/framework/core/less/lib/Alert.less +++ b/framework/core/less/lib/Alert.less @@ -15,6 +15,13 @@ color: @alert-error-color; } } +.Alert--success { + background: @alert-success-bg; + + &, a, a:hover, button, button:hover { + color: @alert-success-color; + } +} .Alert-controls { list-style-type: none; padding: 0; @@ -39,6 +46,7 @@ > .Button { margin: -10px; + vertical-align: 0; } } } diff --git a/framework/core/less/lib/Badge.less b/framework/core/less/lib/Badge.less index bb67a29c6..37f5c7fc7 100755 --- a/framework/core/less/lib/Badge.less +++ b/framework/core/less/lib/Badge.less @@ -1,5 +1,5 @@ .Badge { - .Badge--size(23px); + .Badge--size(24px); border: 1px solid @body-bg; background: @muted-color; color: #fff; @@ -20,7 +20,7 @@ line-height: @size - 3px; &, .Badge-icon { - font-size: 0.56 * @size; + font-size: 0.58 * @size; } } diff --git a/framework/core/less/lib/Button.less b/framework/core/less/lib/Button.less index b16ab81d4..89c998cdd 100755 --- a/framework/core/less/lib/Button.less +++ b/framework/core/less/lib/Button.less @@ -157,12 +157,14 @@ background: transparent !important; padding: 0; color: inherit !important; + line-height: inherit; &:hover { text-decoration: underline; } &:active, - &.active { + &.active, + .open > &.Dropdown-toggle { .box-shadow(none); } } @@ -204,6 +206,7 @@ .Button-icon { font-size: 16px; vertical-align: -1px; + margin: 0; } } .SessionDropdown .Dropdown-toggle { @@ -214,6 +217,9 @@ .Avatar--size(24px); } } +.Button-icon { + margin-right: 3px; +} .Button-icon, .Button-caret { font-size: 14px; diff --git a/framework/core/less/lib/Dropdown.less b/framework/core/less/lib/Dropdown.less index e5f10a1e7..2e102d2c7 100755 --- a/framework/core/less/lib/Dropdown.less +++ b/framework/core/less/lib/Dropdown.less @@ -9,7 +9,7 @@ display: none; min-width: 160px; padding: 8px 0; - margin: 7px 0 0; + margin: 7px 0; background: @body-bg; border-radius: @border-radius; .box-shadow(0 2px 6px @shadow-color); @@ -42,8 +42,10 @@ &.hasIcon { padding-left: 40px; } - &:hover, &:focus { + &:hover { background: @control-bg; + } + &:focus { outline: none; } @@ -52,6 +54,11 @@ margin-left: -25px; margin-top: 2px; } + + &.disabled { + opacity: 0.5; + background: none; + } } &.active { > a, > button { @@ -60,6 +67,10 @@ } } } +.Dropdown-menu--top { + top: auto; + bottom: 100%; +} .Dropdown-menu--right { left: auto; right: 0; diff --git a/framework/core/less/lib/Form.less b/framework/core/less/lib/Form.less index c94a52c48..25a9d8606 100755 --- a/framework/core/less/lib/Form.less +++ b/framework/core/less/lib/Form.less @@ -13,3 +13,11 @@ .Form-group { margin-bottom: 12px; } + +.Form-group label { + font-size: 14px; + font-weight: bold; + margin-bottom: 10px; + color: @text-color; + display: block; +} diff --git a/framework/core/less/lib/Modal.less b/framework/core/less/lib/Modal.less index 45da92b69..0db85e8b7 100755 --- a/framework/core/less/lib/Modal.less +++ b/framework/core/less/lib/Modal.less @@ -101,11 +101,13 @@ color: @text-color; } - .helpText { - font-size: 14px; - line-height: 1.5em; - margin-bottom: 25px; - text-align: left; + .Form--centered { + .helpText { + font-size: 14px; + line-height: 1.5em; + margin-bottom: 25px; + text-align: left; + } } > :last-child { diff --git a/framework/core/less/lib/scaffolding.less b/framework/core/less/lib/scaffolding.less index 1ad846488..f1d0aa4ea 100755 --- a/framework/core/less/lib/scaffolding.less +++ b/framework/core/less/lib/scaffolding.less @@ -88,6 +88,7 @@ legend { font-size: 14px; font-weight: bold; margin-bottom: 10px; + color: @text-color; } input[type="search"] { -webkit-appearance: none; diff --git a/framework/core/less/lib/variables.less b/framework/core/less/lib/variables.less index 4e3da2391..80fcead00 100755 --- a/framework/core/less/lib/variables.less +++ b/framework/core/less/lib/variables.less @@ -68,6 +68,9 @@ @alert-error-bg: #d83e3e; @alert-error-color: #fff; +@alert-success-bg: #B4F1AF; +@alert-success-color: #33722D; + .define-header(@config-colored-header); .define-header(false) { @header-bg: @body-bg; diff --git a/framework/core/src/Admin/Actions/UpdateConfigAction.php b/framework/core/src/Admin/Actions/UpdateConfigAction.php new file mode 100644 index 000000000..31cb62d06 --- /dev/null +++ b/framework/core/src/Admin/Actions/UpdateConfigAction.php @@ -0,0 +1,42 @@ +settings = $settings; + } + + /** + * {@inheritdoc} + */ + public function handle(Request $request, array $routeParams = []) + { + $config = array_get($request->getAttributes(), 'config', []); + + // TODO: throw HTTP status 400 or 422 + if (! is_array($config)) { + throw new Exception; + } + + foreach ($config as $k => $v) { + $this->settings->set($k, $v); + } + + return $this->success(); + } +} diff --git a/framework/core/src/Admin/Actions/UpdatePermissionAction.php b/framework/core/src/Admin/Actions/UpdatePermissionAction.php new file mode 100644 index 000000000..80379a19a --- /dev/null +++ b/framework/core/src/Admin/Actions/UpdatePermissionAction.php @@ -0,0 +1,29 @@ +getAttributes(); + $permission = array_get($input, 'permission'); + $groupIds = array_get($input, 'groupIds'); + + Permission::where('permission', $permission)->delete(); + + Permission::insert(array_map(function ($groupId) use ($permission) { + return [ + 'permission' => $permission, + 'group_id' => $groupId + ]; + }, $groupIds)); + + return $this->success(); + } +} diff --git a/framework/core/src/Admin/AdminServiceProvider.php b/framework/core/src/Admin/AdminServiceProvider.php index be60a09d3..a1fd767fb 100644 --- a/framework/core/src/Admin/AdminServiceProvider.php +++ b/framework/core/src/Admin/AdminServiceProvider.php @@ -50,6 +50,18 @@ class AdminServiceProvider extends ServiceProvider 'flarum.admin.index', $this->action('Flarum\Admin\Actions\ClientAction') ); + + $routes->post( + '/config', + 'flarum.admin.updateConfig', + $this->action('Flarum\Admin\Actions\UpdateConfigAction') + ); + + $routes->post( + '/permission', + 'flarum.admin.updatePermission', + $this->action('Flarum\Admin\Actions\UpdatePermissionAction') + ); } protected function action($class) diff --git a/framework/core/src/Api/Serializers/ForumSerializer.php b/framework/core/src/Api/Serializers/ForumSerializer.php index 7210e4c83..b52eb3709 100644 --- a/framework/core/src/Api/Serializers/ForumSerializer.php +++ b/framework/core/src/Api/Serializers/ForumSerializer.php @@ -32,6 +32,7 @@ class ForumSerializer extends Serializer ]; if ($this->actor->isAdmin()) { + $attributes['adminUrl'] = Core::config('admin_url'); } return $attributes; diff --git a/framework/core/src/Core/Settings/DatabaseSettingsRepository.php b/framework/core/src/Core/Settings/DatabaseSettingsRepository.php index ec726eefe..dd45a008c 100644 --- a/framework/core/src/Core/Settings/DatabaseSettingsRepository.php +++ b/framework/core/src/Core/Settings/DatabaseSettingsRepository.php @@ -29,6 +29,10 @@ class DatabaseSettingsRepository implements SettingsRepository public function set($key, $value) { - $this->database->table('config')->where('key', $key)->update(['value' => $value]); + $query = $this->database->table('config')->where('key', $key); + + $method = $query->exists() ? 'update' : 'insert'; + + $query->$method(compact('key', 'value')); } }