From ad1a3d2ba98016ec745fbb764ee1cea32dff5441 Mon Sep 17 00:00:00 2001 From: Aurélien Bompard Date: Apr 05 2017 12:44:22 +0000 Subject: [PATCH 1/9] Use url_for in templates --- diff --git a/hubs/templates/hubs.html b/hubs/templates/hubs.html index 231b364..30b7078 100644 --- a/hubs/templates/hubs.html +++ b/hubs/templates/hubs.html @@ -17,14 +17,14 @@
{% endif %} From b439153a22f78395c117fd222111af7d4d14097a Mon Sep 17 00:00:00 2001 From: Aurélien Bompard Date: Apr 06 2017 23:29:35 +0000 Subject: [PATCH 2/9] Add an authorization framework --- diff --git a/hubs/app.py b/hubs/app.py index c8b5aca..7b28c4c 100644 --- a/hubs/app.py +++ b/hubs/app.py @@ -95,11 +95,14 @@ def check_auth(): ) flask.session.auth.avatar = username2avatar( flask.session.auth.nickname) - flask.session.auth.user = hubs.models.User.by_username( - flask.session.auth.nickname) + + user = hubs.models.User.get_or_create( + flask.g.db, username=flask.session.auth.nickname, + fullname=flask.session.auth.fullname) + flask.session.auth.user = user flask.g.auth = flask.session.auth else: - flask.g.auth = munch.Munch(logged_in=False) + flask.g.auth = munch.Munch(logged_in=False, user=None) # Register widgets import hubs.widgets # noqa diff --git a/hubs/authz.py b/hubs/authz.py new file mode 100644 index 0000000..ebd85ca --- /dev/null +++ b/hubs/authz.py @@ -0,0 +1,88 @@ +from __future__ import absolute_import, unicode_literals + +from enum import IntEnum +from flask import current_app + + +#class Authorization(object): +# +# def __init__(self, app=None, db=None): +# self.db = db +# if app is not None: +# self.init_app(app) +# +# def init_app(self): +# app.config.setdefault("AUTHZ_ADAPTER", None) + + +class AccessLevel(IntEnum): + anonymous = 0 + logged_in = 1 + member = 2 + sponsor = 3 + owner = 4 + + +PERMISSIONS = { + "hub.public.view": AccessLevel.anonymous, + "hub.preview.view": AccessLevel.anonymous, + "hub.private.view": AccessLevel.member, + "hub.users.manage": AccessLevel.sponsor, + "hub.config": AccessLevel.owner, + "widget.public.view": AccessLevel.anonymous, + "widget.restricted.view": AccessLevel.logged_in, +} + + +class ObjectAuthzMixin(object): + + def allows(self, user, action): + site_admins = current_app.config.get("SITE_ADMINS", []) + if user is not None and user.username in site_admins: + return True + user_level = self._get_auth_access_level(user) + permission = self._get_auth_permission_name(action) + min_level = PERMISSIONS[permission] + if user_level >= min_level: + return True + return False + + def _get_auth_access_level(self, user): + if user is None: + return AccessLevel.anonymous + levels = [ + self._get_auth_user_access_level(user), + self._get_auth_group_access_level(user), + ] + return max(l for l in levels if l is not None) + + def _get_auth_user_access_level(self, user): + return AccessLevel.logged_in + + def _get_auth_group_access_level(self, user): + group = self._get_auth_group() + user_roles = self._get_auth_user_roles(user) + try: + user_level = user_roles[group] + except KeyError: + # No role for this group + return None + try: + return AccessLevel[user_level] + except KeyError: + # Unsupported role + return None + + def _get_auth_group(self): + raise NotImplementedError + + def _get_auth_user_roles(self, user): + return get_user_roles(user) + + def _get_auth_permission_name(self, action): + return action + + +def get_user_roles(user): + # TODO: use the new API + return {g: "member" for g in user.groups} diff --git a/hubs/migrations/versions/bed8bbc0f78e_authz.py b/hubs/migrations/versions/bed8bbc0f78e_authz.py new file mode 100644 index 0000000..525c2ad --- /dev/null +++ b/hubs/migrations/versions/bed8bbc0f78e_authz.py @@ -0,0 +1,63 @@ +# This Alembic database migration is part of the Fedora Hubs project. +# Copyright (C) +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +""" +Authorization + +Revision ID: bed8bbc0f78e +Revises: 6d4862ec4f93 +Create Date: 2017-03-30 21:38:03.629462 +""" + +from __future__ import absolute_import, unicode_literals + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'bed8bbc0f78e' +down_revision = '6d4862ec4f93' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column( + 'hubs_config', + sa.Column('auth_group', sa.String(length=256), nullable=True)) + op.add_column( + 'hubs_config', + sa.Column('visibility', sa.Enum('public', 'preview', 'private'), + nullable=True)) + op.add_column( + 'widgets', + sa.Column('visibility', sa.Enum('public', 'restricted'), + nullable=True)) + op.execute("UPDATE hubs_config SET visibility = 'public'") + op.execute("UPDATE widgets SET visibility = 'public'") + with op.batch_alter_table("hubs_config") as batch_op: + batch_op.alter_column("visibility", nullable=False) + with op.batch_alter_table("widgets") as batch_op: + batch_op.alter_column("visibility", nullable=False) + + +def downgrade(): + # http://alembic.zzzcomputing.com/en/latest/batch.html + with op.batch_alter_table("hubs_config") as batch_op: + batch_op.drop_column('visibility') + batch_op.drop_column('auth_group') + with op.batch_alter_table("widgets") as batch_op: + batch_op.drop_column('visibility') diff --git a/hubs/models.py b/hubs/models.py index 48ed695..758a64f 100644 --- a/hubs/models.py +++ b/hubs/models.py @@ -47,6 +47,7 @@ import fedmsg.utils import hubs.defaults import hubs.widgets +from hubs.authz import ObjectAuthzMixin, AccessLevel from hubs.utils import username2avatar @@ -135,7 +136,7 @@ class Association(BASE): .first() -class Hub(BASE): +class Hub(ObjectAuthzMixin, BASE): __tablename__ = 'hubs' name = sa.Column(sa.String(50), primary_key=True) created_on = sa.Column(sa.DateTime, default=datetime.datetime.utcnow) @@ -221,9 +222,6 @@ class Hub(BASE): session.delete(association) session.commit() - def is_admin(self, user): - return user in self.owners - @classmethod def by_name(cls, name): return cls.query.filter_by(name=name).first() @@ -264,19 +262,39 @@ class Hub(BASE): hubs.defaults.add_group_widgets(session, hub, name, summary, **extra) return hub - @property - def left_widgets(self): - return sorted( - [w for w in self.widgets - if w.plugin in hubs.widgets.registry and w.left], - key=lambda w: w.index) + def _get_auth_user_access_level(self, user): + # overridden to handle user hubs. + if self.user_hub and user.username == self.name: + return AccessLevel.owner + return super(Hub, self)._get_auth_user_access_level(user) + + def _get_auth_group(self): + # While we're local-only, just use the hub name: + return self.name + # When the CAIAPI is in place we will be able to use the auth_group + # setting: + # group = self.config.auth_group + # if group is None: + # group = self.name + # return group + + def _get_auth_user_roles(self, user): + """Override until we can get groups from FAS. + + This will not return all roles, only the one for the current hub, but + that fine since it's the only one we're interested in when this method + is called. + """ + return { + assoc.hub.name: assoc.role + for assoc in self.associations + if assoc.user.username == user.username + } - @property - def right_widgets(self): - return sorted( - [w for w in self.widgets - if w.plugin in hubs.widgets.registry and not w.left], - key=lambda w: w.index) + def _get_auth_permission_name(self, action): + if action == "view": + action = "{}.view".format(self.config.visibility) + return "hub.{}".format(action) def __json__(self): return { @@ -292,7 +310,11 @@ class Hub(BASE): class HubConfig(BASE): + __tablename__ = 'hubs_config' + + VISIBILITY = ["public", "preview", "private"] + id = sa.Column(sa.Integer, primary_key=True) hub_id = sa.Column(sa.String(50), sa.ForeignKey('hubs.name'), nullable=False) @@ -303,6 +325,9 @@ class HubConfig(BASE): header_img = sa.Column(sa.String(256), default=randomheader) chat_channel = sa.Column(sa.String(256), nullable=True) chat_domain = sa.Column(sa.String(256), nullable=True) + auth_group = sa.Column(sa.String(256), nullable=True) + visibility = sa.Column( + sa.Enum(*VISIBILITY), default="public", nullable=False) def __json__(self): return { @@ -311,6 +336,7 @@ class HubConfig(BASE): 'avatar': self.avatar, 'chat_channel': self.chat_channel, 'chat_domain': self.chat_domain, + 'visibility': self.visibility, } @property @@ -326,8 +352,12 @@ def _widget_config_default(context): ])) -class Widget(BASE): +class Widget(ObjectAuthzMixin, BASE): + __tablename__ = 'widgets' + + VISIBILITY = ["public", "restricted"] + idx = sa.Column(sa.Integer, primary_key=True) plugin = sa.Column(sa.String(50), nullable=False) created_on = sa.Column(sa.DateTime, default=datetime.datetime.utcnow) @@ -336,6 +366,8 @@ class Widget(BASE): index = sa.Column(sa.Integer, nullable=False) left = sa.Column(sa.Boolean, nullable=False, default=False) + visibility = sa.Column( + sa.Enum(*VISIBILITY), default="public", nullable=False) @classmethod def by_idx(cls, idx): @@ -359,6 +391,21 @@ class Widget(BASE): def config(self, config): self._config = json.dumps(config) + def _get_auth_access_level(self, user): + return self.hub._get_auth_access_level(user) + + def _get_auth_user_roles(self, user): + """Override until we can get groups from FAS""" + return self.hub._get_auth_user_roles(user) + + def _get_auth_permission_name(self, action): + if action != "view": + return self.hub._get_auth_permission_name(action) + hub_visibility = self.hub.config.visibility + if hub_visibility != "preview": + return "hub.{}.view".format(hub_visibility) + return "widget.{}.view".format(self.visibility) + def __json__(self): module = hubs.widgets.registry[self.plugin] root_view = module.get_views()["root"](module) diff --git a/hubs/static/client/app/core/HubConfigPanel.jsx b/hubs/static/client/app/core/HubConfigPanel.jsx index 517042f..ddc22d3 100644 --- a/hubs/static/client/app/core/HubConfigPanel.jsx +++ b/hubs/static/client/app/core/HubConfigPanel.jsx @@ -3,6 +3,7 @@ import { IntlProvider, defineMessages, FormattedMessage, + FormattedHTMLMessage, } from 'react-intl'; import TabPanel from '../components/TabPanel.jsx'; @@ -20,21 +21,51 @@ const messages = defineMessages({ id: "hubs.core.config.general.summary", defaultMessage: "Summary", }, + general_summary_help: { + id: "hubs.core.config.general.summary_help", + defaultMessage: "This text will be displayed at the top of the hub.", + }, general_left_width: { id: "hubs.core.config.general.left_width", defaultMessage: "Left width", }, + general_left_width_help: { + id: "hubs.core.config.general.left_width_help", + defaultMessage: ( + "Change the proportions of you hub's columns by setting the width of " + +"the left column (the right column's width will be {right_width})." + ), + }, + general_visibility: { + id: "hubs.core.config.general.visibility", + defaultMessage: "Visibility", + }, + general_visibility_help: { + id: "hubs.core.config.general.visibility_help", + defaultMessage: ( + "Public: the hub is visible to anyone.
" + +"Private: the hub is only visible to members.
" + +"Preview: some widgets can be made visible to logged-in users only." + ), + }, general_avatar: { id: "hubs.core.config.general.avatar", defaultMessage: "Avatar", }, + general_avatar_help: { + id: "hubs.core.config.general.avatar_help", + defaultMessage: "An URL to you hub's main image or logo." + }, chat_title: { id: "hubs.core.config.chat.title", defaultMessage: "Chat Settings", }, chat_intro_1: { id: "hubs.core.config.chat.intro1", - defaultMessage: "If your team or project has an IRC channel associated with it, you can connect this hub to it via the Hubs chat widget.", + defaultMessage: ( + "If your team or project has an IRC channel associated with it, you " + +"can connect this hub to it via the Hubs chat widget." + ), }, chat_intro_2: { id: "hubs.core.config.chat.intro2", @@ -72,6 +103,7 @@ export class GeneralPanel extends React.Component { render() { var stillLoading = (typeof this.props.hubConfig.summary === "undefined"); + var visibilities = this.props.generalConfig.hub_visibility || []; return (
@@ -87,6 +119,9 @@ export class GeneralPanel extends React.Component { onChange={this.props.handleChange} value={this.props.hubConfig.summary || ""} /> +

+ +

+
+ + +

+ +

); diff --git a/hubs/templates/errors/hub.html b/hubs/templates/errors/hub.html new file mode 100644 index 0000000..36506e1 --- /dev/null +++ b/hubs/templates/errors/hub.html @@ -0,0 +1,8 @@ +{% extends "hubs.html" %} + +{% block hub_content %} + +

{{ msg }}

+ +{% endblock %} + diff --git a/hubs/templates/hubs.html b/hubs/templates/hubs.html index 30b7078..35ab48a 100644 --- a/hubs/templates/hubs.html +++ b/hubs/templates/hubs.html @@ -48,7 +48,7 @@
- {% if g.auth.logged_in and hub.is_admin(g.auth.user) %} + {% if hub.allows(g.auth.user, "config") %}
diff --git a/hubs/tests/__init__.py b/hubs/tests/__init__.py index 67d82fa..edd2ba5 100644 --- a/hubs/tests/__init__.py +++ b/hubs/tests/__init__.py @@ -120,7 +120,7 @@ def auth_set(APP, auth): g.auth = auth g.oidc_id_token = None if not auth: - g.auth = munch.Munch(logged_in=False) + g.auth = munch.Munch(logged_in=False, user=None) with appcontext_pushed.connected_to(handler, APP): yield @@ -136,9 +136,7 @@ class FakeUser(object): """ self.username = username self.booksmarks = [] - - def __getitem__(self, key): - return self.dic[key] + self.groups = [] class FakeAuthorization(object): diff --git a/hubs/tests/test_fedora_hubs_flask_api.py b/hubs/tests/test_fedora_hubs_flask_api.py index 2c8f1ff..778ea54 100644 --- a/hubs/tests/test_fedora_hubs_flask_api.py +++ b/hubs/tests/test_fedora_hubs_flask_api.py @@ -66,7 +66,9 @@ class HubsAPITest(hubs.tests.APPTest): result.get_data(as_text=True)) def test_hub_json(self): - result = self.app.get('/ralph/json', follow_redirects=True) + user = tests.FakeAuthorization('ralph') + with tests.auth_set(app, user): + result = self.app.get('/ralph/json', follow_redirects=True) # assert the status code of the response self.assertEqual(result.status_code, 200) data = { diff --git a/hubs/tests/test_models.py b/hubs/tests/test_models.py index 8669160..c3279cd 100644 --- a/hubs/tests/test_models.py +++ b/hubs/tests/test_models.py @@ -153,15 +153,3 @@ class ModelTest(hubs.tests.APPTest): session=self.session, username=username, visited_hub=hub) - - def test_removed_widget(self): - hub = hubs.models.Hub.get("ralph") - widget = hubs.models.Widget( - hub=hub, plugin="does-not-exist", - left=True, index=-1, _config="{}") - self.session.add(widget) - self.assertNotIn( - "does-not-exist", [w.plugin for w in hub.left_widgets]) - widget.left = False - self.assertNotIn( - "does-not-exist", [w.plugin for w in hub.right_widgets]) diff --git a/hubs/tests/test_view_utils.py b/hubs/tests/test_view_utils.py new file mode 100644 index 0000000..d5e8218 --- /dev/null +++ b/hubs/tests/test_view_utils.py @@ -0,0 +1,24 @@ +from __future__ import unicode_literals + +import hubs.models +import hubs.tests +from hubs.views.utils import get_visible_widgets + + +class ModelTest(hubs.tests.APPTest): + + def test_removed_widget(self): + hub = hubs.models.Hub.get("ralph") + widget = hubs.models.Widget( + hub=hub, plugin="does-not-exist", + left=True, index=-1, _config="{}") + self.session.add(widget) + user = hubs.tests.FakeAuthorization('ralph') + with hubs.tests.auth_set(self.app.application, user): + with self.app.application.app_context(): + widgets = get_visible_widgets(hub) + self.assertNotIn( + "does-not-exist", [w.plugin for w in widgets["left"]]) + widget.left = False + self.assertNotIn( + "does-not-exist", [w.plugin for w in widgets["right"]]) diff --git a/hubs/tests/test_widgets/test_about.py b/hubs/tests/test_widgets/test_about.py index d86435f..edab332 100644 --- a/hubs/tests/test_widgets/test_about.py +++ b/hubs/tests/test_widgets/test_about.py @@ -2,16 +2,19 @@ from __future__ import unicode_literals import json -import hubs.tests.test_widgets +from hubs.tests import FakeAuthorization, auth_set +from hubs.tests.test_widgets import WidgetTest -class TestBadges(hubs.tests.test_widgets.WidgetTest): +class TestAbout(WidgetTest): plugin = 'about' # The name in hubs.widgets.registry def test_data_simple(self): widget = self.widget_instance('ralph', self.plugin) - response = self.app.get('/ralph/%i/json' % widget.idx) - assert response.status_code == 200, response.status_code + user = FakeAuthorization('ralph') + with auth_set(self.app.application, user): + response = self.app.get('/ralph/%i/json' % widget.idx) + self.assertEqual(response.status_code, 200) data = json.loads(response.get_data(as_text=True)) self.assertDictEqual(data['data'], { 'text': 'Testing.', diff --git a/hubs/tests/test_widgets/test_badges.py b/hubs/tests/test_widgets/test_badges.py index 3521ec2..30284fd 100644 --- a/hubs/tests/test_widgets/test_badges.py +++ b/hubs/tests/test_widgets/test_badges.py @@ -2,16 +2,20 @@ from __future__ import unicode_literals import json -import hubs.tests.test_widgets +import hubs.widgets +from hubs.tests import FakeAuthorization, auth_set +from hubs.tests.test_widgets import WidgetTest -class TestBadges(hubs.tests.test_widgets.WidgetTest): +class TestBadges(WidgetTest): plugin = 'badges' # The name in hubs.widgets.registry def test_data_simple(self): widget = self.widget_instance('ralph', self.plugin) - response = self.app.get('/ralph/%i/json' % widget.idx) - assert response.status_code == 200, response.status_code + user = FakeAuthorization('ralph') + with auth_set(self.app.application, user): + response = self.app.get('/ralph/%i/json' % widget.idx) + self.assertEqual(response.status_code, 200) data = json.loads(response.get_data(as_text=True)) self.assertEquals(data['plugin'], 'badges') self.assertIn('assertions', data['data'].keys()) diff --git a/hubs/tests/test_widgets/test_contact.py b/hubs/tests/test_widgets/test_contact.py index a934c38..3e63953 100644 --- a/hubs/tests/test_widgets/test_contact.py +++ b/hubs/tests/test_widgets/test_contact.py @@ -73,7 +73,9 @@ class ContactsTest(APPTest): def test_data_simple(self): widget = self.widget_instance('ralph', self.plugin) - response = self.app.get('/ralph/%i/json' % widget.idx) + user = FakeAuthorization('ralph') + with auth_set(self.app.application, user): + response = self.app.get('/ralph/%i/json' % widget.idx) self.assertEqual(response.status_code, 200) data = json.loads(response.get_data(as_text=True)) self.assertDictEqual(data['data'], { diff --git a/hubs/tests/test_widgets/test_fedmsgstats.py b/hubs/tests/test_widgets/test_fedmsgstats.py index cb3d5d3..4861b49 100644 --- a/hubs/tests/test_widgets/test_fedmsgstats.py +++ b/hubs/tests/test_widgets/test_fedmsgstats.py @@ -2,16 +2,19 @@ from __future__ import unicode_literals import json -import hubs.tests.test_widgets +from hubs.tests import FakeAuthorization, auth_set +from hubs.tests.test_widgets import WidgetTest -class TestFedmsgStats(hubs.tests.test_widgets.WidgetTest): +class TestFedmsgStats(WidgetTest): plugin = 'fedmsgstats' # The name in hubs.widgets.registry def test_data_simple(self): widget = self.widget_instance('ralph', self.plugin) - response = self.app.get('/ralph/%i/json' % widget.idx) - assert response.status_code == 200, response.status_code + user = FakeAuthorization('ralph') + with auth_set(self.app.application, user): + response = self.app.get('/ralph/%i/json' % widget.idx) + self.assertEqual(response.status_code, 200) data = json.loads(response.get_data(as_text=True)) self.assertDictEqual(data['data'], { u'fedmsgs': 83854, diff --git a/hubs/tests/test_widgets/test_library.py b/hubs/tests/test_widgets/test_library.py index 0e09a7d..eaac331 100644 --- a/hubs/tests/test_widgets/test_library.py +++ b/hubs/tests/test_widgets/test_library.py @@ -2,16 +2,18 @@ from __future__ import unicode_literals import json -import hubs.tests.test_widgets -import hubs.models +from hubs.tests import FakeAuthorization, auth_set +from hubs.tests.test_widgets import WidgetTest -class TestLibrary(hubs.tests.test_widgets.WidgetTest): +class TestLibrary(WidgetTest): plugin = 'library' # The name in hubs.widgets.registry def test_data_simple(self): widget = self.widget_instance('ralph', self.plugin) - response = self.app.get('/ralph/%i/json' % widget.idx) + user = FakeAuthorization('ralph') + with auth_set(self.app.application, user): + response = self.app.get('/ralph/%i/json' % widget.idx) self.assertEqual(response.status_code, 200) data = json.loads(response.get_data(as_text=True)) expected_dict = { diff --git a/hubs/tests/test_widgets/test_meetings.py b/hubs/tests/test_widgets/test_meetings.py index 78c41b9..8c2784b 100644 --- a/hubs/tests/test_widgets/test_meetings.py +++ b/hubs/tests/test_widgets/test_meetings.py @@ -2,16 +2,19 @@ from __future__ import unicode_literals import json -import hubs.tests.test_widgets +from hubs.tests import FakeAuthorization, auth_set +from hubs.tests.test_widgets import WidgetTest -class TestMeetings(hubs.tests.test_widgets.WidgetTest): +class TestMeetings(WidgetTest): plugin = 'meetings' def test_data_simple(self): team = 'i18n' widget = self.widget_instance(team, self.plugin) - response = self.app.get('/%s/%i/json' % (team, widget.idx)) + user = FakeAuthorization('ralph') + with auth_set(self.app.application, user): + response = self.app.get('/%s/%i/json' % (team, widget.idx)) self.assertEqual(200, response.status_code) data = json.loads(response.get_data(as_text=True)) calendar_name = data['data']['calendar'] @@ -20,8 +23,10 @@ class TestMeetings(hubs.tests.test_widgets.WidgetTest): def test_render_simple(self): team = 'i18n' widget = self.widget_instance(team, self.plugin) - response = self.app.get('/%s/w/%s/%i/' - % (team, self.plugin, widget.idx)) + user = FakeAuthorization('ralph') + with auth_set(self.app.application, user): + response = self.app.get('/%s/w/%s/%i/' + % (team, self.plugin, widget.idx)) self.assertEqual(200, response.status_code) self.assertIn('i18n', response.get_data(as_text=True)) self.assertIn('Request A New Meeting', diff --git a/hubs/views/hub.py b/hubs/views/hub.py index 4227357..75b5764 100644 --- a/hubs/views/hub.py +++ b/hubs/views/hub.py @@ -5,19 +5,25 @@ import flask import hubs.models from hubs.app import app -from .utils import get_hub, login_required, RequestValidator +from .utils import ( + get_hub, get_visible_widgets, login_required, RequestValidator, + require_hub_access, + ) @app.route('/') @app.route('//') +@require_hub_access("view") def hub(name): hub = get_hub(name, load_config=True) + widgets = get_visible_widgets(hub) return flask.render_template( - 'hubs.html', hub=hub, edit=False) + 'hubs.html', hub=hub, widgets=widgets, edit=False) @app.route('//json/') @app.route('//json') +@require_hub_access("view", json=True) def hub_json(name): hub = get_hub(name) response = flask.jsonify(hub.__json__()) @@ -27,21 +33,22 @@ def hub_json(name): @app.route('//edit', methods=['GET', 'POST']) @login_required +@require_hub_access("config") def hub_edit(name): + hub = get_hub(name, load_config=True) if flask.request.method == 'POST': - return hub_edit_post(name) + return hub_edit_post(hub) else: - return hub_edit_get(name) + return hub_edit_get(hub) -def hub_edit_get(name): - hub = get_hub(name, load_config=True) +def hub_edit_get(hub): + widgets = get_visible_widgets(hub) return flask.render_template( - 'hubs.html', hub=hub, edit=True) + 'hubs.html', hub=hub, widgets=widgets, edit=True) -def hub_edit_post(name): - hub = get_hub(name) +def hub_edit_post(hub): is_js = flask.request.form.get('js', False) error = False @@ -138,11 +145,12 @@ def hub_edit_post(name): else: return ('ok', 200) else: - return flask.redirect(flask.url_for('hub', name=name)) + return flask.redirect(flask.url_for('hub', name=hub.name)) @app.route('//config', methods=['GET', 'POST']) @login_required +@require_hub_access("config", json=True) def hub_config(name): hub = get_hub(name) config = { @@ -151,6 +159,7 @@ def hub_config(name): "hub": {}, "general": { "chat_networks": app.config["CHAT_NETWORKS"], + "hub_visibility": hubs.models.HubConfig.VISIBILITY, }, }, } diff --git a/hubs/views/root.py b/hubs/views/root.py index 1250490..74215da 100644 --- a/hubs/views/root.py +++ b/hubs/views/root.py @@ -48,9 +48,6 @@ def login(): else: return_point = default - hubs.models.User.get_or_create( - flask.g.db, username=flask.g.auth.nickname, - fullname=flask.g.auth.fullname) flask.flash('Login successful', 'success') return flask.redirect(return_point) diff --git a/hubs/views/user.py b/hubs/views/user.py index 62866ae..5012aea 100644 --- a/hubs/views/user.py +++ b/hubs/views/user.py @@ -6,14 +6,18 @@ import hubs.models import hubs.stream from hubs.app import app -from .utils import login_required, get_hub +from .utils import ( + login_required, get_hub, get_visible_widgets, require_hub_access, + ) @app.route('//stream') @app.route('//stream/') @login_required +@require_hub_access("view") def stream(name): hub = get_hub(name) + widgets = get_visible_widgets(hub) saved = hubs.models.SavedNotification.by_username(name) saved = [n.__json__() for n in saved] @@ -23,6 +27,7 @@ def stream(name): return flask.render_template( 'stream.html', hub=hub, + widgets=widgets, saved=json.dumps(saved), actions=actions ) diff --git a/hubs/views/utils.py b/hubs/views/utils.py index a90e468..0a89595 100644 --- a/hubs/views/utils.py +++ b/hubs/views/utils.py @@ -1,15 +1,16 @@ from __future__ import unicode_literals import datetime -import flask import functools import logging -from hubs.models import Hub, Widget +import flask from six.moves.urllib import parse as urlparse from sqlalchemy.orm import joinedload from sqlalchemy.orm.exc import NoResultFound +from hubs.models import Hub, Widget + log = logging.getLogger(__name__) @@ -44,6 +45,27 @@ def get_widget_instance(hub, idx, session=None): flask.abort(404) +def get_visible_widgets(hub): + from hubs.widgets import registry + widgets = {"left": [], "right": []} + for widget in hub.widgets: + if widget.plugin not in registry: + continue # disabled widget + try: + user = flask.g.auth.user + except AttributeError: + user = None + if not widget.allows(user, "view"): + continue + if widget.left: + widgets["left"].append(widget) + else: + widgets["right"].append(widget) + widgets["left"].sort(key=lambda w: w.index) + widgets["right"].sort(key=lambda w: w.index) + return widgets + + class WidgetConfigError(Exception): pass @@ -127,6 +149,38 @@ def login_required(function): return decorated_function +def require_hub_access(action, url_param="name", json=False): + """Check access to the hub for the specified action.""" + def decorator(function): + @functools.wraps(function) + def wrapper(*args, **kwargs): + hub_name = kwargs[url_param] + hub = get_hub(hub_name, load_config=True) + try: + user = flask.g.auth.user + except AttributeError: + user = None + if not hub.allows(user, action): + if action == "view": + msg = "This hub is for members only." + elif action == "config": + msg = "You are not allowed to configure this hub." + else: + msg = "Access forbidden." + if json: + result = flask.jsonify({ + "status": "ERROR", "message": msg, "hub": hub.name, + }) + else: + result = flask.render_template( + 'errors/hub.html', hub=hub, msg=msg, + ) + return result, 403 + return function(*args, **kwargs) + return wrapper + return decorator + + def is_safe_url(target): """ Checks that the target url is safe and sending to the current website not some other malicious one. diff --git a/hubs/views/widget.py b/hubs/views/widget.py index 39bb8f7..35fb12a 100644 --- a/hubs/views/widget.py +++ b/hubs/views/widget.py @@ -8,11 +8,13 @@ from pkg_resources import resource_isdir from .utils import ( create_widget_instance, configure_widget_instance, get_hub, get_widget_instance, login_required, WidgetConfigError, - get_position, + get_position, require_hub_access, ) @app.route('//add', methods=['GET', 'POST']) +@login_required +@require_hub_access("config") def hub_add_widget(name): hub = get_hub(name) position = get_position() @@ -45,6 +47,8 @@ def hub_add_widget(name): @app.route('//add/', methods=['GET', 'POST']) +@login_required +@require_hub_access("config", url_param="hub") def widget_add(hub, widget): hub = get_hub(hub) position = get_position() @@ -65,6 +69,7 @@ def widget_add(hub, widget): @app.route('///edit', methods=['GET', 'POST']) @login_required +@require_hub_access("config", url_param="hub") def widget_edit(hub, idx): widget_instance = get_widget_instance(hub, idx) if flask.request.method == 'POST': @@ -85,6 +90,7 @@ def widget_edit(hub, idx): @app.route('///delete', methods=['POST']) @login_required +@require_hub_access("config", url_param="hub") def widget_edit_delete(hub, idx): ''' Remove a widget from a hub. ''' widget_instance = get_widget_instance(hub, idx) @@ -100,6 +106,7 @@ def widget_edit_delete(hub, idx): @app.route('///json') +@require_hub_access("view", url_param="hub") def widget_json(hub, idx): widget = get_widget_instance(hub, idx) response = flask.jsonify(widget.__json__()) diff --git a/hubs/widgets/halp/views.py b/hubs/widgets/halp/views.py index 264da3d..f1aae71 100644 --- a/hubs/widgets/halp/views.py +++ b/hubs/widgets/halp/views.py @@ -7,7 +7,7 @@ import flask from flask.signals import template_rendered, before_render_template from hubs.models import Hub -from hubs.views.utils import get_hub +from hubs.views.utils import get_hub, require_hub_access from hubs.widgets import registry from hubs.widgets.base import WidgetView from .functions import GetRequests @@ -33,9 +33,9 @@ class DataView(WidgetView): name = "data" url_rules = ["data"] + json = True - def dispatch_request(self, *args, **kwargs): - instance = self._get_instance(*args, **kwargs) + def get_context(self, instance, *args, **kwargs): get_requests = GetRequests(instance) data = {"requests": []} hubs_filter = flask.request.args.getlist("hubs") @@ -55,7 +55,7 @@ class DataView(WidgetView): if len(data["requests"]) == 3: # Only 3 requests on the main widget. break - return flask.jsonify(data) + return data class SearchView(WidgetView): @@ -64,9 +64,9 @@ class SearchView(WidgetView): name = "search" url_rules = ["search"] methods = ['GET', 'POST'] + json = True - def dispatch_request(self, *args, **kwargs): - instance = self._get_instance(*args, **kwargs) + def get_context(self, instance, *args, **kwargs): get_requests = GetRequests(instance) # prepare filters if "hubs" in flask.request.values: @@ -112,7 +112,7 @@ class SearchView(WidgetView): data["requests"], instance.config["per_page"]) data["requests"] = requests data["page"] = page_data - return flask.jsonify(data) + return data class RequestersView(WidgetView): @@ -121,9 +121,9 @@ class RequestersView(WidgetView): name = "requesters" url_rules = ["requesters"] MAX_SUGGESTS = 5 + json = True - def dispatch_request(self, *args, **kwargs): - instance = self._get_instance(*args, **kwargs) + def get_context(self, instance, *args, **kwargs): get_requests = GetRequests(instance) results = set([ req["author"]["name"] for req in get_requests() @@ -132,7 +132,7 @@ class RequestersView(WidgetView): if query: results = [name for name in results if name.startswith(query)] results = sorted(list(results))[:self.MAX_SUGGESTS] - return flask.jsonify({"results": results}) + return {"results": results} class ConfigView(WidgetView): @@ -143,6 +143,7 @@ class ConfigView(WidgetView): name = "config" url_rules = ["config"] template_name = "halp_config.html" + permission = "config" def get_context(self, instance, *args, **kwargs): post_url = flask.url_for( @@ -168,6 +169,7 @@ def hubs_suggest_view(): return flask.jsonify({"results": [h.name for h in results]}) +@require_hub_access("config", url_param="hub") def add_view(hub): """The custom configuration panel when adding the widget. @@ -188,7 +190,9 @@ def add_view(hub): ) initial["hubs"] = [hub.name] context = dict(mode="add", url=post_url, initial=initial) - before_render_template.send(widget, template=template, context=context) + before_render_template.send( + flask.current_app, template=template, context=context) output = template.render(**context) - template_rendered.send(widget, template=template, context=context) + template_rendered.send( + flask.current_app, template=template, context=context) return output diff --git a/hubs/widgets/view.py b/hubs/widgets/view.py index 943302d..f02e777 100644 --- a/hubs/widgets/view.py +++ b/hubs/widgets/view.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals, absolute_import +import flask from flask.signals import template_rendered, before_render_template from flask.views import View @@ -44,6 +45,8 @@ class WidgetView(View): name = None url_rules = [] template_name = None + json = False + permission = "view" def __init__(self, widget): """ @@ -96,12 +99,30 @@ class WidgetView(View): implement. It binds the other methods together. """ instance = self._get_instance(*args, **kwargs) + try: + user = flask.g.auth.user + except AttributeError: + user = None + if not instance.allows(user, self.permission): + msg = "You are not allowed to access this widget." + if self.json: + result = flask.jsonify({ + "status": "ERROR", "message": msg, + }) + else: + result = flask.render_template( + 'errors/hub.html', hub=instance.hub, msg=msg, + ) + return result, 403 context = self.get_context(instance, *args, **kwargs) - context.update(self.get_extra_context(instance, *args, **kwargs)) - template = self.get_template() - before_render_template.send( - self.widget, template=template, context=context) - output = template.render(**context) - template_rendered.send( - self.widget, template=template, context=context) - return output + if self.json: + return flask.jsonify(context) + else: + context.update(self.get_extra_context(instance, *args, **kwargs)) + template = self.get_template() + before_render_template.send( + flask.current_app, template=template, context=context) + output = template.render(**context) + template_rendered.send( + flask.current_app, template=template, context=context) + return output diff --git a/requirements.txt b/requirements.txt index 56ea13b..74264a8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ blinker datanommer.models decorator dogpile.cache +enum34 fedmsg fedmsg_meta_fedora_infrastructure flask From f6797c682f4785bef0f28fce9eea360853d23524 Mon Sep 17 00:00:00 2001 From: Aurélien Bompard Date: Apr 06 2017 23:29:35 +0000 Subject: [PATCH 3/9] Add a config panel to view, add and delete members --- diff --git a/hubs/models.py b/hubs/models.py index 758a64f..d54dccd 100644 --- a/hubs/models.py +++ b/hubs/models.py @@ -123,9 +123,9 @@ class Association(BASE): role = sa.Column(sa.Enum(*roles), primary_key=True) user = relation("User", backref=backref( - 'associations', cascade="all, delete")) + 'associations', cascade="all, delete, delete-orphan")) hub = relation("Hub", backref=backref( - 'associations', cascade="all, delete")) + 'associations', cascade="all, delete, delete-orphan")) @classmethod def get(cls, hub, user, role): diff --git a/hubs/static/client/app/core/HubConfig.jsx b/hubs/static/client/app/core/HubConfig.jsx index 73cbc7a..f4b75af 100644 --- a/hubs/static/client/app/core/HubConfig.jsx +++ b/hubs/static/client/app/core/HubConfig.jsx @@ -7,6 +7,7 @@ import { import { GeneralPanel, ChatPanel, + UserPanel, NotImplementedPanel, } from './HubConfigPanel.jsx'; import TabSet from '../components/TabSet.jsx'; @@ -56,13 +57,15 @@ export default class HubConfig extends React.Component { this.state = { hubConfig: {}, generalConfig: {}, + users: {owner: [], member: []}, dirty: false, mustReload: false, // the settings were changed at least once error: null, loading: false - } + }; this.serverRequest = null; this.handleChange = this.handleChange.bind(this); + this.handleUserChange = this.handleUserChange.bind(this); this.loadState = this.loadState.bind(this); this.pushState = this.pushState.bind(this); } @@ -73,21 +76,31 @@ export default class HubConfig extends React.Component { dataType: 'json', cache: false, success: function(data) { - this.setState({ - hubConfig: data.result.hub, - generalConfig: data.result.general, - dirty: false, - error: null, - loading: false - }); + if (data.status === "OK") { + this.setState({ + hubConfig: data.result.hubconfig, + generalConfig: data.result.general, + users: data.result.users, + dirty: false, + error: null, + }); + } else if (data.status === "ERROR") { + this.setState({error: data.message}); + } else { + this.setState({ + error: "Unknown status: " + data.status + }); + } }.bind(this), error: function(xhr, status, err) { - console.error(this.props.url, status, err.toString()); - this.setState({ - error: err.toString(), - loading: false - }); - }.bind(this) + var msg = err.toString(); + if (!msg) { msg = "Error communicating with the server"; } + console.error(this.props.url, status, msg); + this.setState({error: msg}); + }.bind(this), + complete: function(xhr, status) { + this.setState({loading: false}); + }.bind(this), } } @@ -98,10 +111,11 @@ export default class HubConfig extends React.Component { this.serverRequest = $.ajax(params); } - pushState() { + pushState(data, category) { var params = this.getRequestParams(); params.method = "POST"; - params.data = this.state.hubConfig; + params.data = data; + params.url += "?category=" + category; this.setState({loading: true}); this.serverRequest = $.ajax(params); } @@ -115,10 +129,17 @@ export default class HubConfig extends React.Component { mustReload: true, }, function() { window.clearTimeout(this.pushTimer); - this.pushTimer = window.setTimeout(this.pushState, 1000); + this.pushTimer = window.setTimeout( + this.pushState, 1000, this.state.hubConfig, "config"); }); } + handleUserChange(username, role) { + console.log("new role", username, role); + var data = {username: username, role: role}; + this.pushState(data, "role_change"); + } + componentDidMount() { this.loadState(); } @@ -129,7 +150,7 @@ export default class HubConfig extends React.Component { // The configuration was changed but the current display may not // reflect it (for example, the displayed summary may be the old one). // The only way to make the changes visible is to reload the page. - window.location = window.location; + window.location.reload(true); } } @@ -138,7 +159,6 @@ export default class HubConfig extends React.Component {
-
@@ -190,7 +223,7 @@ export default class HubConfig extends React.Component { >
}
-
diff --git a/hubs/static/client/app/core/HubConfigPanel.jsx b/hubs/static/client/app/core/HubConfigPanel.jsx index ddc22d3..865e340 100644 --- a/hubs/static/client/app/core/HubConfigPanel.jsx +++ b/hubs/static/client/app/core/HubConfigPanel.jsx @@ -83,6 +83,34 @@ const messages = defineMessages({ id: "hubs.core.config.chat.network", defaultMessage: "IRC network", }, + users_fullname: { + id: "hubs.core.config.users.fullname", + defaultMessage: "Full name", + }, + users_username: { + id: "hubs.core.config.users.username", + defaultMessage: "Username", + }, + users_change_role: { + id: "hubs.core.config.users.change_role", + defaultMessage: "Change role", + }, + users_na: { + id: "hubs.core.config.users.na", + defaultMessage: "(N/A)", + }, + users_remove_user: { + id: "hubs.core.config.users.remove_user", + defaultMessage: "(remove user)", + }, + users_add_user: { + id: "hubs.core.config.users.add_user", + defaultMessage: "Add user...", + }, + users_add: { + id: "hubs.core.config.users.add", + defaultMessage: "Add", + }, }); @@ -105,7 +133,7 @@ export class GeneralPanel extends React.Component { var stillLoading = (typeof this.props.hubConfig.summary === "undefined"); var visibilities = this.props.generalConfig.hub_visibility || []; return ( -
+
{e.preventDefault();}}>
@@ -177,6 +205,112 @@ export class GeneralPanel extends React.Component {

+ + ); + } + +} + + +export class UserPanel extends React.Component { + + constructor(props) { + super(props); + this.state = { + addUsername: "", + }; + this.handleAdd = this.handleAdd.bind(this); + this.handleAddInputChange = this.handleAddInputChange.bind(this); + this.handleChange = this.handleChange.bind(this); + } + + handleAdd(e) { + e.preventDefault(); + this.props.handleChange(this.state.addUsername, this.props.role); + this.setState({addUsername: ""}); + } + + handleAddInputChange(e) { + this.setState({ + addUsername: e.target.value, + }); + } + + handleChange(e) { + e.preventDefault(); + this.props.handleChange(e.target.name, e.target.value); + } + + render() { + var users = this.props.users.map(function(user) { + return ( + + {user.fullname} + {user.username} + + {user.locked ? + + : + + } + + + ); + }.bind(this)); + + return ( +
+

{this.props.tabTitle}

+

[Membership requests will be shown here]

+
+
+
+ + {(value) => ( + + )} + +
+ +
+ {users.length !== 0 && + + + + + + + + + + {users} + +
+ }
); } @@ -210,7 +344,7 @@ export class ChatPanel extends React.Component { } return ( -
+
{e.preventDefault();}}> @@ -244,7 +378,7 @@ export class ChatPanel extends React.Component { {networks}
-
+ ); } diff --git a/hubs/templates/widget_add.html b/hubs/templates/widget_add.html index f410ac8..3e0d4d6 100644 --- a/hubs/templates/widget_add.html +++ b/hubs/templates/widget_add.html @@ -23,6 +23,18 @@ {% else %}

Nothing to configure

{% endfor %} + {% if hub.config.visibility == "preview" %} +
+ Visibility + + + Restricted widgets will only be visible by logged-in users. + +
+ {% endif %} - ); - } - -} - - -export class ChatPanel extends React.Component { - - render() { - var networks = [], - channel = "#", - stillLoading = ( - typeof this.props.hubConfig.chat_channel === "undefined" - ); - - if (this.props.hubConfig.chat_channel) { - channel = "#" + this.props.hubConfig.chat_channel.replace(/^#*/, ""); - } - - if (this.props.generalConfig.chat_networks) { - networks = this.props.generalConfig.chat_networks.map( - function(network, index) { - return ( - - ); - }.bind(this) - ); - } - - return ( -
{e.preventDefault();}}> - - - -
- - -

- - #fedora-devel -

-
-
- - -
- - ); - } - -} - - -// vim: set ts=2 sw=2 et: diff --git a/hubs/static/client/app/core/HubConfigPanelChat.jsx b/hubs/static/client/app/core/HubConfigPanelChat.jsx new file mode 100644 index 0000000..458fd36 --- /dev/null +++ b/hubs/static/client/app/core/HubConfigPanelChat.jsx @@ -0,0 +1,106 @@ +import React from 'react'; +import { + defineMessages, + FormattedMessage, + } from 'react-intl'; + + +const messages = defineMessages({ + title: { + id: "hubs.core.config.chat.title", + defaultMessage: "Chat Settings", + }, + intro_1: { + id: "hubs.core.config.chat.intro1", + defaultMessage: ( + "If your team or project has an IRC channel associated with it, you " + +"can connect this hub to it via the Hubs chat widget." + ), + }, + intro_2: { + id: "hubs.core.config.chat.intro2", + defaultMessage: "Please indicate your team's IRC channel below:", + }, + channel_name: { + id: "hubs.core.config.chat.channel_name", + defaultMessage: "Channel name", + }, + example: { + id: "hubs.core.config.chat.example", + defaultMessage: "Example:", + }, + network: { + id: "hubs.core.config.chat.network", + defaultMessage: "IRC network", + }, +}); + + +export default class ChatPanel extends React.Component { + + render() { + var networks = [], + channel = "#", + stillLoading = ( + typeof this.props.hubConfig.chat_channel === "undefined" + ); + + if (this.props.hubConfig.chat_channel) { + channel = "#" + this.props.hubConfig.chat_channel.replace(/^#*/, ""); + } + + if (this.props.generalConfig.chat_networks) { + networks = this.props.generalConfig.chat_networks.map( + function(network, index) { + return ( + + ); + }.bind(this) + ); + } + + return ( +
{e.preventDefault();}}> + + + +
+ + +

+ + #fedora-devel +

+
+
+ + +
+ + ); + } + +} + + +// vim: set ts=2 sw=2 et: diff --git a/hubs/static/client/app/core/HubConfigPanelGeneral.jsx b/hubs/static/client/app/core/HubConfigPanelGeneral.jsx new file mode 100644 index 0000000..15d53ab --- /dev/null +++ b/hubs/static/client/app/core/HubConfigPanelGeneral.jsx @@ -0,0 +1,145 @@ +import React from 'react'; +import { + defineMessages, + FormattedMessage, + FormattedHTMLMessage, + } from 'react-intl'; + + +const messages = defineMessages({ + title: { + id: "hubs.core.config.general.title", + defaultMessage: "General Settings", + }, + intro: { + id: "hubs.core.config.general.intro", + defaultMessage: "Change your global hub parameters here.", + }, + summary: { + id: "hubs.core.config.general.summary", + defaultMessage: "Summary", + }, + summary_help: { + id: "hubs.core.config.general.summary_help", + defaultMessage: "This text will be displayed at the top of the hub.", + }, + left_width: { + id: "hubs.core.config.general.left_width", + defaultMessage: "Left width", + }, + left_width_help: { + id: "hubs.core.config.general.left_width_help", + defaultMessage: ( + "Change the proportions of you hub's columns by setting the width of " + +"the left column (the right column's width will be {right_width})." + ), + }, + visibility: { + id: "hubs.core.config.general.visibility", + defaultMessage: "Visibility", + }, + visibility_help: { + id: "hubs.core.config.general.visibility_help", + defaultMessage: ( + "Public: the hub is visible to anyone.
" + +"Private: the hub is only visible to members.
" + +"Preview: some widgets can be made visible to logged-in users only." + ), + }, + avatar: { + id: "hubs.core.config.general.avatar", + defaultMessage: "Avatar", + }, + avatar_help: { + id: "hubs.core.config.general.avatar_help", + defaultMessage: "An URL to you hub's main image or logo." + }, +}); + + +export default class GeneralPanel extends React.Component { + + render() { + var stillLoading = (typeof this.props.hubConfig.summary === "undefined"); + var visibilities = this.props.generalConfig.hub_visibility || []; + return ( +
{e.preventDefault();}}> + + +
+ + +

+ +

+
+
+ + +

+ +

+
+
+ + +

+ +

+
+
+ + +

+ +

+
+ + ); + } + +} + + +// vim: set ts=2 sw=2 et: diff --git a/hubs/static/client/app/core/HubConfigPanelNotImpl.jsx b/hubs/static/client/app/core/HubConfigPanelNotImpl.jsx new file mode 100644 index 0000000..31803be --- /dev/null +++ b/hubs/static/client/app/core/HubConfigPanelNotImpl.jsx @@ -0,0 +1,17 @@ +import React from 'react'; + + +export default class NotImplementedPanel extends React.Component { + + render() { + return ( +
+ Not implemented yet. +
+ ); + } + +} + + +// vim: set ts=2 sw=2 et: diff --git a/hubs/static/client/app/core/HubConfigPanelUser.jsx b/hubs/static/client/app/core/HubConfigPanelUser.jsx new file mode 100644 index 0000000..d913c79 --- /dev/null +++ b/hubs/static/client/app/core/HubConfigPanelUser.jsx @@ -0,0 +1,147 @@ +import React from 'react'; +import { + defineMessages, + FormattedMessage, + } from 'react-intl'; +import CompletionInput from '../components/CompletionInput.jsx'; + + +const messages = defineMessages({ + fullname: { + id: "hubs.core.config.users.fullname", + defaultMessage: "Full name", + }, + username: { + id: "hubs.core.config.users.username", + defaultMessage: "Username", + }, + change_role: { + id: "hubs.core.config.users.change_role", + defaultMessage: "Change role", + }, + na: { + id: "hubs.core.config.users.na", + defaultMessage: "(N/A)", + }, + remove_user: { + id: "hubs.core.config.users.remove_user", + defaultMessage: "(remove user)", + }, + add_user: { + id: "hubs.core.config.users.add_user", + defaultMessage: "Add user...", + }, + add: { + id: "hubs.core.config.users.add", + defaultMessage: "Add", + }, +}); + + +export default class UserPanel extends React.Component { + + constructor(props) { + super(props); + this.state = { + addUsername: "", + }; + this.handleAdd = this.handleAdd.bind(this); + this.handleAddInputChange = this.handleAddInputChange.bind(this); + this.handleChange = this.handleChange.bind(this); + } + + handleAdd(e) { + e.preventDefault(); + this.props.handleChange(this.state.addUsername, this.props.role); + this.setState({addUsername: ""}); + } + + handleAddInputChange(e) { + this.setState({ + addUsername: e.target.value, + }); + } + + handleChange(e) { + e.preventDefault(); + this.props.handleChange(e.target.name, e.target.value); + } + + render() { + var users = this.props.users.map(function(user) { + return ( + + {user.fullname} + {user.username} + + {user.locked ? + + : + + } + + + ); + }.bind(this)); + + return ( +
+

{this.props.tabTitle}

+

[Membership requests will be shown here]

+
+
+
+ + {(value) => ( + + )} + +
+ +
+ {users.length !== 0 && + + + + + + + + + + {users} + +
+ } +
+ ); + } + +} + + +// vim: set ts=2 sw=2 et: From 2cb5e8bcd7d8f8bab9e40d36e3ae411989d09b6c Mon Sep 17 00:00:00 2001 From: Aurélien Bompard Date: Apr 11 2017 12:51:54 +0000 Subject: [PATCH 8/9] Suggest users in the config panel --- diff --git a/hubs/static/client/app/components/CompletionInput.jsx b/hubs/static/client/app/components/CompletionInput.jsx index 9b8b581..a97fc7f 100644 --- a/hubs/static/client/app/components/CompletionInput.jsx +++ b/hubs/static/client/app/components/CompletionInput.jsx @@ -20,7 +20,6 @@ export default class CompletionInput extends React.Component { this.onChange = this.onChange.bind(this); this.onSuggestionsFetchRequested = this.onSuggestionsFetchRequested.bind(this); this.onSuggestionsClearRequested = this.onSuggestionsClearRequested.bind(this); - this.onSuggestionSelected = this.onSuggestionSelected.bind(this); this.loadFromServer = this.loadFromServer.bind(this); } @@ -39,20 +38,18 @@ export default class CompletionInput extends React.Component { this.setState({ value: newValue }); - } - - onSuggestionSelected(e, {suggestionValue}) { // Transmit a "fake" changed event to the parent, if asked to. - if (!this.props.onChange) { return; } - const mockedEvent = { - type: 'change', - target: { - type: 'input', - name: this.props.name, - value: suggestionValue, - }, + if (this.props.onChange) { + const mockedEvent = { + type: 'change', + target: { + type: 'input', + name: this.props.name, + value: newValue, + }, + } + this.props.onChange(mockedEvent); } - this.props.onChange(mockedEvent); } // Autosuggest will call this function every time you need to update @@ -83,13 +80,16 @@ export default class CompletionInput extends React.Component { dataType: 'json', cache: false, success: function(data) { - this.setState({ - suggestions: data.results, - loading: false - }); + var suggestions = data.results; + if (this.props.filterResults) { + suggestions = suggestions.filter(this.props.filterResults); + } + this.setState({suggestions: suggestions}); }.bind(this), error: function(xhr, status, err) { console.error(this.props.url, status, err.toString()); + }.bind(this), + complete: function(xhr, status) { this.setState({loading: false}); }.bind(this) }); @@ -111,13 +111,11 @@ export default class CompletionInput extends React.Component { const renderSuggestion = suggestion => suggestion; // Autosuggest will pass through all these props to the input element. const inputProps = { - //placeholder: 'Type a programming language', - name: this.props.name, className: "form-control", value: this.state.value, - placeholder: this.props.placeholder, onChange: this.onChange, }; + Object.assign(inputProps, this.props.inputProps); // Render the widget. return ( @@ -126,23 +124,21 @@ export default class CompletionInput extends React.Component { suggestions={this.state.suggestions} onSuggestionsFetchRequested={this.onSuggestionsFetchRequested} onSuggestionsClearRequested={this.onSuggestionsClearRequested} - onSuggestionSelected={this.onSuggestionSelected} getSuggestionValue={getSuggestionValue} renderSuggestion={renderSuggestion} inputProps={inputProps} /> - { this.state.loading ? - - - - : null } +
); } } CompletionInput.defaultProps = { - placeholder: "", + inputProps: {}, queryDelay: 800, }; diff --git a/hubs/static/client/app/core/HubConfig.jsx b/hubs/static/client/app/core/HubConfig.jsx index fca9e33..f3993d7 100644 --- a/hubs/static/client/app/core/HubConfig.jsx +++ b/hubs/static/client/app/core/HubConfig.jsx @@ -70,7 +70,7 @@ export default class HubConfig extends React.Component { getRequestParams() { return { - url: this.props.url, + url: this.props.urls.config, dataType: 'json', cache: false, success: function(data) { @@ -93,7 +93,7 @@ export default class HubConfig extends React.Component { error: function(xhr, status, err) { var msg = err.toString(); if (!msg) { msg = "Error communicating with the server"; } - console.error(this.props.url, status, msg); + console.error(this.props.urls.config, status, msg); this.setState({error: msg}); }.bind(this), complete: function(xhr, status) { @@ -133,7 +133,6 @@ export default class HubConfig extends React.Component { } handleUserChange(username, role) { - console.log("new role", username, role); var data = {username: username, role: role}; this.pushState(data, "role_change"); } @@ -195,6 +194,7 @@ export default class HubConfig extends React.Component { handleChange={this.handleUserChange} tabTitle={} loading={this.state.loading} + urls={this.props.urls} /> } loading={this.state.loading} + urls={this.props.urls} /> [Membership requests will be shown here]

-
+ {(this.props.role == "member") && +
+

+ [Membership requests will be shown here] +

+
+
+ }
- {(value) => ( - ( + - )} + )} -
-
diff --git a/hubs/static/client/app/widgets/halp/Config.jsx b/hubs/static/client/app/widgets/halp/Config.jsx index 9e81477..4a65107 100644 --- a/hubs/static/client/app/widgets/halp/Config.jsx +++ b/hubs/static/client/app/widgets/halp/Config.jsx @@ -125,10 +125,11 @@ export default class Config extends React.Component { const IntlCompletionInput = injectIntl((props) => ( )); diff --git a/hubs/static/client/app/widgets/halp/ModalAllRequests.jsx b/hubs/static/client/app/widgets/halp/ModalAllRequests.jsx index c3d13cd..cbb24b3 100644 --- a/hubs/static/client/app/widgets/halp/ModalAllRequests.jsx +++ b/hubs/static/client/app/widgets/halp/ModalAllRequests.jsx @@ -176,18 +176,25 @@ export default class ModalAllRequests extends Modal {
+ onChange={this.handleChange} + />
- - { this.state.loading && } +
diff --git a/hubs/static/client/app/widgets/halp/Widget.jsx b/hubs/static/client/app/widgets/halp/Widget.jsx index 71234a4..91a2fe8 100644 --- a/hubs/static/client/app/widgets/halp/Widget.jsx +++ b/hubs/static/client/app/widgets/halp/Widget.jsx @@ -147,7 +147,10 @@ export default class Widget extends React.Component { onHubClick={this.onHubClick} /> { this.state.loading ? - +
:
{requestNodes} diff --git a/hubs/static/css/style.css b/hubs/static/css/style.css index 86e35f0..ec6f499 100644 --- a/hubs/static/css/style.css +++ b/hubs/static/css/style.css @@ -141,6 +141,17 @@ display: none; /* hidden by default */ } +.loading-circle { + width: 32px; + height: 32px; + line-height: 32px; + background: url("../img/spinner-circle.gif") no-repeat center center; + vertical-align: bottom; + display: none; /* hidden by default */ +} + + + /* * Settings */ @@ -442,47 +453,36 @@ font-size: 32pt; /* * Autosuggest */ +.completion-input { + position: relative; +} .react-autosuggest__suggestions-container { - position: absolute; - z-index: 10; - width: 100%; - padding-right: 2em; + position: absolute; + width: 100%; + z-index: 10; +} +.react-autosuggest__suggestions-list { + list-style: none; + padding: 0.3em 0.8em; + background-color: #fafafa; + border-bottom-left-radius: .25rem; + border-bottom-right-radius: .25rem; + border: 1px solid #ccc; + border-top: none; +} +.react-autosuggest__suggestion { + cursor: pointer; } -.react-autosuggest__suggestions-container ul { - list-style: none; - padding: 0.3em 0.8em; - background-color: #eee; - border-bottom-left-radius: 4px; - border-bottom-right-radius: 4px; +.react-autosuggest__container--open input { + border-bottom-left-radius: 0px; + border-bottom-right-radius: 0px; } -.form-control-feedback { +.completion-input .loading-circle { position: absolute; top: 0; right: 0; z-index: 2; - display: block; - width: 34px; - height: 34px; - line-height: 34px; - text-align: center; - pointer-events: none; -} -.input-lg + .form-control-feedback { - width: 46px; - height: 46px; - line-height: 46px; -} -.input-sm + .form-control-feedback { - width: 30px; - height: 30px; - line-height: 30px; -} -.form-horizontal .form-control-feedback { - right: 15px; -} -.form-inline .form-control-feedback { - top: 0; } diff --git a/hubs/templates/hubs.html b/hubs/templates/hubs.html index 35ab48a..2851568 100644 --- a/hubs/templates/hubs.html +++ b/hubs/templates/hubs.html @@ -390,7 +390,10 @@ $(function() { setup_widgets(); setup_edit_btns(); setup_settings({ - url: {{ url_for("hub_config", name=hub.name)|tojson }}, + urls: { + config: {{ url_for("hub_config", name=hub.name)|tojson }}, + suggestUsers: {{ url_for("hub_config_suggest_users", name=hub.name)|tojson }}, + } }); {% if edit -%} diff --git a/hubs/tests/test_fedora_hubs_flask_api.py b/hubs/tests/test_fedora_hubs_flask_api.py index a0a0d62..ed58d25 100644 --- a/hubs/tests/test_fedora_hubs_flask_api.py +++ b/hubs/tests/test_fedora_hubs_flask_api.py @@ -527,3 +527,61 @@ class HubsAPITest(hubs.tests.APPTest): url = '/decause/config?category=config' result = self.app.post(url, data={"summary": "Defaced!"}) self.assertEqual(result.status_code, 403) + + def test_hub_config_suggest_users_no_filter(self): + user = tests.FakeAuthorization('ralph') + expected = [ + u.username for u in + hubs.models.User.query.order_by( + hubs.models.User.username + ).all()] + # Check without filter + with tests.auth_set(app, user): + url = '/ralph/config/suggest-users' + result = self.app.get(url) + self.assertEqual(result.status_code, 200) + result_data = json.loads(result.get_data(as_text=True)) + self.assertEqual(result_data["status"], "OK") + self.assertListEqual(result_data["results"], expected) + + def test_hub_config_suggest_users_filter_owners(self): + # Filters on owners + user = tests.FakeAuthorization('ralph') + expected = [ + u.username for u in + hubs.models.User.query.order_by( + hubs.models.User.username + ).filter( + hubs.models.User.username != "ralph" + ).all()] + with tests.auth_set(app, user): + url = '/ralph/config/suggest-users?exclude-role=owner' + result = self.app.get(url) + self.assertEqual(result.status_code, 200) + result_data = json.loads(result.get_data(as_text=True)) + self.assertEqual(result_data["status"], "OK") + self.assertListEqual(result_data["results"], expected) + + def test_hub_config_suggest_users_filter_members(self): + # Filters on members + user = tests.FakeAuthorization('ralph') + hub = hubs.models.Hub.get('ralph') + decause = hubs.models.User.query.get("decause") + devyani7 = hubs.models.User.query.get("devyani7") + hub.subscribe(decause, "member") + hub.subscribe(devyani7, "member") + expected = [ + u.username for u in + hubs.models.User.query.order_by( + hubs.models.User.username + ).filter( + hubs.models.User.username != "decause", + hubs.models.User.username != "devyani7" + ).all()] + with tests.auth_set(app, user): + url = '/ralph/config/suggest-users?exclude-role=member' + result = self.app.get(url) + self.assertEqual(result.status_code, 200) + result_data = json.loads(result.get_data(as_text=True)) + self.assertEqual(result_data["status"], "OK") + self.assertListEqual(result_data["results"], expected) diff --git a/hubs/views/hub.py b/hubs/views/hub.py index c3ea5f4..d6d05f7 100644 --- a/hubs/views/hub.py +++ b/hubs/views/hub.py @@ -246,3 +246,37 @@ def hub_config_post_role_change(hub): hub.associations.append(hubs.models.Association( hub=hub, user=user, role=role)) flask.g.db.commit() + + +@app.route('//config/suggest-users') +@login_required +@require_hub_access("config", json=True) +def hub_config_suggest_users(name): + MAX_SUGGESTS = 10 + hub = get_hub(name) + results = flask.g.db.query(hubs.models.User.username) + query = flask.request.args.get("q") + if query: + results = results.filter( + hubs.models.User.username.ilike("%{}%".format(query)) + ) + exclude = flask.request.args.get("exclude-role") + if exclude: + exclude_query = flask.g.db.query( + hubs.models.User.username + ).join( + hubs.models.Association + ).join( + hubs.models.Hub + ).filter( + hubs.models.Hub.name == hub.name, + hubs.models.Association.role == exclude, + ) + results = results.filter( + ~hubs.models.User.username.in_(exclude_query) + ) + results = results.order_by(hubs.models.User.username).limit(MAX_SUGGESTS) + return flask.jsonify({ + "status": "OK", + "results": [r[0] for r in results], + }) From 122c7bb78d71e55053e00c9a093fe3624c884c1d Mon Sep 17 00:00:00 2001 From: Aurélien Bompard Date: Apr 11 2017 12:51:55 +0000 Subject: [PATCH 9/9] Implement review suggestions --- diff --git a/docs/api/auth.rst b/docs/api/auth.rst index 36b5cf3..c5e8382 100644 --- a/docs/api/auth.rst +++ b/docs/api/auth.rst @@ -1,4 +1,4 @@ -Authentication and authorization +Authentication and Authorization ================================ Authentication is the fact of checking that the user is who they say they are.