From 590c51a8a0eabf46da425462d323dce16af4101f Mon Sep 17 00:00:00 2001
From: Julian Rother <julianr@fsmpi.rwth-aachen.de>
Date: Sat, 17 Apr 2021 17:22:32 +0200
Subject: [PATCH] Added role moderator pages

---
 uffd/__init__.py                         |  4 +-
 uffd/invite/models.py                    |  3 +-
 uffd/invite/templates/invite/list.html   |  2 +-
 uffd/invite/templates/invite/new.html    |  2 +-
 uffd/invite/views.py                     |  1 +
 uffd/rolemod/__init__.py                 |  3 ++
 uffd/rolemod/templates/rolemod/list.html | 30 +++++++++++
 uffd/rolemod/templates/rolemod/show.html | 65 +++++++++++++++++++++++
 uffd/rolemod/views.py                    | 67 ++++++++++++++++++++++++
 uffd/signup/models.py                    |  2 +-
 10 files changed, 173 insertions(+), 6 deletions(-)
 create mode 100644 uffd/rolemod/__init__.py
 create mode 100644 uffd/rolemod/templates/rolemod/list.html
 create mode 100644 uffd/rolemod/templates/rolemod/show.html
 create mode 100644 uffd/rolemod/views.py

diff --git a/uffd/__init__.py b/uffd/__init__.py
index 557044ca..0ea45a23 100644
--- a/uffd/__init__.py
+++ b/uffd/__init__.py
@@ -49,10 +49,10 @@ def create_app(test_config=None): # pylint: disable=too-many-locals
 	db.init_app(app)
 	Migrate(app, db, render_as_batch=True)
 	# pylint: disable=C0415
-	from uffd import user, selfservice, role, mail, session, csrf, mfa, oauth2, services, signup, invite
+	from uffd import user, selfservice, role, mail, session, csrf, mfa, oauth2, services, signup, rolemod, invite
 	# pylint: enable=C0415
 
-	for i in user.bp + selfservice.bp + role.bp + mail.bp + session.bp + csrf.bp + mfa.bp + oauth2.bp + services.bp:
+	for i in user.bp + selfservice.bp + role.bp + mail.bp + session.bp + csrf.bp + mfa.bp + oauth2.bp + services.bp + rolemod.bp:
 		app.register_blueprint(i)
 
 	if app.config['LDAP_SERVICE_USER_BIND'] and (app.config['ENABLE_INVITE'] or
diff --git a/uffd/invite/models.py b/uffd/invite/models.py
index fcfc948f..eb441fb9 100644
--- a/uffd/invite/models.py
+++ b/uffd/invite/models.py
@@ -30,7 +30,7 @@ class Invite(db.Model):
 	disabled = Column(Boolean, default=False, nullable=False)
 	roles = relationship('Role', secondary=invite_roles)
 	signups = relationship('InviteSignup', back_populates='invite', lazy=True)
-	grants = relationship('InviteGrant', backref='invite', lazy=True)
+	grants = relationship('InviteGrant', back_populates='invite', lazy=True)
 
 	@property
 	def expired(self):
@@ -76,6 +76,7 @@ class InviteGrant(db.Model):
 	__tablename__ = 'invite_grant'
 	id = Column(Integer(), primary_key=True, autoincrement=True)
 	invite_id = Column(Integer(), ForeignKey('invite.id'), nullable=False)
+	invite = relationship('Invite', back_populates='grants')
 	user_dn = Column(String(128), nullable=False)
 	user = DBRelationship('user_dn', User)
 
diff --git a/uffd/invite/templates/invite/list.html b/uffd/invite/templates/invite/list.html
index bb6ad283..a371f9e4 100644
--- a/uffd/invite/templates/invite/list.html
+++ b/uffd/invite/templates/invite/list.html
@@ -89,7 +89,7 @@
 					<li><b>Expires:</b> {{ invite.valid_until.strftime('%Y-%m-%d %H:%M:%S') }}</li>
 					<li><b>Permissions:</b>
 						<ul>
-							{% if invite.signup %}
+							{% if invite.allow_signup %}
 							<li>Link allows account registration</li>
 							{% else %}
 							<li>No account registration allowed</li>
diff --git a/uffd/invite/templates/invite/new.html b/uffd/invite/templates/invite/new.html
index 7f2d9847..d1f913ac 100644
--- a/uffd/invite/templates/invite/new.html
+++ b/uffd/invite/templates/invite/new.html
@@ -39,7 +39,7 @@
 				<tr>
 					<td>
 						<div class="form-check">
-							<input class="form-check-input" type="checkbox" id="role-{{ role.id }}" name="role-{{ role.id }}" value="1">
+							<input class="form-check-input" type="checkbox" id="role-{{ role.id }}" name="role-{{ role.id }}" value="1" {{ 'checked' if 'role-%d'%role.id in request.values }}>
 						</div>
 					</td>
 					<td>{{ role.name }}</td>
diff --git a/uffd/invite/views.py b/uffd/invite/views.py
index 8e83dd77..b42ff07a 100644
--- a/uffd/invite/views.py
+++ b/uffd/invite/views.py
@@ -131,6 +131,7 @@ def use(token):
 
 @bp.route('/<token>/grant', methods=['POST'])
 @login_required()
+@csrf_protect(blueprint=bp)
 def grant(token):
 	invite = Invite.query.filter_by(token=token).first_or_404()
 	invite_grant = InviteGrant(invite=invite, user=get_current_user())
diff --git a/uffd/rolemod/__init__.py b/uffd/rolemod/__init__.py
new file mode 100644
index 00000000..67157866
--- /dev/null
+++ b/uffd/rolemod/__init__.py
@@ -0,0 +1,3 @@
+from .views import bp as bp_ui
+
+bp = [bp_ui]
diff --git a/uffd/rolemod/templates/rolemod/list.html b/uffd/rolemod/templates/rolemod/list.html
new file mode 100644
index 00000000..f727ba6c
--- /dev/null
+++ b/uffd/rolemod/templates/rolemod/list.html
@@ -0,0 +1,30 @@
+{% extends 'base.html' %}
+
+{% block body %}
+<div class="row">
+	<div class="col">
+		<table class="table table-striped table-sm">
+			<thead>
+				<tr>
+					<th scope="col">name</th>
+					<th scope="col">description</th>
+				</tr>
+			</thead>
+			<tbody>
+				{% for role in roles|sort(attribute='name') %}
+				<tr id="role-{{ role.id }}">
+					<th scope="row">
+						<a href="{{ url_for('rolemod.show', role_id=role.id) }}">
+							{{ role.name or '<empty name>' }}
+						</a>
+					</th>
+					<td>
+						{{ role.description }}
+					</td>
+				</tr>
+				{% endfor %}
+			</tbody>
+		</table>
+	</div>
+</div>
+{% endblock %}
diff --git a/uffd/rolemod/templates/rolemod/show.html b/uffd/rolemod/templates/rolemod/show.html
new file mode 100644
index 00000000..1fcb2558
--- /dev/null
+++ b/uffd/rolemod/templates/rolemod/show.html
@@ -0,0 +1,65 @@
+{% extends 'base.html' %}
+
+{% block body %}
+
+<form method="POST" action="{{ url_for("rolemod.update", role_id=role.id) }}">
+	<div class="float-sm-right pb-2">
+		{% if config['ENABLE_INVITE'] %}
+		<a href="{{ url_for("invite.new", **{"role-%d"%role.id: 1}) }}" class="btn btn-primary mr-2"><i class="fa fa-link" aria-hidden="true"></i> Invite Members</a>
+		{% endif %}
+		<button type="submit" class="btn btn-primary"><i class="fa fa-save" aria-hidden="true"></i> Save</button>
+		<a href="{{ url_for("rolemod.index") }}" class="btn btn-secondary">Cancel</a>
+	</div>
+	<ul class="nav nav-tabs pt-2 border-0" id="tablist" role="tablist">
+		<li class="nav-item">
+			<a class="nav-link active" id="overview-tab" data-toggle="tab" href="#overview" role="tab" aria-controls="overview" aria-selected="true">Overview</a>
+		</li>
+		<li class="nav-item">
+			<a class="nav-link" id="members-tab" data-toggle="tab" href="#members" role="tab" aria-controls="groups" aria-selected="false">Members <span class="badge badge-pill badge-secondary">{{ role.members|length }}</span></a>
+		</li>
+	</ul>
+	<div class="tab-content border mb-2 pt-2" id="tabcontent">
+		<div class="tab-pane fade show active" id="overview" role="tabpanel" aria-labelledby="overview-tab">
+			<div class="form-group col">
+				<label for="role-name">Role Name</label>
+				<input type="text" class="form-control" id="role-name" value="{{ role.name }}" readonly>
+			</div>
+			<div class="form-group col">
+				<label for="role-description">Description</label>
+				<textarea class="form-control" id="role-description" rows="5" name="description">{{ role.description }}</textarea>
+			</div>
+			<div class="form-group col">
+				<label for="role-moderators">Moderators:</label>
+				<ul>
+					{% for moderator in role.moderator_group.members %}
+					<li>{{ moderator.displayname }} ({{ moderator.loginname }})</li>
+					{% endfor %}
+				</ul>
+			</div>
+		</div>
+		<div class="tab-pane fade" id="members" role="tabpanel" aria-labelledby="members-tab">
+			<div class="col">
+				<span>Role members:</span>
+				<table class="table table-striped table-sm">
+					<thead>
+						<tr>
+							<th scope="col">Name</th>
+							<th scope="col"></th>
+						</tr>
+					</thead>
+					<tbody>
+						{% for member in role.members|sort(attribute="loginname") %}
+						<tr>
+							<td>{{ member.displayname }} ({{ member.loginname }})</td>
+							<td class="text-right">
+								<a type="button" class="btn btn-danger py-0" href="{{ url_for('rolemod.delete_member', role_id=role.id, member_dn=member.dn) }}">Remove</a>
+							</td>
+						</tr>
+						{% endfor %}
+					</tbody>
+				</table>
+			</div>
+		</div>
+	</div>
+</form>
+{% endblock %}
diff --git a/uffd/rolemod/views.py b/uffd/rolemod/views.py
new file mode 100644
index 00000000..174d923b
--- /dev/null
+++ b/uffd/rolemod/views.py
@@ -0,0 +1,67 @@
+from flask import Blueprint, render_template, request, url_for, redirect, flash
+
+from uffd.navbar import register_navbar
+from uffd.csrf import csrf_protect
+from uffd.role.models import Role
+from uffd.user.models import User
+from uffd.session import get_current_user, login_required, is_valid_session
+from uffd.database import db
+from uffd.ldap import ldap
+
+bp = Blueprint('rolemod', __name__, template_folder='templates', url_prefix='/rolemod/')
+
+def user_is_rolemod():
+	return is_valid_session() and Role.query.filter(Role.moderator_group_dn.in_(get_current_user().group_dns)).count()
+
+@bp.before_request
+@login_required()
+def acl_check(): #pylint: disable=inconsistent-return-statements
+	if not user_is_rolemod():
+		flash('Access denied')
+		return redirect(url_for('index'))
+
+@bp.route("/")
+@register_navbar('Moderation', icon='user-lock', blueprint=bp, visible=user_is_rolemod)
+def index():
+	roles = Role.query.filter(Role.moderator_group_dn.in_(get_current_user().group_dns)).all()
+	return render_template('rolemod/list.html', roles=roles)
+
+@bp.route("/<int:role_id>")
+def show(role_id):
+	# prefetch all users so the ldap orm can cache them and doesn't run one ldap query per user
+	User.query.all()
+	role = Role.query.get_or_404(role_id)
+	if role.moderator_group not in get_current_user().groups:
+		flash('Access denied')
+		return redirect(url_for('index'))
+	return render_template('rolemod/show.html', role=role)
+
+@bp.route("/<int:role_id>", methods=['POST'])
+@csrf_protect(blueprint=bp)
+def update(role_id):
+	role = Role.query.get_or_404(role_id)
+	if role.moderator_group not in get_current_user().groups:
+		flash('Access denied')
+		return redirect(url_for('index'))
+	if request.form['description'] != role.description:
+		if len(request.form['description']) > 256:
+			flash('Description too long')
+			return redirect(url_for('.show', role_id=role.id))
+		role.description = request.form['description']
+	db.session.commit()
+	return redirect(url_for('.show', role_id=role.id))
+
+@bp.route("/<int:role_id>/delete_member/<member_dn>")
+@csrf_protect(blueprint=bp)
+def delete_member(role_id, member_dn):
+	role = Role.query.get_or_404(role_id)
+	if role.moderator_group not in get_current_user().groups:
+		flash('Access denied')
+		return redirect(url_for('index'))
+	member = User.query.get_or_404(member_dn)
+	role.members.discard(member)
+	member.update_groups()
+	ldap.session.commit()
+	db.session.commit()
+	flash('Member removed')
+	return redirect(url_for('.show', role_id=role.id))
diff --git a/uffd/signup/models.py b/uffd/signup/models.py
index 0a7532ae..a2f4b721 100644
--- a/uffd/signup/models.py
+++ b/uffd/signup/models.py
@@ -35,7 +35,7 @@ class Signup(db.Model):
 	mail = Column(Text)
 	pwhash = Column(Text)
 	user_dn = Column(String(128)) # Set after successful confirmation
-	user = DBRelationship('user_dn', User)
+	user = DBRelationship('user_dn', User, backref='signups')
 
 	type = Column(String(50))
 	__mapper_args__ = {
-- 
GitLab