Skip to content

Commit 1a4b807

Browse files
authored
feat: user management UI qol features (#124)
1 parent 2469340 commit 1a4b807

6 files changed

Lines changed: 321 additions & 12 deletions

File tree

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,64 @@
11
{% extends "_base.html" %}
22

33
{% block content %}
4+
<div class="container" id="content">
5+
<div class="row">
6+
<h1>Welcome!</h1>
7+
</div>
48

5-
<div class="container" id="content">
6-
<div class="row">
7-
<h1>Welcome!</h1>
8-
</div>
9-
</div>
9+
{% if current_user.is_authenticated %}
10+
<div class="row">
11+
<div class="col-md-6">
12+
<h2>Your Organizations</h2>
13+
<table class="table">
14+
{% for organization in current_user.organizations %}
15+
<tr>
16+
<td>
17+
<a href="{{ url_for('user.organization', org_slug=organization.organization.slug) }}">
18+
{{ organization.organization.name }}
19+
</a>
20+
{% if organization.admin %}
21+
<span class="label label-primary pull-right">Admin</span>
22+
{% else %}
23+
<span class="label label-default pull-right">Member</span>
24+
{% endif %}
25+
</td>
26+
</tr>
27+
{% else %}
28+
<tr>
29+
<td>No organizations yet</td>
30+
</tr>
31+
{% endfor %}
32+
</table>
33+
</div>
1034

35+
<div class="col-md-6">
36+
<h2>Your Projects</h2>
37+
<table class="table">
38+
{% for project in current_user.projects %}
39+
<tr>
40+
<td>
41+
<a href="{{ url_for('user.project', org_slug=project.organization.slug, project_slug=project.slug) }}">
42+
{{ project.organization.slug }}/{{ project.name }}
43+
</a>
44+
</td>
45+
</tr>
46+
{% else %}
47+
<tr>
48+
<td>No projects yet</td>
49+
</tr>
50+
{% endfor %}
51+
</table>
52+
</div>
53+
</div>
54+
{% else %}
55+
<div class="jumbotron text-center">
56+
<h1>Welcome to Cabotage</h1>
57+
<p>Please log in to manage your organizations and projects</p>
58+
<p>
59+
<a href="{{ url_for('security.login') }}" class="btn btn-primary btn-lg">Log In</a>
60+
</p>
61+
</div>
62+
{% endif %}
63+
</div>
1164
{% endblock %}
Lines changed: 73 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
{% extends "_base.html" %}
22
{% from "bootstrap/utils.html" import form_button %}
3+
34
{% block content %}
5+
{% include "security/_messages.html" %}
46
<div class="container" id="content">
57
<div class="row">
68
<h1>Organization: {{ organization.name }}</h1>
@@ -9,14 +11,79 @@ <h1>Organization: {{ organization.name }}</h1>
911
<h2>Projects</h2>
1012
</div>
1113
<table class="table">
12-
{% for project in organization.projects %}
14+
{% for project in organization.projects %}
15+
<tr>
16+
<td>
17+
<a href="{{ url_for("user.project", org_slug=project.organization.slug, project_slug=project.slug) }}">{{ project.name }}</a>
18+
</td>
19+
</tr>
20+
{% endfor %}
1321
<tr>
14-
<td><a href="{{ url_for("user.project", org_slug=project.organization.slug, project_slug=project.slug) }}">{{ project.name }}</a></td>
15-
</tr>
16-
{% endfor %}
17-
<tr>
18-
<td>{{ form_button(url_for('user.organization_project_create', org_slug=organization.slug), "Create a new Project", method="GET", class="btn btn-primary pull-right") }}</td>
22+
<td colspan="2" class="text-right">
23+
{{ form_button(url_for('user.organization_project_create', org_slug=organization.slug), "Create a new Project", method="GET", class="btn btn-primary") }}
24+
</td>
1925
</tr>
2026
</table>
27+
28+
<div class="row">
29+
<h2>Users</h2>
30+
</div>
31+
<table class="table">
32+
<thead>
33+
<tr>
34+
<th>Username</th>
35+
<th>Role</th>
36+
<th></th>
37+
</tr>
38+
</thead>
39+
<tbody>
40+
{% for member in organization.members %}
41+
<tr>
42+
<td>{{ member.user.username }}</td>
43+
<td>{{ "Admin" if member.admin else "Member" }}</td>
44+
<td class="text-right">
45+
{% if member.user.id != current_user.id %} {# Don't show management buttons for current user #}
46+
{% if current_user.admin or (organization.id in current_user.organizations|selectattr('admin')|map(attribute='organization_id')|list) %}
47+
{% if not member.admin %}
48+
<form action="{{ url_for('user.organization_promote_user', org_slug=organization.slug) }}"
49+
method="POST" style="display: inline;">
50+
<input type="hidden" name="user_id" value="{{ member.user.id }}">
51+
<button type="submit" class="btn btn-sm btn-success"
52+
onclick="return confirm('Are you sure you want to promote {{ member.user.username }} to admin? \n\nAn organization admin can undo this action.');">
53+
<span class="glyphicon glyphicon-arrow-up"></span> Promote
54+
</button>
55+
</form>
56+
{% elif current_user.admin or (organization.id in current_user.organizations|selectattr('admin')|map(attribute='organization_id')|list) %}
57+
<form action="{{ url_for('user.organization_demote_user', org_slug=organization.slug) }}"
58+
method="POST" style="display: inline;">
59+
<input type="hidden" name="user_id" value="{{ member.user.id }}">
60+
<button type="submit" class="btn btn-sm btn-warning"
61+
onclick="return confirm('Are you sure you want to demote {{ member.user.username }} to member?');">
62+
<span class="glyphicon glyphicon-arrow-down"></span> Demote
63+
</button>
64+
</form>
65+
{% endif %}
66+
<form action="{{ url_for('user.organization_remove_user', org_slug=organization.slug) }}"
67+
method="POST" style="display: inline;">
68+
<input type="hidden" name="user_id" value="{{ member.user.id }}">
69+
<button type="submit" class="btn btn-sm btn-danger"
70+
onclick="return confirm('Are you sure you want to remove {{ member.user.username }} from this organization?');">
71+
<span class="glyphicon glyphicon-trash"></span> Remove
72+
</button>
73+
</form>
74+
{% endif %}
75+
{% endif %}
76+
</td>
77+
</tr>
78+
{% endfor %}
79+
{% if current_user.admin or (organization.id in current_user.organizations|selectattr('admin')|map(attribute='organization_id')|list) %}
80+
<tr>
81+
<td colspan="3">
82+
{{ form_button(url_for('user.organization_add_user', org_slug=organization.slug), "Add User", method="GET", class="btn btn-primary pull-right") }}
83+
</td>
84+
</tr>
85+
{% endif %}
86+
</tbody>
87+
</table>
2188
</div>
2289
{% endblock %}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
{% extends "_base.html" %}
2+
{% from "security/_macros.html" import render_field_with_errors, render_field %}
3+
{% from "bootstrap/wtf.html" import form_errors, form_field %}
4+
{% from "bootstrap/utils.html" import form_button %}
5+
6+
{% block content %}
7+
{% include "security/_messages.html" %}
8+
<div class="container-fluid clearfix" id="content">
9+
<div class="row">
10+
<div class="col-md-8 col-md-offset-2">
11+
<h1>Add User to {{ organization.name }}</h1>
12+
13+
{% if current_user.admin and all_users %}
14+
<div class="panel panel-default">
15+
<div class="panel-heading">
16+
<h3 class="panel-title">All Users</h3>
17+
</div>
18+
<div class="panel-body">
19+
{% set member_ids = organization.members|map(attribute='user_id')|list %}
20+
{% set available_users = [] %}
21+
{% for user in all_users %}
22+
{% if user.id not in member_ids %}
23+
{% do available_users.append(user) %}
24+
{% endif %}
25+
{% endfor %}
26+
27+
{% if available_users %}
28+
<div class="table-responsive">
29+
<table class="table table-striped">
30+
<thead>
31+
<tr>
32+
<th>Username</th>
33+
<th>Email</th>
34+
<th></th>
35+
</tr>
36+
</thead>
37+
<tbody>
38+
{% for user in available_users %}
39+
<tr>
40+
<td>{{ user.username }}</td>
41+
<td>{{ user.email }}</td>
42+
<td class="text-right">
43+
<form action="{{ url_for('user.organization_add_user', org_slug=organization.slug) }}" method="POST" style="display: inline;">
44+
{{ form.hidden_tag() }}
45+
<input type="hidden" name="email" value="{{ user.email }}">
46+
<button type="submit" class="btn btn-sm btn-success">
47+
<span class="glyphicon glyphicon-plus"></span> Add
48+
</button>
49+
</form>
50+
</td>
51+
</tr>
52+
{% endfor %}
53+
</tbody>
54+
</table>
55+
</div>
56+
{% else %}
57+
<div class="alert alert-info">
58+
All users are already members of this organization.
59+
</div>
60+
{% endif %}
61+
</div>
62+
</div>
63+
{% else %}
64+
<div class="col-md-4 col-md-offset-4 col-sm-8 col-xs-8 col-xs-offset-2 col-sm-offset-2">
65+
<form action="{{ url_for('user.organization_add_user', org_slug=organization.slug) }}" method="POST" name="organization_add_user_form">
66+
{{ form.hidden_tag() }}
67+
{{ form_field(form.email) }}
68+
{{ form_button(form.submit, "Add User", method="POST", class="btn btn-primary") }}
69+
</form>
70+
</div>
71+
{% endif %}
72+
</div>
73+
</div>
74+
</div>
75+
{% endblock %}

cabotage/server/models/auth.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ def add_user(self, user, admin=False):
135135
def remove_user(self, user):
136136
association = OrganizationMember.query.filter_by(
137137
user_id=user.id, organization_id=self.id
138-
)
138+
).first()
139139
if association:
140140
db.session.delete(association)
141141

cabotage/server/user/forms.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,3 +426,14 @@ class ApplicationScaleForm(FlaskForm):
426426
[DataRequired()],
427427
description="Application to scale.",
428428
)
429+
430+
431+
class AddOrganizationUserForm(FlaskForm):
432+
email = StringField(
433+
"User Email",
434+
[
435+
InputRequired(),
436+
Length(min=3, max=255),
437+
],
438+
description="Email address of the user to add to this organization",
439+
)

cabotage/server/user/views.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
render_template,
1515
request,
1616
url_for,
17+
flash,
1718
)
1819
from flask_security import (
1920
current_user,
@@ -49,7 +50,9 @@
4950

5051
from cabotage.server.models.auth import (
5152
Organization,
53+
User,
5254
)
55+
from cabotage.server.models.auth_associations import OrganizationMember
5356
from cabotage.server.models.projects import (
5457
DEFAULT_POD_CLASS,
5558
Application,
@@ -73,6 +76,7 @@
7376
EditApplicationSettingsForm,
7477
EditConfigurationForm,
7578
ReleaseDeployForm,
79+
AddOrganizationUserForm,
7680
)
7781

7882
from cabotage.utils.docker_auth import (
@@ -1608,3 +1612,102 @@ def github_hooks():
16081612
process_github_hook.delay(hook_id=hook.id)
16091613
return jsonify({"hook_id": hook.id})
16101614
abort(403)
1615+
1616+
1617+
@user_blueprint.route("/organizations/<org_slug>/users/add", methods=["GET", "POST"])
1618+
@login_required
1619+
def organization_add_user(org_slug):
1620+
organization = Organization.query.filter_by(slug=org_slug).first_or_404()
1621+
if not AdministerOrganizationPermission(organization.id).can():
1622+
abort(403)
1623+
1624+
form = AddOrganizationUserForm()
1625+
all_users = User.query.all() if current_user.admin else None
1626+
1627+
if form.validate_on_submit():
1628+
if user := User.query.filter_by(email=form.email.data).first():
1629+
organization.add_user(user)
1630+
db.session.commit()
1631+
flash("User has been added to the organization.", "success")
1632+
return redirect(url_for("user.organization", org_slug=org_slug))
1633+
else:
1634+
flash("User with this email address does not exist.", "error")
1635+
return render_template(
1636+
"user/organization_add_user.html",
1637+
organization=organization,
1638+
form=form,
1639+
all_users=all_users,
1640+
)
1641+
1642+
1643+
@user_blueprint.route("/organizations/<org_slug>/users/remove", methods=["POST"])
1644+
@login_required
1645+
def organization_remove_user(org_slug):
1646+
organization = Organization.query.filter_by(slug=org_slug).first_or_404()
1647+
if not AdministerOrganizationPermission(organization.id).can():
1648+
abort(403)
1649+
1650+
user_id = request.form.get("user_id")
1651+
if user := User.query.get(user_id):
1652+
org_user_count = len(organization.members)
1653+
if org_user_count <= 1:
1654+
flash(
1655+
"Cannot remove the last user from an organization. Add someone else first!",
1656+
"warning",
1657+
)
1658+
else:
1659+
organization.remove_user(user)
1660+
db.session.commit()
1661+
flash(
1662+
f"User {user.email} removed from organization {organization.name}.",
1663+
"success",
1664+
)
1665+
else:
1666+
flash("User not found.", "error")
1667+
return redirect(url_for("user.organization", org_slug=org_slug))
1668+
1669+
1670+
@user_blueprint.route("/organizations/<org_slug>/users/promote", methods=["POST"])
1671+
@login_required
1672+
def organization_promote_user(org_slug):
1673+
organization = Organization.query.filter_by(slug=org_slug).first_or_404()
1674+
if not AdministerOrganizationPermission(organization.id).can():
1675+
abort(403)
1676+
1677+
user_id = request.form.get("user_id")
1678+
if user := User.query.get(user_id):
1679+
if member := OrganizationMember.query.filter_by(
1680+
user_id=user.id, organization_id=organization.id
1681+
).first():
1682+
member.admin = True
1683+
db.session.commit()
1684+
flash(
1685+
f"User {user.email} promoted to admin in {organization.name}.",
1686+
"success",
1687+
)
1688+
else:
1689+
flash("User not found.", "error")
1690+
return redirect(url_for("user.organization", org_slug=org_slug))
1691+
1692+
1693+
@user_blueprint.route("/organizations/<org_slug>/users/demote", methods=["POST"])
1694+
@login_required
1695+
def organization_demote_user(org_slug):
1696+
organization = Organization.query.filter_by(slug=org_slug).first_or_404()
1697+
if not AdministerOrganizationPermission(organization.id).can():
1698+
abort(403)
1699+
1700+
user_id = request.form.get("user_id")
1701+
if user := User.query.get(user_id):
1702+
if member := OrganizationMember.query.filter_by(
1703+
user_id=user.id, organization_id=organization.id
1704+
).first():
1705+
member.admin = False
1706+
db.session.commit()
1707+
flash(
1708+
f"User {user.email} demoted to member in {organization.name}.",
1709+
"success",
1710+
)
1711+
else:
1712+
flash("User not found.", "error")
1713+
return redirect(url_for("user.organization", org_slug=org_slug))

0 commit comments

Comments
 (0)