feat(usermanager): add user creation and list views
This commit is contained in:
parent
4827db3d51
commit
7cb519a89f
103
lumi2/static/images/base/navbar-logo.svg
Normal file
103
lumi2/static/images/base/navbar-logo.svg
Normal 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 |
BIN
lumi2/static/images/base/plus-circle.png
Normal file
BIN
lumi2/static/images/base/plus-circle.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.9 KiB |
4
lumi2/static/images/base/plus-circle.svg
Normal file
4
lumi2/static/images/base/plus-circle.svg
Normal 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 |
BIN
lumi2/static/images/base/plus.png
Normal file
BIN
lumi2/static/images/base/plus.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 926 B |
3
lumi2/static/images/base/plus.svg
Normal file
3
lumi2/static/images/base/plus.svg
Normal 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 |
@ -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>
|
||||
|
@ -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>
|
@ -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 %}
|
||||
|
@ -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 %}
|
@ -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 %}
|
||||
|
@ -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,
|
||||
)
|
||||
|
Loading…
Reference in New Issue
Block a user