feat(usermanager): add user creation and list views

This commit is contained in:
Julian Lobbes 2022-11-18 23:04:43 +01:00
parent 4827db3d51
commit 7cb519a89f
11 changed files with 293 additions and 71 deletions

View File

@ -0,0 +1,103 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="1200"
height="630"
viewBox="0 0 1200 630"
version="1.1"
id="svg5"
sodipodi:docname="navbar-logo.svg"
inkscape:version="1.2.1 (9c6d41e410, 2022-07-14, custom)"
inkscape:export-filename="og.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="false"
inkscape:deskcolor="#505050"
inkscape:document-units="px"
showgrid="false"
inkscape:zoom="1.0086093"
inkscape:cx="-438.7229"
inkscape:cy="320.73866"
inkscape:window-width="5100"
inkscape:window-height="1364"
inkscape:window-x="10"
inkscape:window-y="38"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs2">
<rect
x="443.72474"
y="78.23549"
width="662.99755"
height="255.29392"
id="rect1106" />
</defs>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<circle
style="fill:#d8af24;fill-opacity:1;stroke:none;stroke-width:0.511802"
id="path111"
cx="138.89902"
cy="145.00546"
r="84.022392" />
<path
style="fill:#c9c033;fill-opacity:1;stroke:none;stroke-width:0.994968"
id="path927"
sodipodi:type="arc"
sodipodi:cx="-138.89902"
sodipodi:cy="-384.03238"
sodipodi:rx="136.01785"
sodipodi:ry="138.06476"
sodipodi:start="0"
sodipodi:end="3.1415927"
sodipodi:open="true"
sodipodi:arc-type="arc"
d="m -2.8811646,-384.03238 a 136.01785,138.06476 0 0 1 -68.0089284,119.56759 136.01785,138.06476 0 0 1 -136.017857,0 136.01785,138.06476 0 0 1 -68.00892,-119.56759"
transform="scale(-1)" />
<path
id="rect1005"
d="M 2.8811746,381.98549 H 274.91688 v 18.33321 c 0,17.28211 -272.0357054,17.98233 -272.0357054,0 z"
sodipodi:nodetypes="ccssc"
style="fill:#c9c033;fill-opacity:1;stroke-width:0.344016" />
<path
id="rect1005-3-6-7"
d="m 2.8811746,421.74333 c 0,14.53527 272.0357054,12.84369 272.0357054,0 V 452.884 c 0,12.78284 -272.0357054,10.59969 -272.0357054,0 z"
sodipodi:nodetypes="sssss"
style="fill:#f8c655;fill-opacity:1;stroke-width:0.448357" />
<path
id="rect1005-3-6-7-1"
d="m 2.8811746,469.87064 c 0,14.53527 272.0357054,12.84369 272.0357054,0 v 31.14067 c 0,12.78284 -272.0357054,10.59969 -272.0357054,0 z"
sodipodi:nodetypes="sssss"
style="fill:#b1ac4b;fill-opacity:1;stroke-width:0.448357" />
<path
id="rect1005-3-6-7-27"
d="m 2.8811746,517.99795 c 0,14.53527 272.0357054,12.84369 272.0357054,0 v 31.14067 c 0,12.78284 -272.0357054,10.59969 -272.0357054,0 z"
sodipodi:nodetypes="sssss"
style="fill:#518664;fill-opacity:1;stroke-width:0.448357" />
<text
xml:space="preserve"
id="text1104"
style="white-space:pre;shape-inside:url(#rect1106);display:inline;fill:#c9c033;fill-opacity:1"
transform="matrix(1.3473628,0,0,1.3473628,-276.37417,67.44972)"><tspan
x="443.72461"
y="262.55603"
id="tspan391"><tspan
style="font-weight:600;font-size:213.333px;font-family:'URW Gothic';-inkscape-font-specification:'URW Gothic, Semi-Bold'"
id="tspan389">LUMI 2</tspan></tspan></text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-plus-circle" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z"/>
</svg>

After

Width:  |  Height:  |  Size: 336 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 926 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-plus" viewBox="0 0 16 16">
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z"/>
</svg>

After

Width:  |  Height:  |  Size: 245 B

View File

@ -23,14 +23,39 @@
</head>
<body>
<nav class="navbar navbar-expand-lg bg-light">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('index') }}">
<img src="{{ url_for('static', filename='images/base/navbar-logo.svg') }}"
alt="Lumi2 Logo" style="max-height: 60px"
>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link" href="{{ url_for('usermanager.user_list') }}">Users</a>
</li>
</ul>
<div class="d-flex"">
<a class="btn btn-primary"
href="#"
role="button">Log In</a>
</div>
</div>
</div>
</nav>
{% with messages = get_flashed_messages() %}
{% if messages %}
{% for message in messages %}
<div class="alert alert-primary m-3">{{ message }}</div>
<div class="alert alert-primary mt-3">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<div class="container border rounded">
<div class="container border rounded mt-3">
{% block content %}
{% endblock content %}
</div>

View File

@ -3,11 +3,22 @@
{% block content %}
<div class="row">
<div class="col">
<h1>Edit user: {{ username }}</h1>
<h1>{{ heading }}</h1>
</div>
</div>
<form method="post" enctype="multipart/form-data">
{{ form.csrf_token }}
{% if not is_update %}
<div class="mb-3">
{{ form.username.label(class="form-label") }}
{{ form.username(class="form-control" + (" is-invalid" if form.username.errors else "")) }}
{% if form.username.errors %}
{% for error in form.username.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
{% endif %}
</div>
{% endif %}
<div class="mb-3">
{{ form.email.label(class="form-label") }}
{{ form.email(class="form-control" + (" is-invalid" if form.email.errors else "")) }}
@ -43,6 +54,11 @@
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
{% endif %}
{% if not is_update %}
<div id="displayNameHelp" class="form-text">
Leave empty to use the first name. Will be displayed instead of the first name in some applications.
</div>
{% endif %}
</div>
<div class="mb-3">
{{ form.password.label(class="form-label") }}
@ -52,6 +68,13 @@
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
{% endif %}
<div id="passwordHelp" class="form-text">
{% if is_update %}
Must be at least 8 characters long. Leave empty to keep the current password.
{% else %}
Must be at least 8 characters long.
{% endif %}
</div>
</div>
<div class="mb-3">
{{ form.password_confirmation.label(class="form-label") }}
@ -70,11 +93,24 @@
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
{% endif %}
<div id="pictureHelp" class="form-text">
{% if is_update %}
Only JPEG files can be used. Leave empty to keep the current picture.
{% else %}
Optional but recommended. Only JPEG files can be used.
{% endif %}
</div>
</div>
<div class="mb-3">
{% if is_update %}
<a class="btn btn-secondary"
href="{{ url_for('usermanager.user_view', username=username) }}"
role="button">Cancel</a>
{% else %}
<a class="btn btn-secondary"
href="{{ url_for('usermanager.user_list') }}"
role="button">Cancel</a>
{% endif %}
{{ form.submit(class_="btn btn-primary") }}
</div>
</form>

View File

@ -1,34 +1,46 @@
{% extends 'base.html' %}
{% block content %}
<div class="text-center border-bottom mb-1">
<h1>All users</h1>
</div>
<div class="text-end border-bottom pb-1 mb-1">
<a class="btn btn-primary"
href="{{ url_for('usermanager.user_create') }}"
role="button"
>
<img src="/static/images/base/plus.png" alt="Plus-Icon" width="16" height="16">
Create a new user
</a>
</div>
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th scope="col">Picture</th>
<th scope="col">Username</th>
<th scope="col">Email address</th>
<th scope="col">First Name</th>
<th scope="col">Last Name</th>
<th scope="col">First Name</th>
<th scope="col">Email address</th>
<th scope="col">Nickname</th>
</tr>
</thead>
<tbody>
{% for user in users %}
{% for user in users | sort %}
<tr>
<th scope="row">
<img src="{{ url_for('static', filename='images/users/' + user.username + '/thumbnail.jpg') }}"
alt="profile picture for user {{ user.username }}"
class="img-fluid rounded"
style="height: 100px"
style="max-width: 100px"
>
</th>
<td class="align-middle">
<a href="{{ url_for('usermanager.user_view', username=user.username) }}">{{ user.username }}</a>
</td>
<td class="align-middle">{{ user.email }}</td>
<td class="align-middle">{{ user.first_name }}</td>
<td class="align-middle">{{ user.last_name }}</td>
<td class="align-middle">{{ user.first_name }}</td>
<td class="align-middle">{{ user.email }}</td>
<td class="align-middle">{{ user.display_name }}</td>
</tr>
{% endfor %}

View File

@ -1,16 +0,0 @@
{% extends 'base.html' %}
{% block content %}
<div>
<div class="container text-center">
<div class="row">
<div class="col-sm-4 border">
Column
</div>
<div class="col-sm-8 border">
Column
</div>
</div>
</div>
</div>
{% endblock content %}

View File

@ -1,45 +1,28 @@
{% extends 'base.html' %}
{% block content %}
<div class="row text-center">
<div class="col">
<div class="row justify-content-sm-center">
<div class="col-sm-4 text-center align-self-center m-2">
<img src="{{ url_for('static', filename='images/users/' + user.username + '/full.jpg') }}"
alt="profile picture for user {{ user.username }}"
class="img-thumbnail"
style="height: 200px"
style="max-width: 150px"
>
</div>
</div>
<div class="row text-center">
<div class="col">
<h1>{{ user.username }}</h1>
<div class="col-sm-auto vstack gap-1 align-self-center m-2">
<div><b>Username:</b> {{ user.username }}</div>
<div><b>Email:</b> {{ user.email }}</div>
<div><b>First Name:</b> {{ user.first_name }}</div>
<div><b>Last Name:</b> {{ user.last_name }}</div>
<div><b>Nickname:</b> {{ user.display_name }}</div>
</div>
</div>
<div class="row gx-1">
<div class="col text-end fw-bold">Username:</div>
<div class="col text-start">{{ user.username }}</div>
</div>
<div class="row gx-1">
<div class="col text-end fw-bold">Email:</div>
<div class="col text-start">{{ user.email }}</div>
</div>
<div class="row gx-1">
<div class="col text-end fw-bold">First Name:</div>
<div class="col text-start">{{ user.first_name }}</div>
</div>
<div class="row gx-1">
<div class="col text-end fw-bold">Last Name:</div>
<div class="col text-start">{{ user.last_name }}</div>
</div>
<div class="row gx-1">
<div class="col text-end fw-bold">Nickname:</div>
<div class="col text-start">{{ user.display_name }}</div>
</div>
<div class="row text-center">
<div class="col">
<div class="col-sm-4 vstack gap-1 align-self-center m-2">
<a class="btn btn-primary"
href="{{ url_for('usermanager.user_update', username=user.username) }}"
role="button">Edit</a>
<a class="btn btn-danger"
href="#"
role="button">Delete</a>
</div>
</div>
{% endblock content %}

View File

@ -4,7 +4,7 @@ from pathlib import Path
from tempfile import TemporaryFile
from flask import (
Blueprint, render_template, abort, request, flash, redirect, url_for
Blueprint, render_template, abort, request, flash, redirect, url_for, current_app
)
from PIL import Image, UnidentifiedImageError
from flask_wtf import FlaskForm
@ -68,20 +68,14 @@ def user_list():
users=users,
)
@bp.route("/users/test")
def user_test():
return render_template(
'usermanager/user_test.html',
)
class UserUpdateForm(FlaskForm):
@staticmethod
def validate_name(form, field) -> None:
try:
User.assert_is_valid_name(field.data)
except InvalidStringFormatException as e:
raise ValidationError(str(e))
if field.data:
try:
User.assert_is_valid_name(field.data)
except InvalidStringFormatException as e:
raise ValidationError(str(e))
@staticmethod
def validate_password(form, field) -> None:
@ -115,11 +109,11 @@ class UserUpdateForm(FlaskForm):
[InputRequired(), validate_name]
)
display_name = StringField(
'Nick Name',
'Nickname',
[InputRequired(), validate_name]
)
password = PasswordField(
'Password (leave empty to keep the same)',
'Password',
[
EqualTo('password_confirmation', message='Passwords must match'),
validate_password,
@ -137,6 +131,82 @@ class UserUpdateForm(FlaskForm):
)
class UserCreationForm(UserUpdateForm):
@staticmethod
def validate_username(form, field) -> None:
try:
User.assert_is_valid_username(field.data)
except InvalidStringFormatException as e:
raise ValidationError(str(e))
new_user_dn = f"uid={field.data}," + current_app.config['LDAP_USERS_OU']
conn = ldap.get_connection()
if ldap.user_exists(conn, new_user_dn):
raise ValidationError("Username is taken.")
conn.unbind()
username = StringField(
'Username',
[
InputRequired('Please enter a username.'),
validate_username
]
)
display_name = StringField(
'Nickname',
[UserUpdateForm.validate_name]
)
password = PasswordField(
'Password',
[
EqualTo('password_confirmation', message='Passwords must match'),
InputRequired('Please enter a password.'),
UserUpdateForm.validate_password,
],
)
submit = SubmitField(
'Create',
)
@bp.route("/users/create", methods=("GET", "POST"))
def user_create():
"""Creation view for a new User.
Provides a form which can be used to enter the new user's details.
"""
try:
conn = ldap.get_connection()
except Exception:
abort(500)
form = UserCreationForm()
if form.validate_on_submit():
user = User(
form.username.data,
User.generate_password_hash(form.password.data),
form.email.data,
form.first_name.data,
form.last_name.data,
form.display_name.data if form.display_name.data else None,
Image.open(form.picture.data, formats=['JPEG']) if form.picture.data and form.picture.data.filename else None,
)
ldap.create_user(conn, user)
user._generate_static_images(force=True)
conn.unbind()
flash(f"User '{user.username}' was created.")
return redirect(url_for('usermanager.user_view', username=user.username))
conn.unbind()
return render_template(
'usermanager/user_edit.html',
form=form,
heading=f"Create a new user",
is_update=False,
)
@bp.route("/users/update/<string:username>", methods=("GET", "POST"))
def user_update(username: str):
"""Update view for a specific User.
@ -183,7 +253,9 @@ def user_update(username: str):
conn.unbind()
return render_template(
'usermanager/user_update.html',
'usermanager/user_edit.html',
form=form,
username=user.username
username=user.username,
heading=f"Edit user: {user.username}",
is_update=True,
)