Compare commits
56 commits
ldap-api-v
...
master
Author | SHA1 | Date | |
---|---|---|---|
0bd6dd6345 | |||
24bc2630c4 | |||
5a4ea98e5d | |||
6832843f00 | |||
70794ad61a | |||
d8670fc558 | |||
5d936900be | |||
e2a5efdbc4 | |||
b2223c6a78 | |||
c3fb28f6e4 | |||
a9a01e5e6f | |||
cbfcafbb18 | |||
961fc09acb | |||
f47da87d03 | |||
0f2e78c2ac | |||
ed4919ef8c | |||
d1acd0a01d | |||
180fccaf90 | |||
f501acf839 | |||
1cda01eb1f | |||
b96cbbde25 | |||
d9ef64d983 | |||
8d73839de7 | |||
182c17ebac | |||
c158697f7b | |||
520f16f0c1 | |||
0d307591ca | |||
c193f1faae | |||
519cd26e13 | |||
0ed0a3f981 | |||
18a2bf89e9 | |||
ebe3bca3a2 | |||
2d48a26c51 | |||
00c2715a83 | |||
632c03a2b9 | |||
413bc29ec4 | |||
3b40baf64b | |||
413dedd29e | |||
0bb678acc0 | |||
faeece3cd2 | |||
7572736e0f | |||
8e9777353e | |||
a082af09c3 | |||
5c56e2d1de | |||
851a180815 | |||
7cb519a89f | |||
4827db3d51 | |||
95ac626933 | |||
b111490bab | |||
cd7233f566 | |||
001e80977e | |||
7b1196f09d | |||
997327338e | |||
76deba94f8 | |||
59316042fb | |||
8925b00109 |
1
.dockerignore
Normal file
|
@ -0,0 +1 @@
|
|||
*/__pycache__
|
3
.gitignore
vendored
|
@ -18,3 +18,6 @@ build/
|
|||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# Node modules
|
||||
/node_modules/
|
||||
|
|
25
Dockerfile
|
@ -1,11 +1,26 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
|
||||
# This Dockerfile builds a docker image containing all dependecies to run
|
||||
# a development version of the lumi application.
|
||||
|
||||
FROM python:3
|
||||
|
||||
# Install dependencies
|
||||
# Create non-root user
|
||||
ARG LUMI2_UID
|
||||
ARG LUMI2_GID
|
||||
ENV LUMI2_USERNAME=lumi2
|
||||
ENV LUMI2_GROUPNAME=lumi2
|
||||
RUN groupadd --gid ${LUMI2_GID:-1000} ${LUMI2_GROUPNAME} && \
|
||||
useradd --uid ${LUMI2_UID:-1000} --gid ${LUMI2_GID:-1000} --no-create-home --shell /bin/bash ${LUMI2_USERNAME} && \
|
||||
mkdir /app && chown ${LUMI2_UID:-1000}:${LUMI2_GID:-1000} /app
|
||||
|
||||
# Copy source files
|
||||
WORKDIR /app
|
||||
COPY requirements.txt /app/
|
||||
COPY --chown=${LUMI2_USERNAME}:${LUMI2_GROUPNAME} requirements.txt /app/
|
||||
COPY --chown=${LUMI2_USERNAME}:${LUMI2_GROUPNAME} lumi2 /app/lumi2/
|
||||
COPY --chown=${LUMI2_USERNAME}:${LUMI2_GROUPNAME} tests /app/tests/
|
||||
COPY --chown=${LUMI2_USERNAME}:${LUMI2_GROUPNAME} pytest.ini /app/
|
||||
|
||||
# Install dependencies
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
# Launch WSGI server
|
||||
USER ${LUMI2_UID:-1000}:${LUMI2_GID:-1000}
|
||||
ENTRYPOINT ["waitress-serve", "--listen=0.0.0.0:80", "--no-ipv6", "--call", "lumi2:create_app"]
|
||||
|
|
660
LICENSE.md
Normal file
|
@ -0,0 +1,660 @@
|
|||
### GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc.
|
||||
<https://fsf.org/>
|
||||
|
||||
Everyone is permitted to copy and distribute verbatim copies of this
|
||||
license document, but changing it is not allowed.
|
||||
|
||||
### Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains
|
||||
free software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing
|
||||
under this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
### TERMS AND CONDITIONS
|
||||
|
||||
#### 0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public
|
||||
License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds
|
||||
of works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of
|
||||
an exact copy. The resulting work is called a "modified version" of
|
||||
the earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user
|
||||
through a computer network, with no transfer of a copy, is not
|
||||
conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices" to
|
||||
the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
#### 1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work for
|
||||
making modifications to it. "Object code" means any non-source form of
|
||||
a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users can
|
||||
regenerate automatically from other parts of the Corresponding Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that same
|
||||
work.
|
||||
|
||||
#### 2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not convey,
|
||||
without conditions so long as your license otherwise remains in force.
|
||||
You may convey covered works to others for the sole purpose of having
|
||||
them make modifications exclusively for you, or provide you with
|
||||
facilities for running those works, provided that you comply with the
|
||||
terms of this License in conveying all material for which you do not
|
||||
control copyright. Those thus making or running the covered works for
|
||||
you must do so exclusively on your behalf, under your direction and
|
||||
control, on terms that prohibit them from making any copies of your
|
||||
copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under the
|
||||
conditions stated below. Sublicensing is not allowed; section 10 makes
|
||||
it unnecessary.
|
||||
|
||||
#### 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such
|
||||
circumvention is effected by exercising rights under this License with
|
||||
respect to the covered work, and you disclaim any intention to limit
|
||||
operation or modification of the work as a means of enforcing, against
|
||||
the work's users, your or third parties' legal rights to forbid
|
||||
circumvention of technological measures.
|
||||
|
||||
#### 4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
#### 5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these
|
||||
conditions:
|
||||
|
||||
- a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
- b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under
|
||||
section 7. This requirement modifies the requirement in section 4
|
||||
to "keep intact all notices".
|
||||
- c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
- d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
#### 6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms of
|
||||
sections 4 and 5, provided that you also convey the machine-readable
|
||||
Corresponding Source under the terms of this License, in one of these
|
||||
ways:
|
||||
|
||||
- a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
- b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the Corresponding
|
||||
Source from a network server at no charge.
|
||||
- c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
- d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
- e) Convey the object code using peer-to-peer transmission,
|
||||
provided you inform other peers where the object code and
|
||||
Corresponding Source of the work are being offered to the general
|
||||
public at no charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal,
|
||||
family, or household purposes, or (2) anything designed or sold for
|
||||
incorporation into a dwelling. In determining whether a product is a
|
||||
consumer product, doubtful cases shall be resolved in favor of
|
||||
coverage. For a particular product received by a particular user,
|
||||
"normally used" refers to a typical or common use of that class of
|
||||
product, regardless of the status of the particular user or of the way
|
||||
in which the particular user actually uses, or expects or is expected
|
||||
to use, the product. A product is a consumer product regardless of
|
||||
whether the product has substantial commercial, industrial or
|
||||
non-consumer uses, unless such uses represent the only significant
|
||||
mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to
|
||||
install and execute modified versions of a covered work in that User
|
||||
Product from a modified version of its Corresponding Source. The
|
||||
information must suffice to ensure that the continued functioning of
|
||||
the modified object code is in no case prevented or interfered with
|
||||
solely because modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or
|
||||
updates for a work that has been modified or installed by the
|
||||
recipient, or for the User Product in which it has been modified or
|
||||
installed. Access to a network may be denied when the modification
|
||||
itself materially and adversely affects the operation of the network
|
||||
or violates the rules and protocols for communication across the
|
||||
network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
#### 7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders
|
||||
of that material) supplement the terms of this License with terms:
|
||||
|
||||
- a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
- b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
- c) Prohibiting misrepresentation of the origin of that material,
|
||||
or requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
- d) Limiting the use for publicity purposes of names of licensors
|
||||
or authors of the material; or
|
||||
- e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
- f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions
|
||||
of it) with contractual assumptions of liability to the recipient,
|
||||
for any liability that these contractual assumptions directly
|
||||
impose on those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions; the
|
||||
above requirements apply either way.
|
||||
|
||||
#### 8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your license
|
||||
from a particular copyright holder is reinstated (a) provisionally,
|
||||
unless and until the copyright holder explicitly and finally
|
||||
terminates your license, and (b) permanently, if the copyright holder
|
||||
fails to notify you of the violation by some reasonable means prior to
|
||||
60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
#### 9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or run
|
||||
a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
#### 10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
#### 11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims owned
|
||||
or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within the
|
||||
scope of its coverage, prohibits the exercise of, or is conditioned on
|
||||
the non-exercise of one or more of the rights that are specifically
|
||||
granted under this License. You may not convey a covered work if you
|
||||
are a party to an arrangement with a third party that is in the
|
||||
business of distributing software, under which you make payment to the
|
||||
third party based on the extent of your activity of conveying the
|
||||
work, and under which the third party grants, to any of the parties
|
||||
who would receive the covered work from you, a discriminatory patent
|
||||
license (a) in connection with copies of the covered work conveyed by
|
||||
you (or copies made from those copies), or (b) primarily for and in
|
||||
connection with specific products or compilations that contain the
|
||||
covered work, unless you entered into that arrangement, or that patent
|
||||
license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
#### 12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under
|
||||
this License and any other pertinent obligations, then as a
|
||||
consequence you may not convey it at all. For example, if you agree to
|
||||
terms that obligate you to collect a royalty for further conveying
|
||||
from those to whom you convey the Program, the only way you could
|
||||
satisfy both those terms and this License would be to refrain entirely
|
||||
from conveying the Program.
|
||||
|
||||
#### 13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your
|
||||
version supports such interaction) an opportunity to receive the
|
||||
Corresponding Source of your version by providing access to the
|
||||
Corresponding Source from a network server at no charge, through some
|
||||
standard or customary means of facilitating copying of software. This
|
||||
Corresponding Source shall include the Corresponding Source for any
|
||||
work covered by version 3 of the GNU General Public License that is
|
||||
incorporated pursuant to the following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
#### 14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions
|
||||
of the GNU Affero General Public License from time to time. Such new
|
||||
versions will be similar in spirit to the present version, but may
|
||||
differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Program
|
||||
specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever
|
||||
published by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future versions
|
||||
of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
#### 15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT
|
||||
WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND
|
||||
PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE
|
||||
DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR
|
||||
CORRECTION.
|
||||
|
||||
#### 16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR
|
||||
CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES
|
||||
ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT
|
||||
NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR
|
||||
LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM
|
||||
TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER
|
||||
PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
#### 17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
### How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these
|
||||
terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest to
|
||||
attach them to the start of each source file to most effectively state
|
||||
the exclusion of warranty; and each file should have at least the
|
||||
"copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper
|
||||
mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for
|
||||
the specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or
|
||||
school, if any, to sign a "copyright disclaimer" for the program, if
|
||||
necessary. For more information on this, and how to apply and follow
|
||||
the GNU AGPL, see <https://www.gnu.org/licenses/>.
|
215
README.md
|
@ -1,12 +1,215 @@
|
|||
# Lumi2 (LDAP user management interface)
|
||||
# LUMI 2
|
||||
|
||||
**Lumi2** is a web application for managing users and user groups present on an OpenLDAP server.
|
||||
It provides a web-interface for administrators to create/read/update/delete organization users and user groups, and to allow basic account self-service for organization members themselves.
|
||||
Lumi is written in Python, using the [Flask](https://flask.palletsprojects.com/en/latest/) web framework.
|
||||
**Lumi2** is the *LDAP user management interface*, a minimalistic web-ui for managing simple LDAP authentication backends.
|
||||
|
||||
The motivation for Lumi is for it to provide a more user-friendly interface than [PhpLdapAdmin](https://phpldapadmin.sourceforge.net/wiki/index.php/Main_Page), however it is not a replacement.
|
||||
It lets an administrator create/read/update/delete users and groups in a user-friendly interface:
|
||||
|
||||
# Testing
|
||||

|
||||
|
||||
Lumi2 eliminates the need for complex LDAP frontends, such as [PhpLdapAdmin](https://phpldapadmin.sourceforge.net/wiki/index.php/Main_Page),
|
||||
or modifying the DIT (directory information tree) through LDIF files.
|
||||
|
||||
It is quite opinionated and suitable only for small-scale deployments (such as a homelab or small enterprise), but it works really well there!
|
||||
|
||||
Lumi2 can be used with an existing LDAP deployment, but it makes some [core assumptions](#assumptions-and-limitations) about the DIT it works on.
|
||||
|
||||
# Deployment
|
||||
|
||||
Lumi2 is designed for use and deployment in a microservice environment.
|
||||
|
||||
It works well together with a dockerized LDAP server (such as [osixia's OpenLDAP image](https://github.com/osixia/docker-openldap)),
|
||||
and you can use the included `docker-compose.yml` as a reference.
|
||||
If you are deploying Lumi2 alongside a fresh LDAP instance of the osixia OpenLDAP image,
|
||||
everything works out of the box and you won't ever need to modify your LDAP entries directly.
|
||||
|
||||
## Assumptions and limitations
|
||||
|
||||
Currently, the connection between Lumi2 and your LDAP server **does not encrypt traffic** using TLS.
|
||||
Your Lumi2 instance and the LDAP server it manages should run on the same host, otherwise it will be possible for a man-in-the-middle to
|
||||
read your user's credentials and personal information.
|
||||
|
||||
Lumi2 is simplistic and makes some assumptions about the structure of your LDAP DIT.
|
||||
The DIT hierarchy it expects (or creates) looks like this:
|
||||
|
||||

|
||||
|
||||
When deploying a new LDAP server, you don't need to pay too much attention to the rest of this section, as Lumi2 will create the DIT entries for you as necessary.
|
||||
You should, however, be aware of this structure when configuring other applications to use your LDAP backend.
|
||||
|
||||
If you point Lumi2 at an existing LDAP server, make sure its DIT matches the structure shown above and described below, otherwise Lumi2 will not work with your LDAP instance.
|
||||
|
||||
### Users
|
||||
|
||||
- All user entries are direct children of a single OU (`organizationalUnit`). The `cn` (name) of this OU is configurable.
|
||||
- This OU is a direct child entry of your DIT's root entry
|
||||
- The RDN (relative distinguished name) for users is their `uid` attribute
|
||||
- User entries are LDAP objects of type `inetOrgPerson`. Lumi2 sets (and expects to find) the following attributes for each user:
|
||||
- `uid` - username
|
||||
- `cn` - first name
|
||||
- `sn` - last name
|
||||
- `displayName` - preferred name (or nickname)
|
||||
- `mail` - email address
|
||||
- `jpegPhoto` - profile picture in JPEG format
|
||||
- `userPassword` - SSHA password hash
|
||||
|
||||
The `uid` (username) can contain latin characters, digits, underscores, hypens and periods, and must have a letter as the first character.
|
||||
|
||||
### Groups
|
||||
|
||||
- All group entries are direct children of a single OU (`organizationalUnit`). The `cn` (name) of this OU is configurable.
|
||||
- This OU is a direct child entry of your DIT's root entry
|
||||
- The RDN (relative distinguished name) for groups is their `cn` attribute
|
||||
- Group entries are LDAP objects of type `groupOfUniqueNames`:
|
||||
- A group always has at least one member (an LDAP limitation)
|
||||
- Members of the group are listed in its `uniqueMember` attribute
|
||||
|
||||
The groupname (`cn`) can contain only latin characters.
|
||||
|
||||
## Configuration
|
||||
|
||||
Your Lumi2 instance is configured using the `config.py` python file.
|
||||
Customize the file so that Lumi2 can connect to your LDAP server and bind to it with admin credentials.
|
||||
|
||||
Make sure you configure a secure `SECRET_KEY` and `ADMIN_PASSWORD` prior to deployment, as described below.
|
||||
|
||||
It is recommended you use `docker-compose` for Lumi2 and your LDAP server. Use the `docker-compose.yml` and `config.py` files in this repo as a starting point for this.
|
||||
|
||||
### Security settings
|
||||
|
||||
To generate a secret key and a password hash, you first need to import some of Lumi2's dependencies.
|
||||
The easiest way to do this is by using a virtual environment. In the repo's root folder, run the following shell commands:
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
Lumi2 uses a private key to encrypt session cookies and secrets.
|
||||
Generate a strong secret key now, by running the following command:
|
||||
|
||||
```bash
|
||||
python -c 'import secrets; print(secrets.token_hex())'
|
||||
```
|
||||
|
||||
Replace the insecure `SECRET_KEY` in your `config.py` with the random string you just generated.
|
||||
|
||||
Next, we need to replace the default password hash in `config.py` with a more secure one.
|
||||
Pick a **strong** password, which you will use later to log in to Lumi2, and generate its hash using the following command:
|
||||
|
||||
```bash
|
||||
python -c 'from werkzeug.security import generate_password_hash as gen; from getpass import getpass as p; print("Hash: " + gen(p("Password: ")))'
|
||||
```
|
||||
|
||||
Replace the existing `ADMIN_PASSWORD` in your `config.py` with the hash you just generated.
|
||||
|
||||
You can deactivate and delete the virtual environment now by running:
|
||||
```bash
|
||||
deactivate
|
||||
rm -rf .venv
|
||||
```
|
||||
|
||||
### LDAP settings
|
||||
|
||||
#### Connection settings
|
||||
|
||||
Lumi2 needs to know where to reach your LDAP server. This is set in the `LDAP_HOSTNAME` variable.
|
||||
|
||||
What you put here depends on your environment, but if you are using `docker-compose` with both Lumi2 and OpenLDAP running in the same compose-stack,
|
||||
you can simply set the LDAP container's hostname here.
|
||||
|
||||
By default, Lumi2 tries to connnect on 389, the standard LDAP port, but you can specify a non-standard port as well.
|
||||
The following are all valid options:
|
||||
|
||||
```python
|
||||
LDAP_HOSTNAME = 'myhost'
|
||||
|
||||
LDAP_HOSTNAME = 'ldap.example.com:9000'
|
||||
|
||||
LDAP_HOSTNAME = 'ldap://foo.bar.org'
|
||||
```
|
||||
|
||||
**Important:** Communication between Lumi2 and LDAP is currently not encrypted, so anyone listening to the network traffic between the two can read user information being
|
||||
exchanged between Lumi2 and your LDAP server.
|
||||
Deploying Lumi2 alongside LDAP using `docker-compose` is therefor highly recommended.
|
||||
|
||||
#### Bind user settings
|
||||
|
||||
Provide the DN (distinguished name) and password for a user with read- and write-access to your LDAP server by setting the `LDAP_BIND_USER_DN` and `LDAP_BIND_USER_PASSWORD`
|
||||
variables respectively.
|
||||
|
||||
The `LDAP_BASE_DN` variable tells Lumi2 what the base DN (the root entry) of your LDAP server is called.
|
||||
|
||||
The `LDAP_USERS_OU` and `LDAP_GROUPS_OU` variables tell Lumi2 under which OU users and groups can be found or created.
|
||||
Both must be direct children of the root entry.
|
||||
If they do not exist yet on your LDAP server, they will be newly created by Lumi2 when it first starts.
|
||||
|
||||
### Logging
|
||||
|
||||
By default, the lumi2 container logs HTTP access information to the console.
|
||||
|
||||
You can additionally write the HTTP access logs to a file by specifying `LOG_FILE_PATH`.
|
||||
Note that the specified path points to *inside* the container, so if you want to persist access logs across container restarts, you should set up a Docker volume accordingly.
|
||||
Make sure the specified path is writeable by Lumi2.
|
||||
|
||||
`LOG_FILE_MAX_SIZE` specifies how large the log file can get before being replaced (log rotation).
|
||||
Two access log files are kept: the one currently in use, and the previous one which has reached the maximum size. Any older log files are automatically deleted.
|
||||
To disable log rotation, leave the variable unspecified or set it to `0`.
|
||||
|
||||
## Running the server
|
||||
|
||||
The `Dockerfile` and `docker-compose.yml` create a Lumi2 instance running behind a [waitress](https://docs.pylonsproject.org/projects/waitress/en/latest/) WSGI server,
|
||||
exposing the Lumi2 web interface on port 80.
|
||||
It is recommended to use a reverse proxy in front of the waitress server.
|
||||
|
||||
Inside the container, Lumi2 and waitress are run by a non-root user.
|
||||
You can specify the `UID`/`GID` of this user by setting the `LUMI2_UID`/`LUMI2_GID` build arguments (see `docker-compose.yml` for reference).
|
||||
|
||||
You should make sure your `config.py` file persists across container restarts and is readable by the container's non-root user.
|
||||
The `LUMI2_CONFIG` environment variable is necessary to tell Lumi2 where `config.py` can be found inside the container.
|
||||
|
||||
Once you have configured `docker-compose.yml` and `config.py`, start Lumi2 with
|
||||
|
||||
```
|
||||
sudo docker-compose up -d --build
|
||||
```
|
||||
|
||||
# Development
|
||||
|
||||
Lumi2 is a python application, built using the Flask framework.
|
||||
The following frameworks and/or libraries are also used:
|
||||
|
||||
- Backend:
|
||||
- [Flask](https://flask.palletsprojects.com/en/2.2.x/) - a python web framework
|
||||
- [WTForms](https://wtforms.readthedocs.io/en/3.0.x/) - a python library for web form handling
|
||||
- [FlaskWTF](https://flask-wtf.readthedocs.io/en/1.0.x/) - an extension to WTForms specific to Flask
|
||||
- [flask-restful](https://flask-restful.readthedocs.io/en/latest/quickstart.html) - a minimalistic library for Flask to build RESTful web APIs
|
||||
- [ldap3](https://ldap3.readthedocs.io/en/latest/) - python bindings for interaction with LDAP servers
|
||||
- [pytest](https://docs.pytest.org/en/7.2.x/) - a python testing framework
|
||||
- [coverage](https://coverage.readthedocs.io/en/6.5.0/) - test code coverage reporting used in conjunction with pytest
|
||||
- [Pillow](https://pillow.readthedocs.io/en/stable/) - a python image manipulation library
|
||||
- Frontend:
|
||||
- [Bootstrap 5](https://getbootstrap.com/docs/5.2/getting-started/introduction/) - a CSS framework and component library
|
||||
- [Bootstrap Icons](https://icons.getbootstrap.com/) - an SVG-icon pack used in conjunction with Bootstrap
|
||||
- [jQuery](https://api.jquery.com/) - a JavaScript library for DOM manipulation and AJAX routines
|
||||
|
||||
## Theming (SASS)
|
||||
|
||||
To customize the bootstrap theme, some of Bootstrap 5's SASS variables are modified in `/scss/bootstrap.scss` and then compiled using a SASS preprocessor.
|
||||
|
||||
Using a CLI SASS preprocessor:
|
||||
|
||||
```bash
|
||||
npm install -g sass
|
||||
```
|
||||
|
||||
You can modify the SASS variables and then compile into CSS:
|
||||
|
||||
```
|
||||
sass scss/bootstrap.scss lumi2/static/css/bootstrap.css
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Make sure all dependencies listed in `requirements.txt` are installed.
|
||||
To run all unit tests, simply run the following from within the repository root:
|
||||
|
|
41
config.py
Normal file
|
@ -0,0 +1,41 @@
|
|||
"""Configuration file for LUMI2."""
|
||||
|
||||
# The Flask secret key used cryptographic operations.
|
||||
# This should be a long (>32 characters), random alphanumeric string.
|
||||
SECRET_KEY = 'CHANGEME'
|
||||
# The hashed administrator password, which defaults to 'admin'.
|
||||
# Replace this with the hash of a STRONG password.
|
||||
ADMIN_PASSWORD = 'pbkdf2:sha256:260000$J9yKJOAvWfvaO9Op$f959d88402f67a5143808a00e35d17e636546f1caf5a85c1b6ab1165d1780448'
|
||||
|
||||
# URL or hostname of the LDAP server.
|
||||
# Currently, only unencrypted connections are supported.
|
||||
LDAP_HOSTNAME = 'ldap://openldap'
|
||||
# Credentials for an LDAP bind user with read- and write access to the server.
|
||||
LDAP_BIND_USER_DN = 'cn=admin,dc=example,dc=com'
|
||||
LDAP_BIND_USER_PASSWORD = 'admin'
|
||||
# Base DN of the LDAP server.
|
||||
LDAP_BASE_DN = 'dc=example,dc=com'
|
||||
|
||||
# DN of the organizational unit beneath which users are located.
|
||||
LDAP_USERS_OU = 'ou=users,dc=example,dc=com'
|
||||
# DN of the organizational unit beneath which groups are located.
|
||||
LDAP_GROUPS_OU = 'ou=groups,dc=example,dc=com'
|
||||
|
||||
# The hostname and port number where this LUMI2 instance can be reached.
|
||||
#SERVER_NAME = 'lumi2.example.com:80'
|
||||
# Maximum size in Bytes for incoming requests.
|
||||
# Limits the size of uploaded user profile pictures.
|
||||
MAX_CONTENT_LENGTH = 8_000_000
|
||||
|
||||
# If specified, an HTTP access log is saved to this file.
|
||||
# Make sure the directory for the log file exists and is writeable by lumi2.
|
||||
#LOG_FILE_PATH = '/path/to/file.log'
|
||||
# Maximum log file size in Bytes. When exceeded, the log gets rotated.
|
||||
# Set to 0 to disable log file rotation (can eat up disk space!)
|
||||
#LOG_FILE_MAX_SIZE = 32_000_000
|
||||
|
||||
# The title of pages as displayed in the browser.
|
||||
SITE_TITLE = 'LUMI 2'
|
||||
# Additional metadata as displayed by browsers, search engines, etc.
|
||||
SITE_AUTHOR = 'LUMI 2 Development Team'
|
||||
SITE_DESCRIPTION = 'A simple frontend for LDAP account management.'
|
|
@ -4,23 +4,22 @@ version: "3"
|
|||
|
||||
services:
|
||||
lumi2:
|
||||
build: .
|
||||
container_name: lumi2
|
||||
command: flask --app /app/lumi2 --debug run --host 0.0.0.0 --port 80
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
LUMI2_UID: 1000
|
||||
LUMI2_GID: 1000
|
||||
volumes:
|
||||
- ./lumi2/__init__.py:/app/lumi2/__init__.py:ro
|
||||
- ./lumi2/exceptions.py:/app/lumi2/exceptions.py:ro
|
||||
- ./lumi2/ldap.py:/app/lumi2/ldap.py:ro
|
||||
- ./lumi2/usermodel.py:/app/lumi2/usermodel.py:ro
|
||||
- ./lumi2/usermanager.py:/app/lumi2/usermanager.py:ro
|
||||
- ./lumi2/static/:/app/lumi2/static/:ro
|
||||
- ./lumi2/templates/:/app/lumi2/templates/:ro
|
||||
- ./config.py/:/app/config.py:ro
|
||||
environment:
|
||||
- LUMI2_CONFIG=/app/config.py
|
||||
ports:
|
||||
- "8000:80"
|
||||
depends_on:
|
||||
- lumi2-openldap
|
||||
lumi2-openldap:
|
||||
container_name: lumi2-openldap
|
||||
- openldap
|
||||
openldap:
|
||||
container_name: openldap
|
||||
image: osixia/openldap
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
|
@ -34,18 +33,6 @@ services:
|
|||
LDAP_DOMAIN: "example.com"
|
||||
LDAP_ADMIN_PASSWORD: "admin"
|
||||
LDAP_CONFIG_PASSWORD: "admin"
|
||||
LDAP_TLS_VERIFY_CLIENT: "allow"
|
||||
lumi2-phpldapadmin:
|
||||
container_name: lumi2-phpldapadmin
|
||||
image: osixia/phpldapadmin
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- lumi2-openldap
|
||||
ports:
|
||||
- "8001:80"
|
||||
environment:
|
||||
PHPLDAPADMIN_LDAP_HOSTS: "openldap"
|
||||
PHPLDAPADMIN_HTTPS: "false"
|
||||
PHPLDAPADMIN_SERVER_ADMIN: "admin"
|
||||
LDAP_TLS_VERIFY_CLIENT: "never"
|
||||
|
||||
...
|
||||
|
|
BIN
docs/demo.gif
Normal file
After Width: | Height: | Size: 13 MiB |
1
docs/ldap-dit-structure.drawio
Normal file
|
@ -0,0 +1 @@
|
|||
<mxfile host="Electron" modified="2022-12-03T12:41:26.469Z" agent="5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) draw.io/20.3.0 Chrome/104.0.5112.114 Electron/20.1.3 Safari/537.36" etag="hW4hZfNtdAPsV38uLWIx" version="20.3.0" type="device"><diagram id="y0q7_5OBH_WIh0eJqhVD" name="Page-1">7Vtdd6I6FP01Pl6XEPDj0Wpn7oOdcS1n1tx5jBAhHSDcEKr0198EEj5FbWvV3tKHlnNOEuDsfZKdaHtg5u++Uhi6D8RGXk8f2LsemPd0XRtOxvyP8CSZZzKWDodiWzYqHCv8jKRzIL0xtlFUacgI8RgOq06LBAGyWMUHKSXbarMN8ap3DaGDGo6VBb2m9xe2mSu9/MWKwN8IO6689VgfZQEfqsbyTSIX2mRbcoH7HphRQlh25e9myBPJU3nJ+n1pieYPRlHATunwpH8zHxbhPF6QYL2a7YCxfPxLvsUT9GL5wj196PHx7tb8whEXjITKxwfP3cqH6441rXt4t1KrNBcsUQmmJA5sJJ5R4+GtixlahdAS0S2nFPe5zPdk+AlRhjk4Uw87Aff52LbFQHJYEUa71vxoedY5XRHxEaMJbyI7mAMJVKLszNwWsA+lyy0BruCFkmhOPnCBBb+QcLwAGm10IFcD/s6EMpc4JIDegnCMsgw9IsYSWUYwZqSaP7TD7B/RvW9K67ccTFzPd2UjUUbA36bUSZi/1XjCKLqlVtHPnooC5GZAApR5vmCRhDSevR2yG/VXA4xngMTUQodSZcqZAVIHsWN0bzKAIg8y/FR9kPMDah4vNkIdGOBn/jQC158BZp+i9vRa7WmgWXyaftHqGzeyJai6kqZkdFeQLdkbfoiCHHYF2VKQo8mkb95YSaqB98CVZzSWI9RQ5KNjwjQARvvRm5GYd4sjRCNu2BY30A76IU+nNC3in4QgTzmrwhQxSv6gGfEILSpvw8uu5oISRotDhOghfPfxojoTbUjA1PA9HQzTn3NN1RVaAMNo0EIHF6WFfpwWKbwNVki89xKi48ABDoATVuvRRTlgHOdAh+gBRI1RrayvPtm3r81HqtrhSQu7sn4FCSbaSYv+2LwgD8Dwf6fDBYLyQTT9srpcHU4d1eXZXvVawlw9Zqn4cYDYd+os+YpNggYloi32PZjmznKxZy9gQmLxfhGD1h9l3bmE4meefVgUMKQKCzCotFiJnhKBqiyv45fa8lmKWUGdFooWFEW8/VIBNshdCxgx1UsdD4qoDSM3p3Nj9jhDqZvV6T7ffx9bwTXj3Sb8cQPzVMxPBffL8p1PeppIixVkwfs0WApEMjD1bMmWPGTjKPRg8g36qKWzD7Enb9qHagBjgCxCw362UqimjyFyli4RU4lo3+/3S0GhLZcwiraE2qV4jbcKWg9t2AvWk4gvEDhwFmmvubGHk/UFRhziStLBdXr3jNcMspLN+aPoeBLHDhRvk3kHlMVepmnvtqSA49KiZR/JEpreoNtDvm0PWVebV99EguZheyczXi0zgPEhZAZo7ho7mXFWmQEGN6czQPNTmFxn5NN7XWP8qAeUxvglpAkH8IDE+JHslResv5V9O3XxAnWR1ewtq4umjm2oC0GrPYfUXCuQBKG2s4tUX6jTjU5gHNzejKrnGADsERgX/TRfKZxOYJxFYExOFRjDqwqM5tdr0vr9vvkZ4H9jJBaJqFMZb6/2msrQm6uAftlVQMmXEvJKS5Sn+Xw9T+nwgPy1yIlo1TPvSvHeTO9NB8KdXaS/sz1q+Wzk1C3oCcMqKfTiIc3559AhLd9caNchexk4fjcCaq+VIdD2cdBpkDfPSuOx3r85FWJ2KuR8KsTQP4QKUY/ZqZB3VSHj0eTmVEjzpFupkHyWv7YE+SR6ISvCa+gFbhb/ZJDGSv+qAe7/Aw==</diagram></mxfile>
|
3
docs/ldap-dit-structure.svg
Normal file
After Width: | Height: | Size: 16 KiB |
BIN
docs/screenshot-group-edit.png
Normal file
After Width: | Height: | Size: 989 KiB |
BIN
docs/screenshot-index.png
Normal file
After Width: | Height: | Size: 216 KiB |
BIN
docs/screenshot-user.png
Normal file
After Width: | Height: | Size: 323 KiB |
BIN
docs/screenshot-users.png
Normal file
After Width: | Height: | Size: 2.6 MiB |
|
@ -1,6 +1,11 @@
|
|||
import os
|
||||
import os, logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
from flask import Flask
|
||||
from flask_restful import Api
|
||||
|
||||
|
||||
api = Api()
|
||||
|
||||
|
||||
def create_app(test_config=None):
|
||||
|
@ -9,26 +14,13 @@ def create_app(test_config=None):
|
|||
Creates and configures the flask app.
|
||||
"""
|
||||
|
||||
from . import default_configuration
|
||||
app = Flask(__name__, instance_relative_config=True)
|
||||
app.config.from_mapping(
|
||||
SECRET_KEY='ChangeMeInProduction',
|
||||
SITE_URL='https://www.example.com/',
|
||||
SITE_TITLE='LUMI 2',
|
||||
SITE_AUTHOR='LUMI 2 Development Team',
|
||||
SITE_DESCRIPTION='A simple frontend for LDAP account management.',
|
||||
LDAP_HOSTNAME='ldap://openldap',
|
||||
LDAP_BIND_USER_DN='cn=admin,dc=example,dc=com',
|
||||
LDAP_BIND_USER_PASSWORD='admin',
|
||||
LDAP_BASE_DN='dc=example,dc=com',
|
||||
LDAP_USERS_OU='ou=users,dc=example,dc=com',
|
||||
LDAP_GROUPS_OU='ou=groups,dc=example,dc=com',
|
||||
LDAP_USER_OBJECT_CLASS='inetOrgPerson',
|
||||
LDAP_GROUP_OBJECT_CLASS='groupOfUniqueNames',
|
||||
)
|
||||
app.config.from_object(default_configuration)
|
||||
|
||||
if test_config is None:
|
||||
# Load the instance config, if it exists, when not testing
|
||||
app.config.from_pyfile('config.py', silent=True)
|
||||
app.config.from_envvar('LUMI2_CONFIG', silent=True)
|
||||
else:
|
||||
# Load the test config if passed in
|
||||
app.config.from_mapping(test_config)
|
||||
|
@ -39,8 +31,39 @@ def create_app(test_config=None):
|
|||
except OSError:
|
||||
pass
|
||||
|
||||
# Set up logging
|
||||
if test_config is None and not app.config['DEBUG']:
|
||||
app.logger = logging.getLogger('lumi2')
|
||||
app.logger.setLevel(logging.INFO)
|
||||
app.logger.propagate = False
|
||||
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(logging.INFO)
|
||||
app.logger.addHandler(console_handler)
|
||||
|
||||
if 'LOG_FILE_PATH' in app.config:
|
||||
file_handler = RotatingFileHandler(
|
||||
app.config['LOG_FILE_PATH'],
|
||||
maxBytes=app.config['LOG_FILE_MAX_SIZE'],
|
||||
backupCount=1,
|
||||
)
|
||||
file_handler.setLevel(logging.INFO)
|
||||
app.logger.addHandler(file_handler)
|
||||
|
||||
from lumi2.logging import log_request
|
||||
app.after_request(log_request)
|
||||
|
||||
from . import auth
|
||||
app.register_blueprint(auth.bp)
|
||||
|
||||
from . import usermanager
|
||||
app.register_blueprint(usermanager.bp)
|
||||
app.add_url_rule('/', endpoint='index')
|
||||
|
||||
from . import webapi
|
||||
api.add_resource(webapi.UserResource, '/api/user/<string:username>')
|
||||
api.add_resource(webapi.GroupResource, '/api/group/<string:groupname>')
|
||||
api.add_resource(webapi.GroupMemberResource, '/api/group/<string:groupname>/member/<string:username>')
|
||||
api.init_app(app)
|
||||
|
||||
return app
|
||||
|
|
68
lumi2/auth.py
Normal file
|
@ -0,0 +1,68 @@
|
|||
import functools
|
||||
|
||||
from flask import (
|
||||
Blueprint, current_app, g, flash, redirect, url_for, session,
|
||||
render_template
|
||||
)
|
||||
from werkzeug.security import check_password_hash
|
||||
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import ValidationError, PasswordField, SubmitField
|
||||
from wtforms.validators import InputRequired
|
||||
|
||||
|
||||
bp = Blueprint('auth', __name__)
|
||||
|
||||
class LoginForm(FlaskForm):
|
||||
@staticmethod
|
||||
def validate_password(form, field) -> None:
|
||||
if not field.data:
|
||||
raise ValidationError("Please enter a password.")
|
||||
if not check_password_hash(current_app.config['ADMIN_PASSWORD'], field.data):
|
||||
raise ValidationError("Invalid password.")
|
||||
|
||||
password = PasswordField(
|
||||
'Password',
|
||||
[InputRequired('Please enter a password.'), validate_password],
|
||||
)
|
||||
|
||||
submit = SubmitField(
|
||||
'Log In',
|
||||
)
|
||||
|
||||
@bp.route("/login", methods=("GET", "POST"))
|
||||
def login():
|
||||
form = LoginForm()
|
||||
if form.validate_on_submit():
|
||||
session.clear()
|
||||
session['is_authenticated'] = True
|
||||
return redirect(url_for('index'))
|
||||
|
||||
return render_template('auth/login.html', form=form)
|
||||
|
||||
|
||||
@bp.before_app_request
|
||||
def load_logged_in_user():
|
||||
authentication_status = session.get('is_authenticated')
|
||||
if authentication_status:
|
||||
g.is_authenticated = authentication_status
|
||||
else:
|
||||
g.is_authenticated = False
|
||||
|
||||
|
||||
@bp.route('/logout')
|
||||
def logout():
|
||||
session.clear()
|
||||
flash("You were logged out.")
|
||||
return redirect(url_for('index'))
|
||||
|
||||
|
||||
def login_required(view):
|
||||
@functools.wraps(view)
|
||||
def wrapped_view(**kwargs):
|
||||
if not g.is_authenticated:
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
return view(**kwargs)
|
||||
|
||||
return wrapped_view
|
26
lumi2/default_configuration.py
Normal file
|
@ -0,0 +1,26 @@
|
|||
"""Default configuration for lumi2.
|
||||
|
||||
The values here should be overridden as necessary prior to deployment.
|
||||
"""
|
||||
|
||||
APP_VERSION = '1.0'
|
||||
|
||||
SECRET_KEY = 'INSECURE'
|
||||
ADMIN_PASSWORD = 'pbkdf2:sha256:260000$J9yKJOAvWfvaO9Op$f959d88402f67a5143808a00e35d17e636546f1caf5a85c1b6ab1165d1780448'
|
||||
|
||||
#SERVER_NAME = 'lumi2.example.com:80'
|
||||
|
||||
SITE_TITLE = 'LUMI 2'
|
||||
SITE_AUTHOR = 'LUMI 2 Development Team'
|
||||
SITE_DESCRIPTION = 'A simple frontend for LDAP account management.'
|
||||
|
||||
LDAP_HOSTNAME = 'ldap://ldap.example.com'
|
||||
LDAP_BIND_USER_DN = 'cn=admin,dc=example,dc=com'
|
||||
LDAP_BIND_USER_PASSWORD = 'secret'
|
||||
LDAP_BASE_DN = 'dc=example,dc=com'
|
||||
LDAP_USERS_OU = 'ou=users,dc=example,dc=com'
|
||||
LDAP_GROUPS_OU = 'ou=groups,dc=example,dc=com'
|
||||
|
||||
LOG_FILE_MAX_SIZE = 0
|
||||
|
||||
MAX_CONTENT_LENGTH = 8_000_000
|
|
@ -4,3 +4,27 @@ class MissingConfigKeyError(RuntimeError):
|
|||
"""Raised when an expected appconfig key-value pair is not found."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class InvalidStringFormatException(Exception):
|
||||
"""Exception raised when an invalid string format is encountered."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class InvalidImageException(Exception):
|
||||
"""Exception raised when an invalid image is encountered."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class AttributeNotFoundException(Exception):
|
||||
"""Exception raised when an entry's is unexpectedly not set."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class InvalidAttributeException(Exception):
|
||||
"""Exception raised when an entry's is unexpectedly not set."""
|
||||
|
||||
pass
|
||||
|
|
|
@ -17,12 +17,7 @@ from ldap3 import Connection, Server, ALL, Reader, Writer, ObjectDef, MODIFY_REP
|
|||
from PIL import Image
|
||||
|
||||
from lumi2.usermodel import User, Group
|
||||
from lumi2.exceptions import MissingConfigKeyError
|
||||
|
||||
|
||||
class InvalidStringFormatException(Exception):
|
||||
"""Exception raised when an invalid string format is encountered."""
|
||||
pass
|
||||
from lumi2.exceptions import *
|
||||
|
||||
|
||||
class InvalidConnectionException(Exception):
|
||||
|
@ -40,15 +35,6 @@ class EntryNotFoundException(Exception):
|
|||
pass
|
||||
|
||||
|
||||
class AttributeNotFoundException(Exception):
|
||||
"""Exception raised when an entry's is unexpectedly not set."""
|
||||
pass
|
||||
|
||||
class InvalidAttributeException(Exception):
|
||||
"""Exception raised when an entry's is unexpectedly not set."""
|
||||
pass
|
||||
|
||||
|
||||
def _assert_is_valid_base_dn(input_str) -> None:
|
||||
"""Checks whether the input string is a valid LDAP base DN.
|
||||
|
||||
|
@ -391,8 +377,6 @@ def _assert_app_config_is_valid() -> None:
|
|||
- 'LDAP_BASE_DN'
|
||||
- 'LDAP_USERS_OU'
|
||||
- 'LDAP_GROUPS_OU'
|
||||
- 'LDAP_USER_OBJECT_CLASS'
|
||||
- 'LDAP_GROUP_OBJECT_CLASS'
|
||||
|
||||
Returns
|
||||
-------
|
||||
|
@ -415,8 +399,6 @@ def _assert_app_config_is_valid() -> None:
|
|||
'LDAP_BASE_DN',
|
||||
'LDAP_USERS_OU',
|
||||
'LDAP_GROUPS_OU',
|
||||
'LDAP_USER_OBJECT_CLASS',
|
||||
'LDAP_GROUP_OBJECT_CLASS',
|
||||
]
|
||||
|
||||
for key in required_keys:
|
||||
|
@ -433,8 +415,6 @@ def _assert_app_config_is_valid() -> None:
|
|||
_assert_is_valid_bind_user_dn(current_app.config['LDAP_BIND_USER_DN'])
|
||||
for base in ['LDAP_USERS_OU', 'LDAP_GROUPS_OU']:
|
||||
_assert_is_valid_ou_dn(current_app.config[base])
|
||||
_assert_is_valid_user_object_class(current_app.config['LDAP_USER_OBJECT_CLASS'])
|
||||
_assert_is_valid_group_object_class(current_app.config['LDAP_GROUP_OBJECT_CLASS'])
|
||||
|
||||
|
||||
def get_connection() -> Connection:
|
||||
|
@ -643,9 +623,9 @@ def get_user(connection: Connection, uid: str) -> User:
|
|||
last_name = attributes['sn'][0]
|
||||
display_name = attributes['displayName'][0]
|
||||
|
||||
# Retrieve base64-encoded password hash prefixed with '{SHA512}'
|
||||
# Retrieve base64-encoded password hash prefixed with '{SSHA}'
|
||||
password_hash = attributes['userPassword'][0]
|
||||
expected_hash_type = '{SHA512}'
|
||||
expected_hash_type = '{SSHA}'
|
||||
if not password_hash.startswith(expected_hash_type):
|
||||
raise InvalidAttributeException(
|
||||
f"Unexpected password hash in entry '{user_dn}': expected " \
|
||||
|
@ -696,7 +676,7 @@ def create_user(connection: Connection, user: User) -> None:
|
|||
|
||||
attributes = {
|
||||
"uid": user.username,
|
||||
"userPassword": "{SHA512}" + user.password_hash,
|
||||
"userPassword": "{SSHA}" + user.password_hash,
|
||||
"cn": user.first_name,
|
||||
"sn": user.last_name,
|
||||
"displayName": user.display_name,
|
||||
|
@ -743,7 +723,7 @@ def update_user(connection: Connection, user: User) -> None:
|
|||
user.picture.save(new_picture_bytes, format="jpeg")
|
||||
|
||||
new_attributes = {
|
||||
"userPassword": [(MODIFY_REPLACE, ["{SHA512}" + user.password_hash])],
|
||||
"userPassword": [(MODIFY_REPLACE, ["{SSHA}" + user.password_hash])],
|
||||
"mail": [(MODIFY_REPLACE, [user.email])],
|
||||
"cn": [(MODIFY_REPLACE, [user.first_name])],
|
||||
"sn": [(MODIFY_REPLACE, [user.last_name])],
|
||||
|
@ -924,6 +904,52 @@ def delete_group(connection: Connection, group_cn: str) -> None:
|
|||
connection.delete(group_dn)
|
||||
|
||||
|
||||
def update_group(connection: Connection, group: Group) -> None:
|
||||
"""Updates the specified Group on the LDAP server.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
connection : ldap3.Connection
|
||||
Bound Connection object to an LDAP server.
|
||||
group : lumi2.usermodel.Group
|
||||
The Group for which the LDAP entry is to be updated on the server.
|
||||
|
||||
Raises
|
||||
------
|
||||
TypeError
|
||||
If group is not of type Group.
|
||||
EntryNotFoundException
|
||||
If the specified group does not exist in the DIT, or if a user who is a
|
||||
member of the Group does not exist in the DIT.
|
||||
"""
|
||||
|
||||
_assert_is_valid_connection(connection)
|
||||
if not isinstance(group, Group):
|
||||
raise TypeError(f"Expected a lumi2.usermodel.Group but got: '{type(group)}'.")
|
||||
|
||||
if not group_exists(connection, group.get_dn()):
|
||||
raise EntryNotFoundException(
|
||||
f"Failed to update group '{group.groupname}': no such entry found."
|
||||
)
|
||||
|
||||
member_dn_list = []
|
||||
for user in group.members:
|
||||
user_dn = user.get_dn()
|
||||
if not user_exists(connection, user_dn):
|
||||
raise EntryNotFoundException(
|
||||
f"Failed to create group '{group.groupname}': no entry found for " \
|
||||
f"user '{user.username}'."
|
||||
)
|
||||
member_dn_list.append(user_dn)
|
||||
|
||||
connection.modify(
|
||||
group.get_dn(),
|
||||
{
|
||||
"uniqueMember": [(MODIFY_REPLACE, member_dn_list)],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def get_group(connection: Connection, group_cn: str) -> Group:
|
||||
"""Retrieves the group with the specified CN (common name) from the LDAP server.
|
||||
|
||||
|
|
15
lumi2/logging.py
Normal file
|
@ -0,0 +1,15 @@
|
|||
"""Logging operations for lumi2."""
|
||||
|
||||
import logging as l
|
||||
from time import strftime
|
||||
|
||||
from flask import request
|
||||
|
||||
|
||||
def log_request(response):
|
||||
logger = l.getLogger('lumi2')
|
||||
|
||||
timestamp = strftime('[%Y-%b-%d %H:%M]')
|
||||
logger.info(f"{timestamp} {request.remote_addr} {request.method} {request.scheme} {request.full_path} {response.status_code}")
|
||||
|
||||
return response
|
10723
lumi2/static/css/bootstrap.css
vendored
Normal file
1
lumi2/static/css/bootstrap.css.map
Normal file
145
lumi2/static/css/fonts.css
Normal file
|
@ -0,0 +1,145 @@
|
|||
/* Montserrat */
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
src: url('/static/fonts/montserrat/montserrat-thin-webfont.woff2') format('woff2'),
|
||||
url('/static/fonts/montserrat/montserrat-thin-webfont.woff') format('woff');
|
||||
font-weight: 100;
|
||||
font-style: normal;
|
||||
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
src: url('/static/fonts/montserrat/montserrat-thinitalic-webfont.woff2') format('woff2'),
|
||||
url('/static/fonts/montserrat/montserrat-thinitalic-webfont.woff') format('woff');
|
||||
font-weight: 100;
|
||||
font-style: italic;
|
||||
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
src: url('/static/fonts/montserrat/montserrat-extralight-webfont.woff2') format('woff2'),
|
||||
url('/static/fonts/montserrat/montserrat-extralight-webfont.woff') format('woff');
|
||||
font-weight: 200;
|
||||
font-style: normal;
|
||||
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
src: url('/static/fonts/montserrat/montserrat-extralightitalic-webfont.woff2') format('woff2'),
|
||||
url('/static/fonts/montserrat/montserrat-extralightitalic-webfont.woff') format('woff');
|
||||
font-weight: 200;
|
||||
font-style: italic;
|
||||
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
src: url('/static/fonts/montserrat/montserrat-light-webfont.woff2') format('woff2'),
|
||||
url('/static/fonts/montserrat/montserrat-light-webfont.woff') format('woff');
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
src: url('/static/fonts/montserrat/montserrat-lightitalic-webfont.woff2') format('woff2'),
|
||||
url('/static/fonts/montserrat/montserrat-lightitalic-webfont.woff') format('woff');
|
||||
font-weight: 300;
|
||||
font-style: italic;
|
||||
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
src: url('/static/fonts/montserrat/montserrat-regular-webfont.woff2') format('woff2'),
|
||||
url('/static/fonts/montserrat/montserrat-regular-webfont.woff') format('woff');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
src: url('/static/fonts/montserrat/montserrat-italic-webfont.woff2') format('woff2'),
|
||||
url('/static/fonts/montserrat/montserrat-italic-webfont.woff') format('woff');
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
src: url('/static/fonts/montserrat/montserrat-medium-webfont.woff2') format('woff2'),
|
||||
url('/static/fonts/montserrat/montserrat-medium-webfont.woff') format('woff');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
src: url('/static/fonts/montserrat/montserrat-mediumitalic-webfont.woff2') format('woff2'),
|
||||
url('/static/fonts/montserrat/montserrat-mediumitalic-webfont.woff') format('woff');
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
src: url('/static/fonts/montserrat/montserrat-semibold-webfont.woff2') format('woff2'),
|
||||
url('/static/fonts/montserrat/montserrat-semibold-webfont.woff') format('woff');
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
src: url('/static/fonts/montserrat/montserrat-semibolditalic-webfont.woff2') format('woff2'),
|
||||
url('/static/fonts/montserrat/montserrat-semibolditalic-webfont.woff') format('woff');
|
||||
font-weight: 600;
|
||||
font-style: italic;
|
||||
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
src: url('/static/fonts/montserrat/montserrat-bold-webfont.woff2') format('woff2'),
|
||||
url('/static/fonts/montserrat/montserrat-bold-webfont.woff') format('woff');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
src: url('/static/fonts/montserrat/montserrat-bolditalic-webfont.woff2') format('woff2'),
|
||||
url('/static/fonts/montserrat/montserrat-bolditalic-webfont.woff') format('woff');
|
||||
font-weight: 700;
|
||||
font-style: italic;
|
||||
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
src: url('/static/fonts/montserrat/montserrat-extrabold-webfont.woff2') format('woff2'),
|
||||
url('/static/fonts/montserrat/montserrat-extrabold-webfont.woff') format('woff');
|
||||
font-weight: 800;
|
||||
font-style: normal;
|
||||
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
src: url('/static/fonts/montserrat/montserrat-extrabolditalic-webfont.woff2') format('woff2'),
|
||||
url('/static/fonts/montserrat/montserrat-extrabolditalic-webfont.woff') format('woff');
|
||||
font-weight: 800;
|
||||
font-style: italic;
|
||||
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
src: url('/static/fonts/montserrat/montserrat-black-webfont.woff2') format('woff2'),
|
||||
url('/static/fonts/montserrat/montserrat-black-webfont.woff') format('woff');
|
||||
font-weight: 900;
|
||||
font-style: normal;
|
||||
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
src: url('/static/fonts/montserrat/montserrat-blackitalic-webfont.woff2') format('woff2'),
|
||||
url('/static/fonts/montserrat/montserrat-blackitalic-webfont.woff') format('woff');
|
||||
font-weight: 900;
|
||||
font-style: italic;
|
||||
|
||||
}
|
|
@ -1,4 +0,0 @@
|
|||
body {
|
||||
background-color: #222222;
|
||||
color: #CCCCCC;
|
||||
}
|
BIN
lumi2/static/fonts/montserrat/montserrat-black-webfont.woff
Normal file
BIN
lumi2/static/fonts/montserrat/montserrat-black-webfont.woff2
Normal file
BIN
lumi2/static/fonts/montserrat/montserrat-bold-webfont.woff
Normal file
BIN
lumi2/static/fonts/montserrat/montserrat-bold-webfont.woff2
Normal file
BIN
lumi2/static/fonts/montserrat/montserrat-bolditalic-webfont.woff
Normal file
BIN
lumi2/static/fonts/montserrat/montserrat-extrabold-webfont.woff
Normal file
BIN
lumi2/static/fonts/montserrat/montserrat-extrabold-webfont.woff2
Normal file
BIN
lumi2/static/fonts/montserrat/montserrat-extralight-webfont.woff
Normal file
BIN
lumi2/static/fonts/montserrat/montserrat-italic-webfont.woff
Normal file
BIN
lumi2/static/fonts/montserrat/montserrat-italic-webfont.woff2
Normal file
BIN
lumi2/static/fonts/montserrat/montserrat-light-webfont.woff
Normal file
BIN
lumi2/static/fonts/montserrat/montserrat-light-webfont.woff2
Normal file
BIN
lumi2/static/fonts/montserrat/montserrat-medium-webfont.woff
Normal file
BIN
lumi2/static/fonts/montserrat/montserrat-medium-webfont.woff2
Normal file
BIN
lumi2/static/fonts/montserrat/montserrat-regular-webfont.woff
Normal file
BIN
lumi2/static/fonts/montserrat/montserrat-regular-webfont.woff2
Normal file
BIN
lumi2/static/fonts/montserrat/montserrat-semibold-webfont.woff
Normal file
BIN
lumi2/static/fonts/montserrat/montserrat-semibold-webfont.woff2
Normal file
BIN
lumi2/static/fonts/montserrat/montserrat-thin-webfont.woff
Normal file
BIN
lumi2/static/fonts/montserrat/montserrat-thin-webfont.woff2
Normal file
BIN
lumi2/static/fonts/montserrat/montserrat-thinitalic-webfont.woff
Normal file
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.8 KiB |
Before Width: | Height: | Size: 157 KiB After Width: | Height: | Size: 157 KiB |
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.1 KiB |
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 |
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
BIN
lumi2/static/images/base/plus-circle.png
Normal file
After Width: | Height: | Size: 3.9 KiB |
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
After Width: | Height: | Size: 926 B |
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 |
656
lumi2/static/images/base/toolbox.svg
Normal file
|
@ -0,0 +1,656 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Generator: Adobe Illustrator 23.1.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
|
||||
<svg
|
||||
version="1.1"
|
||||
id="Layer_1"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 1000 1000"
|
||||
style="enable-background:new 0 0 1000 1000;"
|
||||
xml:space="preserve"
|
||||
sodipodi:docname="toolbox.svg"
|
||||
inkscape:version="1.2.1 (9c6d41e410, 2022-07-14, custom)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
id="defs540" /><sodipodi:namedview
|
||||
id="namedview538"
|
||||
pagecolor="#505050"
|
||||
bordercolor="#eeeeee"
|
||||
borderopacity="1"
|
||||
inkscape:showpageshadow="0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#505050"
|
||||
showgrid="false"
|
||||
inkscape:zoom="0.51043286"
|
||||
inkscape:cx="903.15501"
|
||||
inkscape:cy="408.47683"
|
||||
inkscape:window-width="1900"
|
||||
inkscape:window-height="1004"
|
||||
inkscape:window-x="10"
|
||||
inkscape:window-y="38"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="Layer_1" />
|
||||
<style
|
||||
type="text/css"
|
||||
id="style295">
|
||||
.st0{fill:#383333;}
|
||||
.st1{fill:#EEF2F4;}
|
||||
.st2{fill:#2B2727;}
|
||||
.st3{fill:#72361F;}
|
||||
.st4{fill:#5B3318;}
|
||||
.st5{fill:#783F21;}
|
||||
.st6{opacity:0.4;}
|
||||
.st7{clip-path:url(#XMLID_34_);}
|
||||
.st8{fill:none;stroke:#300F0F;stroke-width:6;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
|
||||
.st9{opacity:0.44;fill:#72361F;enable-background:new ;}
|
||||
.st10{fill:#C9C9C9;}
|
||||
.st11{fill:#B5B5B5;}
|
||||
.st12{fill:#F45145;}
|
||||
.st13{fill:#DB493E;}
|
||||
.st14{fill:#AA3930;}
|
||||
.st15{fill:#ABABAB;}
|
||||
.st16{fill:#3B69A9;}
|
||||
.st17{fill:#355E98;}
|
||||
.st18{opacity:0.23;fill:#72361F;enable-background:new ;}
|
||||
.st19{fill:#E1E1E1;}
|
||||
.st20{fill:#FDC457;}
|
||||
.st21{fill:#F2B24C;}
|
||||
.st22{fill:#323B40;}
|
||||
.st23{fill:#2D363A;}
|
||||
.st24{fill:#7D4025;}
|
||||
.st25{opacity:8.000000e-02;clip-path:url(#SVGID_2_);}
|
||||
.st26{fill:none;stroke:#300F0F;stroke-width:5.8435;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
|
||||
.st27{fill:none;stroke:#300F0F;stroke-width:7.8052;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
|
||||
.st28{fill:none;stroke:#300F0F;stroke-width:5.3519;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
|
||||
.st29{fill:#D98C40;}
|
||||
.st30{fill:#C87634;}
|
||||
.st31{clip-path:url(#SVGID_4_);}
|
||||
.st32{fill:none;stroke:#CF8139;stroke-width:6;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
|
||||
.st33{fill:none;stroke:#CF8139;stroke-width:4.9126;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<g
|
||||
id="g838"
|
||||
transform="matrix(1.4994361,0,0,1.4994361,-248.73975,-238.08815)"><path
|
||||
id="XMLID_30_"
|
||||
class="st2"
|
||||
d="M 785.7,676 H 191.6 c -13.4,0 -24.2,-10.8 -24.2,-24.2 v 0 c 0,-13.4 10.8,-24.2 24.2,-24.2 h 594.1 c 13.4,0 24.2,10.8 24.2,24.2 v 0 c 0,13.3 -10.8,24.2 -24.2,24.2 z" /><path
|
||||
id="XMLID_46_"
|
||||
class="st3"
|
||||
d="M 706.3,412.9 V 361 c 0,-19.9 -16.1,-36 -36,-36 v 0 c -19.9,0 -36,16.1 -36,36 v 51.8 c 0,33.1 -26.9,60 -60,60 v 0 152 c 0,13.3 10.7,24 24,24 h 144 c 13.3,0 24,-10.7 24,-24 v -152 0 c -33.2,0.1 -60,-26.8 -60,-59.9 z" /><rect
|
||||
id="XMLID_2_"
|
||||
x="656.29999"
|
||||
y="325"
|
||||
class="st3"
|
||||
width="16.4"
|
||||
height="8.3000002" /><path
|
||||
id="XMLID_43_"
|
||||
class="st4"
|
||||
d="M 692.3,412.9 V 361 c 0,-19.9 -16.1,-36 -36,-36 v 0 c -19.9,0 -36,16.1 -36,36 v 51.8 c 0,33.1 -26.9,60 -60,60 v 0 152 c 0,13.3 10.7,24 24,24 h 144 c 13.3,0 24,-10.7 24,-24 v -152 0 c -33.2,0.1 -60,-26.8 -60,-59.9 z" /><path
|
||||
id="XMLID_20_"
|
||||
class="st4"
|
||||
d="m 752.3,472.9 v 0 c -22.6,0 -42.2,-12.5 -52.5,-30.9 l -55.5,22.4 -28.1,15.6 66.6,10.4 69.5,-13.8 z" /><circle
|
||||
id="XMLID_31_"
|
||||
class="st5"
|
||||
cx="656.29999"
|
||||
cy="361.70001"
|
||||
r="12" /><rect
|
||||
id="XMLID_1_"
|
||||
x="311.29999"
|
||||
y="325"
|
||||
class="st3"
|
||||
width="14"
|
||||
height="3.7" /><rect
|
||||
id="XMLID_28_"
|
||||
x="311.29999"
|
||||
y="349.70001"
|
||||
class="st5"
|
||||
width="345.10001"
|
||||
height="24" /><g
|
||||
class="st6"
|
||||
id="g410">
|
||||
<defs
|
||||
id="defs397">
|
||||
<rect
|
||||
id="XMLID_32_"
|
||||
x="311.29999"
|
||||
y="349.70001"
|
||||
class="st6"
|
||||
width="345.10001"
|
||||
height="24" />
|
||||
</defs>
|
||||
<clipPath
|
||||
id="XMLID_34_">
|
||||
<use
|
||||
xlink:href="#XMLID_32_"
|
||||
style="overflow:visible"
|
||||
id="use399" />
|
||||
</clipPath>
|
||||
<g
|
||||
id="XMLID_82_"
|
||||
class="st7"
|
||||
clip-path="url(#XMLID_34_)">
|
||||
<path
|
||||
id="XMLID_11_"
|
||||
class="st8"
|
||||
d="m 331.5,316.7 c 1.2,8.3 8.1,53.2 41.3,68.7 4,1.9 20.1,9.4 28.7,2.7 11.3,-8.9 -3.1,-33.2 9.3,-48 8,-9.6 23.7,-11 34,-9.3 35.6,5.9 36.3,54.3 70.7,62 21.8,4.9 44.8,-9.3 56.7,-16.7 24.8,-15.3 29.2,-31.7 44,-32 9.1,-0.2 22.5,5.6 39.3,35.3" />
|
||||
<path
|
||||
id="XMLID_10_"
|
||||
class="st8"
|
||||
d="m 358.8,336 c -3.8,5.8 -4.4,13 -1.3,18.7 3,5.5 8.3,7.5 10,8 1.8,0.6 8.3,2.3 14,-1.3 5.6,-3.6 8.4,-11.1 6.7,-18.7" />
|
||||
<path
|
||||
id="XMLID_95_"
|
||||
class="st8"
|
||||
d="m 422.1,376 c -0.5,-1.4 -4.9,-14.1 2.7,-25.3 0.4,-0.7 6.9,-10.1 15.3,-10.7 11.9,-0.9 26.6,15.9 31.3,43.3" />
|
||||
<path
|
||||
id="XMLID_96_"
|
||||
class="st8"
|
||||
d="m 438.1,358 c -3.4,1.7 -4,8.6 -2,13.3 0.4,0.9 2.9,6.6 7.3,6.7 4.3,0.1 8.2,-5.3 8,-10 -0.2,-6.8 -9.1,-12 -13.3,-10 z" />
|
||||
<path
|
||||
id="XMLID_97_"
|
||||
class="st8"
|
||||
d="m 495.5,335.4 c 0.3,3.3 1.2,9.6 5.3,16 5.9,9.2 14.3,12.8 17.3,14 2.9,1.2 12.1,4.8 22.7,2 7,-1.8 9.8,-5.3 27.3,-21.3 6.1,-5.6 11.1,-10 14,-12.7" />
|
||||
<path
|
||||
id="XMLID_98_"
|
||||
class="st8"
|
||||
d="m 599.5,380 c 0.9,-8.2 6.3,-14.8 13.3,-16.7 0.7,-0.2 9.9,-2.5 16,4 4.4,4.7 4.1,10.8 4,12" />
|
||||
<path
|
||||
id="XMLID_99_"
|
||||
class="st8"
|
||||
d="m 527.5,338 c 0,9.1 4.4,15.8 9.3,16.7 4.2,0.7 8.2,-3 10,-4.7 5,-4.6 6.3,-10.4 6.7,-12.7" />
|
||||
</g>
|
||||
</g><rect
|
||||
id="XMLID_101_"
|
||||
x="443.20001"
|
||||
y="349.70001"
|
||||
class="st9"
|
||||
width="15.9"
|
||||
height="23.799999" /><rect
|
||||
id="XMLID_134_"
|
||||
x="356.39999"
|
||||
y="349.70001"
|
||||
class="st9"
|
||||
width="25.1"
|
||||
height="23.799999" /><rect
|
||||
id="XMLID_103_"
|
||||
x="552.29999"
|
||||
y="349.70001"
|
||||
class="st9"
|
||||
width="22"
|
||||
height="23.799999" /><g
|
||||
id="XMLID_107_">
|
||||
<polygon
|
||||
id="XMLID_26_"
|
||||
class="st10"
|
||||
points="424.7,317.8 424.7,317.8 424.7,317.8 424.7,317.8 424.7,317.8 431.8,285.2 450.7,285.2 457.9,317.8 457.9,317.8 457.9,317.8 457.9,317.8 457.9,317.8 449.1,341.2 449.1,341.2 449.1,411.5 433.5,411.5 433.5,341.2 433.4,341.2 " />
|
||||
<polygon
|
||||
id="XMLID_24_"
|
||||
class="st11"
|
||||
points="452.9,317.8 452.9,317.8 452.9,317.8 445.7,285.2 450.7,285.2 457.9,317.8 457.9,317.8 457.9,317.8 457.9,317.8 449.1,341.2 449.1,341.2 449.1,411.5 444.1,411.5 444.1,341.2 452.9,317.8 " />
|
||||
<path
|
||||
id="XMLID_15_"
|
||||
class="st12"
|
||||
d="M 417.2,556.4 V 442.3 c 0,-5.5 3.3,-10.3 8.1,-12.3 2,-0.8 2.3,-3.5 0.6,-4.9 l -6.7,-5.6 c -1.8,-1.5 -2.8,-3.7 -2.8,-6 v -5.3 c 0,-2 1.6,-3.6 3.6,-3.6 h 42.4 c 2,0 3.6,1.6 3.6,3.6 v 5.3 c 0,2.3 -1,4.5 -2.8,6 l -6.7,5.6 c -1.6,1.4 -1.3,4 0.6,4.9 4.8,2.1 8.1,6.8 8.1,12.3 v 114.1 c 0,7.4 -6,13.4 -13.4,13.4 h -21.4 c -7.2,0.1 -13.2,-6 -13.2,-13.4 z" />
|
||||
<path
|
||||
id="XMLID_16_"
|
||||
class="st13"
|
||||
d="M 457.4,556.4 V 442.3 c 0,-5.5 -3.3,-10.3 -8.1,-12.3 -2,-0.8 -2.3,-3.5 -0.6,-4.9 l 6.7,-5.6 c 1.8,-1.5 2.8,-3.7 2.8,-6 v -5.3 c 0,-2 -1.6,-3.6 -3.6,-3.6 h 8 c 2,0 3.6,1.6 3.6,3.6 v 5.3 c 0,2.3 -1,4.5 -2.8,6 l -6.7,5.6 c -1.6,1.4 -1.3,4 0.6,4.9 4.8,2.1 8.1,6.8 8.1,12.3 v 114.1 c 0,7.4 -6,13.4 -13.4,13.4 h -8 c 7.4,0.1 13.4,-6 13.4,-13.4 z" />
|
||||
<path
|
||||
id="XMLID_23_"
|
||||
class="st14"
|
||||
d="M 437,550.3 V 455 c 0,-2.8 -2.2,-5 -5,-5 v 0 c -2.8,0 -5,2.2 -5,5 v 95.3 c 0,2.8 2.2,5 5,5 v 0 c 2.8,0 5,-2.3 5,-5 z" />
|
||||
<path
|
||||
id="XMLID_25_"
|
||||
class="st14"
|
||||
d="M 455.5,550.3 V 455 c 0,-2.8 -2.2,-5 -5,-5 v 0 c -2.8,0 -5,2.2 -5,5 v 95.3 c 0,2.8 2.2,5 5,5 v 0 c 2.8,0 5,-2.3 5,-5 z" />
|
||||
</g><g
|
||||
id="XMLID_108_">
|
||||
<path
|
||||
id="XMLID_9_"
|
||||
class="st10"
|
||||
d="m 583.4,284.7 c -1.6,1.8 -4,4.4 -6,6.6 -2.1,2.3 -5.6,2.7 -8.2,0.8 -5.7,-4.1 -15.1,-11.2 -27,-12.1 -17.2,-1.4 -28.8,4.7 -39.3,12.9 -15.5,12.2 -25.3,26.8 -30.5,35.8 3.9,3.4 5.6,4.4 8.6,6.9 4.9,-5.1 14.5,-15.8 28.1,-19.2 2.1,-0.5 5.8,-1.4 10.2,0 1.5,0.5 4.3,1.6 6.8,4.4 4.3,4.8 4,10.7 4,12 v 38.8 h 25.5 v -37.1 c -0.5,-6.5 2.3,-12.5 7.2,-15 0.4,-0.2 5.2,-2.6 9.9,-0.8 3.4,1.3 7.6,5.2 10.8,8.3 0.3,0.3 1.1,1 2.4,1.5 0.7,0.2 1.5,0.4 2.2,0.3 4.6,-0.1 14,-0.3 20.5,-0.6 3.1,-0.1 5.5,-2.6 5.5,-5.7 v -33.8 c 0,-3.1 -2.5,-5.6 -5.6,-5.7 -6.2,-0.1 -15,-0.3 -20.3,-0.4 -1.8,0 -3.6,0.8 -4.8,2.1 z" />
|
||||
<g
|
||||
id="XMLID_12_">
|
||||
<path
|
||||
id="XMLID_27_"
|
||||
class="st15"
|
||||
d="m 517.2,314.6 c -2.8,-3.2 -6.2,-4.5 -7.8,-5.1 -5,-1.6 -9.4,-0.6 -11.8,0 -7.5,1.8 -13.9,5.6 -19.2,9.7 -2.4,3.5 -4.4,6.7 -6,9.4 3.9,3.4 5.6,4.4 8.6,6.9 4.9,-5.1 14.5,-15.8 28.1,-19.2 1.9,-0.5 5.3,-1.3 9.3,-0.3 -0.3,-0.3 -0.7,-0.8 -1.2,-1.4 z" />
|
||||
<path
|
||||
id="XMLID_44_"
|
||||
class="st15"
|
||||
d="m 612.7,323.2 c -7.5,0.2 -18.4,0.5 -23.7,0.6 -0.9,0 -1.8,-0.1 -2.6,-0.4 -1.5,-0.5 -2.5,-1.4 -2.8,-1.7 -3.7,-3.5 -8.6,-8 -12.5,-9.5 -5.4,-2.1 -11,0.8 -11.4,1 -5.6,3 -8.8,9.8 -8.3,17.4 v 41.2 h 4.3 v -37.1 c -0.5,-6.5 2.3,-12.5 7.2,-15 0.4,-0.2 5.2,-2.6 9.9,-0.8 3.4,1.3 7.6,5.2 10.8,8.3 0.3,0.3 1.1,1 2.4,1.5 0.7,0.2 1.5,0.4 2.2,0.3 4.6,-0.1 14,-0.3 20.5,-0.6 2.9,-0.1 5.3,-2.4 5.5,-5.2 -0.5,-0.1 -1,0 -1.5,0 z" />
|
||||
</g>
|
||||
<path
|
||||
id="XMLID_4_"
|
||||
class="st16"
|
||||
d="m 546,548.7 h -4.8 c -12.6,0 -22.6,-12.4 -22.2,-27.3 l 4.7,-149.7 h 39.8 l 4.7,149.7 c 0.4,14.9 -9.6,27.3 -22.2,27.3 z" />
|
||||
<path
|
||||
id="XMLID_93_"
|
||||
class="st17"
|
||||
d="m 568.2,521.4 -4.7,-149.7 h -9.7 l 4.7,149.7 c 0.4,13.9 -8.3,25.6 -19.7,27.1 0.8,0.1 1.6,0.2 2.4,0.2 h 4.8 c 12.6,0 22.6,-12.4 22.2,-27.3 z" />
|
||||
<path
|
||||
id="XMLID_7_"
|
||||
class="st17"
|
||||
d="m 568.7,383.8 h -50.2 c -3.3,0 -6,-2.7 -6,-6 v 0 c 0,-3.3 2.7,-6 6,-6 h 50.2 c 3.3,0 6,2.7 6,6 v 0 c 0,3.3 -2.7,6 -6,6 z" />
|
||||
</g><polygon
|
||||
id="XMLID_102_"
|
||||
class="st18"
|
||||
points="565.4,435.2 521.1,460 521.8,437.5 564.5,408.1 " /><g
|
||||
id="XMLID_109_">
|
||||
<polygon
|
||||
id="XMLID_70_"
|
||||
class="st10"
|
||||
points="468.4,595.3 762.7,425.5 682.2,322.6 416.5,528.4 " />
|
||||
<polygon
|
||||
id="XMLID_65_"
|
||||
class="st19"
|
||||
points="468.8,595.6 759,427.6 751.1,408.7 455.6,578.7 " />
|
||||
<path
|
||||
id="XMLID_13_"
|
||||
class="st20"
|
||||
d="m 823.4,357.7 -54.7,-70.6 c -8.4,-10.9 -24.1,-12.9 -35,-4.4 l -5.8,4.5 20.4,26.3 v 0 c 1.4,-0.5 2.8,-0.7 4.3,-0.7 h 0.1 c 0.3,0 0.7,0 1,0 0.1,0 0.1,0 0.2,0 0.9,0.1 1.9,0.2 2.8,0.5 0.1,0 0.3,0.1 0.4,0.1 0.2,0.1 0.4,0.1 0.7,0.2 0.2,0.1 0.4,0.1 0.6,0.2 0.2,0.1 0.4,0.2 0.6,0.2 0.2,0.1 0.5,0.2 0.7,0.3 0.1,0.1 0.3,0.1 0.4,0.2 0.4,0.2 0.7,0.4 1,0.7 0.1,0.1 0.2,0.2 0.4,0.3 0.2,0.2 0.5,0.4 0.7,0.5 0.1,0.1 0.2,0.2 0.4,0.3 0.2,0.2 0.4,0.4 0.7,0.6 0.1,0.1 0.2,0.2 0.3,0.3 0.3,0.3 0.6,0.7 0.9,1 l 26,33.6 c 0.3,0.4 0.5,0.7 0.8,1.1 0.1,0.1 0.1,0.2 0.2,0.4 0.2,0.3 0.3,0.5 0.4,0.8 0.1,0.1 0.1,0.3 0.2,0.4 0.1,0.3 0.2,0.5 0.4,0.8 0.1,0.1 0.1,0.3 0.2,0.4 0.1,0.4 0.3,0.8 0.4,1.2 0,0.2 0.1,0.3 0.1,0.5 0.1,0.3 0.1,0.5 0.2,0.8 0,0.2 0.1,0.4 0.1,0.6 0,0.2 0,0.4 0.1,0.6 0,0.2 0,0.5 0.1,0.7 0,0.2 0,0.3 0,0.5 0,1 -0.1,1.9 -0.2,2.8 0,0.1 0,0.1 0,0.2 -0.1,0.3 -0.1,0.6 -0.2,1 v 0.1 c -0.4,1.4 -1,2.7 -1.8,4 v 0 l 21.6,27.9 3.4,-2 c 12.8,-7.5 16.2,-24.9 6.9,-36.9 z" />
|
||||
<g
|
||||
id="XMLID_135_">
|
||||
<path
|
||||
id="XMLID_146_"
|
||||
class="st21"
|
||||
d="m 784.7,344.5 -26,-33.6 c -0.3,-0.4 -0.6,-0.7 -0.9,-1 -0.1,-0.1 -0.2,-0.2 -0.3,-0.3 -0.2,-0.2 -0.4,-0.4 -0.7,-0.6 -0.1,-0.1 -0.2,-0.2 -0.4,-0.3 -0.2,-0.2 -0.5,-0.4 -0.7,-0.5 -0.1,-0.1 -0.2,-0.2 -0.4,-0.3 -0.3,-0.2 -0.7,-0.5 -1,-0.7 -0.1,-0.1 -0.3,-0.1 -0.4,-0.2 -0.2,-0.1 -0.5,-0.2 -0.7,-0.3 -0.2,-0.1 -0.4,-0.2 -0.6,-0.2 -0.2,-0.1 -0.4,-0.1 -0.6,-0.2 -0.2,-0.1 -0.4,-0.2 -0.7,-0.2 -0.1,0 -0.3,-0.1 -0.4,-0.1 -0.9,-0.3 -1.9,-0.4 -2.8,-0.5 -0.1,0 -0.1,0 -0.2,0 -0.3,0 -0.7,0 -1,0 h -0.1 c -1.4,0 -2.9,0.3 -4.3,0.7 v 0 L 728,287.1 v 0 l 20.4,26.3 v 0 c 1.4,-0.5 2.8,-0.7 4.3,-0.7 h 0.1 c 0.3,0 0.7,0 1,0 0.1,0 0.1,0 0.2,0 0.9,0.1 1.9,0.2 2.8,0.5 0.1,0 0.3,0.1 0.4,0.1 0.2,0.1 0.4,0.1 0.7,0.2 0.2,0.1 0.4,0.1 0.6,0.2 0.2,0.1 0.4,0.2 0.6,0.2 0.2,0.1 0.5,0.2 0.7,0.3 0.1,0.1 0.3,0.1 0.4,0.2 0.4,0.2 0.7,0.4 1,0.7 0.1,0.1 0.2,0.2 0.4,0.3 0.2,0.2 0.5,0.4 0.7,0.5 0.1,0.1 0.2,0.2 0.4,0.3 0.2,0.2 0.4,0.4 0.7,0.6 0.1,0.1 0.2,0.2 0.3,0.3 0.3,0.3 0.6,0.7 0.9,1 l 20.7,26.7 c -0.3,0.1 -0.5,-0.1 -0.6,-0.3 z" />
|
||||
<path
|
||||
id="XMLID_149_"
|
||||
class="st21"
|
||||
d="m 823.4,357.7 -4.9,-6.3 c 8.2,11.9 4.6,28.4 -8.1,35.8 l -3.4,2 -15.8,-20.5 v 0 0 l 21.6,27.9 3.4,-2 c 13.1,-7.5 16.5,-24.9 7.2,-36.9 z" />
|
||||
</g>
|
||||
<g
|
||||
id="XMLID_45_">
|
||||
<path
|
||||
id="XMLID_66_"
|
||||
class="st22"
|
||||
d="m 762.2,316.1 v 0 z" />
|
||||
<path
|
||||
id="XMLID_67_"
|
||||
class="st22"
|
||||
d="m 761.1,315.3 c -0.3,-0.2 -0.7,-0.5 -1,-0.7 v 0 c 0.3,0.2 0.7,0.5 1,0.7 z" />
|
||||
<path
|
||||
id="XMLID_68_"
|
||||
class="st22"
|
||||
d="m 762.5,316.4 v 0 c 0.2,0.2 0.4,0.4 0.6,0.6 -0.1,-0.2 -0.3,-0.4 -0.6,-0.6 z" />
|
||||
<path
|
||||
id="XMLID_72_"
|
||||
class="st22"
|
||||
d="m 759.7,314.4 c -0.2,-0.1 -0.5,-0.2 -0.7,-0.3 v 0 c 0.2,0.1 0.4,0.2 0.7,0.3 z" />
|
||||
<path
|
||||
id="XMLID_76_"
|
||||
class="st22"
|
||||
d="m 755.3,312.9 c 0,0 0,0.1 0,0 0,0.1 0,0.1 0,0 z" />
|
||||
<path
|
||||
id="XMLID_77_"
|
||||
class="st22"
|
||||
d="m 752.8,312.7 c -0.1,0 -0.1,0 0,0 -0.1,0 -0.1,0 0,0 z" />
|
||||
<path
|
||||
id="XMLID_79_"
|
||||
class="st22"
|
||||
d="m 793.3,358.9 c 0,0.2 0,0.4 0.1,0.6 v 0 c 0,-0.2 -0.1,-0.4 -0.1,-0.6 z" />
|
||||
<path
|
||||
id="XMLID_81_"
|
||||
class="st22"
|
||||
d="m 758.4,313.8 c -0.1,0 -0.2,-0.1 -0.3,-0.1 0.1,0.1 0.2,0.1 0.3,0.1 z" />
|
||||
<path
|
||||
id="XMLID_83_"
|
||||
class="st22"
|
||||
d="m 753.9,312.8 c -0.1,0 -0.1,0 -0.2,0 v 0 c 0.1,0 0.2,0 0.2,0 z" />
|
||||
<path
|
||||
id="XMLID_84_"
|
||||
class="st22"
|
||||
d="m 793.2,358 c 0,0.1 0,0.2 0.1,0.3 v 0 c -0.1,-0.1 -0.1,-0.2 -0.1,-0.3 z" />
|
||||
<path
|
||||
id="XMLID_85_"
|
||||
class="st22"
|
||||
d="m 792.6,355.9 c 0.1,0.2 0.2,0.5 0.2,0.8 -0.1,-0.3 -0.1,-0.6 -0.2,-0.8 z" />
|
||||
<path
|
||||
id="XMLID_86_"
|
||||
class="st22"
|
||||
d="m 793.4,360.2 v 0 c 0,0.2 0,0.3 0,0.5 v 0 c 0,-0.2 0,-0.3 0,-0.5 z" />
|
||||
<path
|
||||
id="XMLID_87_"
|
||||
class="st22"
|
||||
d="m 792.1,354.7 c 0.1,0.1 0.1,0.3 0.2,0.4 -0.1,-0.2 -0.2,-0.3 -0.2,-0.4 z" />
|
||||
<path
|
||||
id="XMLID_88_"
|
||||
class="st22"
|
||||
d="m 791.1,368.7 v 0 c -0.9,1.4 -2,2.6 -3.3,3.6 l -8.5,6.6 c -12.8,9.9 -31.1,7.6 -41,-5.2 l -8,-10.4 c -9.9,-12.8 -7.6,-31.1 5.2,-41 l 8.5,-6.6 c 1.3,-1 2.8,-1.8 4.4,-2.3 v 0 0 l -20.4,-26.3 -53.3,41.3 79.2,102.2 62.3,-36 -3.4,2 z" />
|
||||
<path
|
||||
id="XMLID_89_"
|
||||
class="st22"
|
||||
d="m 791.4,353.4 c 0.1,0.1 0.2,0.3 0.2,0.4 0,-0.1 -0.1,-0.2 -0.2,-0.4 z" />
|
||||
<path
|
||||
id="XMLID_90_"
|
||||
class="st22"
|
||||
d="m 790.4,351.9 c 0.3,0.4 0.5,0.7 0.8,1.1 v 0 c -0.3,-0.3 -0.5,-0.7 -0.8,-1.1 z" />
|
||||
<path
|
||||
id="XMLID_91_"
|
||||
class="st22"
|
||||
d="M 793.2,363.5 Z" />
|
||||
<path
|
||||
id="XMLID_92_"
|
||||
class="st22"
|
||||
d="M 793.4,362.1 Z" />
|
||||
</g>
|
||||
<path
|
||||
id="XMLID_19_"
|
||||
class="st20"
|
||||
d="M 725.2,284.1" />
|
||||
</g><polygon
|
||||
id="XMLID_195_"
|
||||
class="st23"
|
||||
points="748.4,423.7 807.1,389.4 812.7,396.6 753.8,430.6 " /><path
|
||||
id="XMLID_14_"
|
||||
class="st24"
|
||||
d="M 738.3,648.9 H 388.1 v -176 h 364.1 v 162 c 0.1,7.7 -6.2,14 -13.9,14 z" /><g
|
||||
id="g496">
|
||||
<g
|
||||
id="g494">
|
||||
<defs
|
||||
id="defs461">
|
||||
<path
|
||||
id="SVGID_1_"
|
||||
d="M 738.3,648.9 H 388.1 v -176 h 364.1 v 162 c 0.1,7.7 -6.2,14 -13.9,14 z" />
|
||||
</defs>
|
||||
<clipPath
|
||||
id="SVGID_2_">
|
||||
<use
|
||||
xlink:href="#SVGID_1_"
|
||||
style="overflow:visible"
|
||||
id="use463" />
|
||||
</clipPath>
|
||||
<g
|
||||
id="XMLID_166_"
|
||||
class="st25"
|
||||
clip-path="url(#SVGID_2_)">
|
||||
<path
|
||||
id="XMLID_147_"
|
||||
class="st8"
|
||||
d="m 414.1,598.1 c 9,-15.2 40.8,1.6 26.3,18.3 -11.3,13.1 -34.9,-3.7 -26.3,-18.3 4.6,-7.8 -3.5,5.9 0,0 z" />
|
||||
<path
|
||||
id="XMLID_148_"
|
||||
class="st26"
|
||||
d="m 451,460.3 c 0.1,24.3 8.2,60.9 23.3,73.3 30,24.5 124.7,-16 126,-55.3 C 601.9,430.2 463.8,382 445,405 c -5.8,7.1 -5.3,27.6 -1.7,28.7 1.2,0.4 2.3,-2.2 3.7,-1.9 4.2,0.6 4,23.4 4,28.5 z" />
|
||||
<path
|
||||
id="XMLID_150_"
|
||||
class="st8"
|
||||
d="m 388.4,481.2 c -23.9,66.5 71.3,53.5 89.1,97.4 18.8,46.3 -42.4,82.5 -78.3,93.1" />
|
||||
<path
|
||||
id="XMLID_154_"
|
||||
class="st8"
|
||||
d="m 391.8,578.7 c 40.4,-27.6 81.9,-3.4 57.3,42.1 -14.4,26.6 -43.5,40.2 -72.9,41.9" />
|
||||
<path
|
||||
id="XMLID_176_"
|
||||
class="st8"
|
||||
d="M 689.8,566.6" />
|
||||
<path
|
||||
id="XMLID_172_"
|
||||
class="st8"
|
||||
d="M 677.1,771" />
|
||||
<path
|
||||
id="XMLID_155_"
|
||||
class="st8"
|
||||
d="m 728.3,533.6 c 3.9,2.4 10.6,5.6 19,5.6 3.8,0 8.6,-0.1 12.7,-3.4 1.1,-0.9 4.4,-3.6 4.5,-7.1 0.2,-6.3 -9.5,-12.4 -18.3,-13 -7.1,-0.4 -17.1,2.6 -19,9.8 -0.8,3.2 0.2,6.2 1.1,8.1 z" />
|
||||
<path
|
||||
id="XMLID_161_"
|
||||
class="st8"
|
||||
d="m 715.2,556.9 c 9.1,5 18.1,3.6 36,0.7 12.1,-1.9 18.7,-3.1 25.4,-8.5 1.9,-1.5 11.9,-9.6 12.7,-21.9 1.1,-15.9 -13.9,-28.9 -26.1,-33.9 -21.2,-8.6 -40.9,4.1 -44.4,6.3 -6.1,4 -21.1,13.6 -21.2,29.6 -0.1,12.7 9,23 17.6,27.7 z" />
|
||||
<path
|
||||
id="XMLID_162_"
|
||||
class="st8"
|
||||
d="m 786.1,576.7 c -5.3,4.1 -22.7,16.9 -46.7,14 -19.2,-2.3 -31.3,-13.4 -34.7,-16.7 -15,-14.7 -17.4,-32.4 -18,-38 -0.6,-5.6 -2,-19 5.2,-33.5 6.1,-12.2 15,-18.7 20.5,-22.6 22.6,-16.2 47,-13.9 51.5,-13.4 8.4,0.9 21.1,2.3 32.5,12 20,17 17.4,45.4 16.9,50.1 -2.6,28.7 -23.2,45.1 -27.2,48.1 z" />
|
||||
<path
|
||||
id="XMLID_164_"
|
||||
class="st8"
|
||||
d="m 795.7,590 c -3.3,3.2 -8.5,7.5 -15.7,11 -27.6,13.2 -58.5,0.4 -75.7,-6.7 C 686.6,587 674.4,578.5 657.6,567 628.5,546.9 625,538.5 606.3,533.7 c -6,-1.6 -25.2,-6.2 -44.5,2.5 -5.8,2.6 -10.2,6 -14.8,9.8 -22.3,18.2 -28.5,38.8 -48,82 -4.5,10 -11.2,24.5 -20,42" />
|
||||
<path
|
||||
id="XMLID_165_"
|
||||
class="st8"
|
||||
d="m 523,724 c 15.8,5.1 47.9,-66.9 69.5,-59.2 12.1,4.3 10.9,30.3 20.5,31.2 10.6,0.9 21,-30 22,-33 2.5,-7.7 19.4,-58.9 -9,-92 -3.9,-4.5 -12.7,-14.5 -27.2,-17.7 0,0 -15.5,-3.4 -33.8,5.7 -48.3,23.7 -62.5,158.3 -42,165 z" />
|
||||
<path
|
||||
id="XMLID_173_"
|
||||
class="st8"
|
||||
d="m 565,637 c 5.7,4.2 20.2,-5.7 28,-15 3.9,-4.6 12.1,-14.3 8.4,-21.8 -2.9,-5.8 -11.8,-7.9 -18.4,-6.2 -7.5,1.9 -11,8.5 -14,14 -4.8,8.9 -9.6,24.9 -4,29 z" />
|
||||
<path
|
||||
id="XMLID_174_"
|
||||
class="st8"
|
||||
d="m 604,438.3 c 9.8,10.1 20.7,24.5 17.6,38.8 -2.3,10.6 -11,15.1 -9.3,17.9 3.3,5.5 39.9,-2.4 64,-28 12.8,-13.6 18.7,-28.7 21.5,-38.6" />
|
||||
<path
|
||||
id="XMLID_177_"
|
||||
class="st8"
|
||||
d="m 507,497 c 7.6,8.2 22.6,15.4 34,10 11,-5.2 13.4,-19.6 14,-23.3 5,-29.8 -21.4,-65.8 -37,-62.9 -17,3.1 -31.4,54 -11,76.2 z" />
|
||||
<path
|
||||
id="XMLID_178_"
|
||||
class="st8"
|
||||
d="m 522.2,435.7 c 5.6,0.1 10.4,17.3 10.8,31.3 0.2,7 -0.5,23.8 -5.3,24.7 -6,1 -19.7,-22.8 -14,-43.1 0.1,-0.5 3.7,-13 8.5,-12.9 z" />
|
||||
<path
|
||||
id="XMLID_179_"
|
||||
class="st8"
|
||||
d="m 798.2,632.9 c -39,24.1 -57.4,23.2 -67,16.9 -6.9,-4.5 -9.9,-12.3 -28.2,-26.8 -7.7,-6.1 -13.8,-10 -21.2,-9.2 -7.2,0.8 -12.1,5.6 -13.4,7.1 -4.7,5 -5.7,10.8 -7.1,19.8 -1.6,10.4 -0.1,13.3 -2.1,23.3 -1.1,5.2 -2.5,9.3 -3.5,12" />
|
||||
<path
|
||||
id="XMLID_52_"
|
||||
class="st26"
|
||||
d="M 474.3,420.2" />
|
||||
<path
|
||||
id="XMLID_53_"
|
||||
class="st8"
|
||||
d="m 680.8,657.4 c -7.3,-10.6 -8.2,-20.9 -4,-24.7 5,-4.4 16.1,1.4 17.3,2 12.4,6.6 15.9,20.4 16.7,24" />
|
||||
<path
|
||||
id="XMLID_56_"
|
||||
class="st8"
|
||||
d="m 501.5,554.2 c -7.2,1.2 -14.8,8.2 -13.3,16 1,5.2 5.9,10.1 10.7,10 9.1,-0.1 16.7,-18.2 12,-24 -2.6,-3.2 -8.2,-2.2 -9.4,-2 z" />
|
||||
<path
|
||||
id="XMLID_137_"
|
||||
class="st8"
|
||||
d="m 548,613 c 3.9,-12.7 9.7,-31.5 27,-39 2.4,-1 14.7,-6.4 27.8,-0.6 13.6,6 17.8,19.6 18.7,22.7 5.7,20.3 -9.8,36.4 -10.7,37.3 -3.1,3.2 -5.8,4.6 -19.3,10.7 -26.3,11.7 -39.5,17.5 -43.5,15 -11.8,-7.5 -1.3,-41.7 0,-46.1 z" />
|
||||
<path
|
||||
id="XMLID_144_"
|
||||
class="st8"
|
||||
d="M 740.8,706.2" />
|
||||
<path
|
||||
id="XMLID_145_"
|
||||
class="st27"
|
||||
d="m 403,487.7 c 0.5,4.3 2.9,22.9 17.4,30.7 4.3,2.3 13.5,5.8 17.4,2 5.1,-4.9 -3.8,-16.8 -9.3,-40.1 -6.6,-27.4 -1.7,-41.7 -6,-42.7 -6.1,-1.5 -22.8,24.1 -19.5,50.1 z" />
|
||||
<path
|
||||
id="XMLID_6_"
|
||||
class="st8"
|
||||
d="m 761,662 c -20.6,-11.1 -30.3,-27.1 -27,-34 3.3,-6.8 21.7,-9.1 43,0" />
|
||||
<path
|
||||
id="XMLID_8_"
|
||||
class="st8"
|
||||
d="m 673,599 c -0.1,-4 -9,-9.5 -14,-6 -4.1,2.9 -4.1,10.5 -1,13 4.2,3.4 15.1,-2.5 15,-7 z" />
|
||||
<path
|
||||
id="XMLID_18_"
|
||||
class="st28"
|
||||
d="m 628,508 c -5,2.6 -7.4,3.9 -8,6 -2.1,7.3 12.8,18.5 26,23.5 8,3.1 17.4,6.5 24.3,1.6 8.5,-6 8.2,-21.5 2.7,-30.1 -0.9,-1.4 -3.2,-4.9 -7.6,-6.9 -2.1,-1 -4.8,-2.2 -11.4,-1.1 -12.7,2.2 -18.6,3.1 -26,7 z" />
|
||||
<path
|
||||
id="XMLID_21_"
|
||||
class="st8"
|
||||
d="m 650.4,514.8 c -6,0.6 -11.1,5.4 -9,9 1.7,2.9 7.6,4.5 12.3,3.4 5.3,-1.2 8.8,-5.7 6.6,-9 -1.7,-2.6 -6.2,-3.8 -9.9,-3.4 z" />
|
||||
<path
|
||||
id="XMLID_29_"
|
||||
class="st8"
|
||||
d="m 489.7,443.7 c -2,3.5 -17.3,31.8 -4,59.3 2.2,4.5 9,18.7 24,24 22,7.8 46.3,-9 58,-24.7 12.9,-17.3 13,-36.5 12.7,-44" />
|
||||
</g>
|
||||
</g>
|
||||
</g><circle
|
||||
id="XMLID_49_"
|
||||
class="st5"
|
||||
cx="389.5"
|
||||
cy="513.29999"
|
||||
r="9" /><path
|
||||
id="XMLID_3_"
|
||||
class="st3"
|
||||
d="M 361.3,412.9 V 361 c 0,-19.9 -16.1,-36 -36,-36 v 0 c -19.9,0 -36,16.1 -36,36 v 51.8 c 0,33.1 -26.9,60 -60,60 v 0 152 c 0,13.3 10.7,24 24,24 h 144 c 13.3,0 24,-10.7 24,-24 v -152 0 c -33.2,0.1 -60,-26.8 -60,-59.9 z" /><polygon
|
||||
id="XMLID_17_"
|
||||
class="st3"
|
||||
points="413.9,634.9 755.4,634.9 742.4,648.9 398.9,648.9 " /><path
|
||||
id="XMLID_42_"
|
||||
class="st3"
|
||||
d="m 752.3,472.9 v 135.4 0 16.5 c 0,5.5 -4.5,10 -10,10 h -16.5 v 0 h -27.9 v 14 h 44.5 c 13.3,0 24,-10.7 24,-24 v -152 h -14.1 z" /><path
|
||||
id="XMLID_5_"
|
||||
class="st29"
|
||||
d="M 347.3,412.9 V 361 c 0,-19.9 -16.1,-36 -36,-36 v 0 c -19.9,0 -36,16.1 -36,36 v 51.8 c 0,33.1 -26.9,60 -60,60 v 0 152 c 0,13.3 10.7,24 24,24 h 144 c 13.3,0 24,-10.7 24,-24 v -152 0 c -33.2,0.1 -60,-26.8 -60,-59.9 z" /><path
|
||||
id="XMLID_69_"
|
||||
class="st30"
|
||||
d="m 217.4,634.9 c 3.8,8.3 12.1,14 21.8,14 h 144 c 9.7,0 18,-5.7 21.8,-14 z" /><g
|
||||
id="g528">
|
||||
<g
|
||||
id="g526">
|
||||
<defs
|
||||
id="defs505">
|
||||
<path
|
||||
id="SVGID_3_"
|
||||
d="M 347.3,412.9 V 361 c 0,-19.9 -16.1,-36 -36,-36 v 0 c -19.9,0 -36,16.1 -36,36 v 51.8 c 0,33.1 -26.9,60 -60,60 v 0 152 c 0,13.3 10.7,24 24,24 h 144 c 13.3,0 24,-10.7 24,-24 v -152 0 c -33.2,0.1 -60,-26.8 -60,-59.9 z" />
|
||||
</defs>
|
||||
<clipPath
|
||||
id="SVGID_4_">
|
||||
<use
|
||||
xlink:href="#SVGID_3_"
|
||||
style="overflow:visible"
|
||||
id="use507" />
|
||||
</clipPath>
|
||||
<g
|
||||
id="XMLID_117_"
|
||||
class="st31"
|
||||
clip-path="url(#SVGID_4_)">
|
||||
<path
|
||||
id="XMLID_104_"
|
||||
class="st32"
|
||||
d="m 252.1,678.7 c -18.3,-25.6 -6.3,-74 23.3,-89.3 13.8,-7.1 27.2,-4.9 31.1,-4.3 37.2,6.6 69.2,55.1 56.7,84 -14.2,32.9 -87.1,43 -111.1,9.6 z" />
|
||||
<path
|
||||
id="XMLID_106_"
|
||||
class="st32"
|
||||
d="m 216.1,650.7 c -8,-22.4 10.7,-56.1 35.7,-72 46.7,-29.8 98.2,13.2 134,-16 29.9,-24.5 18.6,-74.8 25,-75 9.7,-0.3 58.1,115.1 0,176 -56.7,59.4 -179.4,29.9 -194.7,-13 z" />
|
||||
<path
|
||||
id="XMLID_110_"
|
||||
class="st32"
|
||||
d="m 277.8,656.7 c -9.2,-10 -14,-27.4 -5.7,-40 6.2,-9.4 17.2,-11.4 20.7,-12 15.9,-3 41.4,3.1 46,22.7 3.9,16.5 -9,34.7 -26,39.3 -12.4,3.4 -26.3,-0.5 -35,-10 z" />
|
||||
<path
|
||||
id="XMLID_111_"
|
||||
class="st33"
|
||||
d="m 379.5,625.7 c 5.3,-2.4 5.9,-19.5 -2.5,-25.8 -2.9,-2.2 -8.3,-4.1 -11.9,-1.6 -3.9,2.6 -3.4,8.8 -3.3,9.8 0.9,10.3 12.7,19.9 17.7,17.6 z" />
|
||||
<path
|
||||
id="XMLID_112_"
|
||||
class="st32"
|
||||
d="m 212.1,527.4 c 7.3,-2.6 17.6,2.2 21.3,9.3 8.9,17.1 -20.1,48.8 -29.3,45.3 -9.4,-3.5 -8.7,-48.6 8,-54.6 z" />
|
||||
<path
|
||||
id="XMLID_113_"
|
||||
class="st32"
|
||||
d="m 207.8,499.7 c 7,6.1 18,14.2 33,20.3 19.2,7.8 36.3,8.9 46.7,8.7 15.6,-3.9 24.2,-10.7 29.3,-17 26.1,-31.9 5.6,-103.7 -54,-177" />
|
||||
<path
|
||||
id="XMLID_115_"
|
||||
class="st32"
|
||||
d="m 219.4,470 c 8,19.7 33.8,35.9 52.4,28.7 13,-5 18,-19.6 19.6,-24.2 11,-32 -8.1,-76.9 -29.6,-78.8 -22.4,-1.9 -55.5,42.2 -42.4,74.3 z" />
|
||||
<path
|
||||
id="XMLID_116_"
|
||||
class="st32"
|
||||
d="m 403.8,377.7 c -5.9,-20 -23.1,-47.3 -38.9,-44.9 -18.2,2.8 -23.5,43.8 -25.1,48.9 -11.6,37.3 7.3,84.7 26,87 13.9,1.7 27,-22.6 31,-30 3.2,-5.9 15.9,-31.1 7,-61 z" />
|
||||
<path
|
||||
id="XMLID_132_"
|
||||
class="st32"
|
||||
d="m 352.9,490.3 c -3,1.6 -7.1,4.5 -25.1,40.4 -8.7,17.3 -9.6,20.3 -8,23 4.8,8.2 29.6,6.4 46.6,-4.9 20.3,-13.6 33.8,-43.1 24,-55.5 -7.1,-9.1 -25.6,-9.4 -37.5,-3 z" />
|
||||
<path
|
||||
id="XMLID_136_"
|
||||
class="st32"
|
||||
d="m 363.4,505.3 c -11.1,7.8 -21.3,22.8 -17.6,27.4 4.2,5.2 27.7,-1.1 32.5,-15.2 1.9,-5.7 1.2,-14.1 -3,-16.2 -3.6,-1.7 -8.5,1.6 -11.9,4 z" />
|
||||
<path
|
||||
id="XMLID_139_"
|
||||
class="st32"
|
||||
d="m 296.8,628.7 c -10.9,5.8 -7.5,36.6 1.3,39.3 7.7,2.4 26.9,-14.8 21.3,-28.7 -3.4,-8.7 -16,-14.1 -22.6,-10.6 z" />
|
||||
<path
|
||||
id="XMLID_140_"
|
||||
class="st32"
|
||||
d="m 244.8,548.7 c -0.5,3.3 8.9,7.9 16.7,8.7 2.7,0.3 7.4,0.7 9.3,-2 1.6,-2.2 0.9,-5.8 -0.7,-8 -5.2,-7.2 -24.6,-3.2 -25.3,1.3 z" />
|
||||
<path
|
||||
id="XMLID_141_"
|
||||
class="st32"
|
||||
d="m 232.1,454.7 c 1,16 18.6,31.5 27.7,28 8.2,-3.2 12.3,-22.9 3.7,-34.7 -6,-8.2 -18.9,-13.6 -26,-8.7 -6.1,4.2 -5.5,14 -5.4,15.4 z" />
|
||||
<path
|
||||
id="XMLID_114_"
|
||||
class="st32"
|
||||
d="m 305.8,349.7 c -3.7,2.5 -3.3,13.9 4,20 8.2,6.8 20.6,3.3 22,-1 2.2,-6.7 -20,-23.1 -26,-19 z" />
|
||||
<path
|
||||
id="XMLID_50_"
|
||||
class="st33"
|
||||
d="m 452.8,570.7 c -14.2,-5.1 -40.3,-8.1 -53,8 -14.3,18.1 -0.2,45.8 2,50 1.6,3 14.8,26.9 43,31 16.7,2.4 41.4,-1.8 50,-18 12,-22.7 -12.1,-60.2 -42,-71 z" />
|
||||
</g>
|
||||
</g>
|
||||
</g><circle
|
||||
id="XMLID_57_"
|
||||
class="st3"
|
||||
cx="389.5"
|
||||
cy="560.90002"
|
||||
r="9" /><circle
|
||||
id="XMLID_105_"
|
||||
class="st3"
|
||||
cx="389.5"
|
||||
cy="513.29999"
|
||||
r="9" /><circle
|
||||
id="XMLID_60_"
|
||||
class="st3"
|
||||
cx="389.5"
|
||||
cy="608.40002"
|
||||
r="9" /><circle
|
||||
id="XMLID_64_"
|
||||
class="st3"
|
||||
cx="232.10001"
|
||||
cy="513.29999"
|
||||
r="9" /><circle
|
||||
id="XMLID_63_"
|
||||
class="st3"
|
||||
cx="232.10001"
|
||||
cy="560.90002"
|
||||
r="9" /><circle
|
||||
id="XMLID_61_"
|
||||
class="st3"
|
||||
cx="232.10001"
|
||||
cy="608.40002"
|
||||
r="9" /><circle
|
||||
id="XMLID_38_"
|
||||
class="st3"
|
||||
cx="311.29999"
|
||||
cy="361.70001"
|
||||
r="12" /></g>
|
||||
</svg>
|
After Width: | Height: | Size: 25 KiB |
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
164
lumi2/static/js/group_create.js
Normal file
|
@ -0,0 +1,164 @@
|
|||
class UserEntry {
|
||||
constructor(username, isMember, rowElement) {
|
||||
this.username = username;
|
||||
this.isMember = isMember;
|
||||
this.rowElement = rowElement;
|
||||
this.buttonElement = $(rowElement).find(".toggleMembershipButton");
|
||||
if (isMember) {
|
||||
$(this.buttonElement).click(() => this.onClickLeave());
|
||||
} else {
|
||||
$(this.buttonElement).click(() => this.onClickJoin());
|
||||
}
|
||||
}
|
||||
|
||||
onClickLeave() {
|
||||
this.isMember = false;
|
||||
$(this.buttonElement).off("click");
|
||||
$(this.buttonElement).click(() => this.onClickJoin());
|
||||
$(this.rowElement).prependTo($("#tableNonMembers").find("tbody"));
|
||||
this.setButtonAppearanceJoinGroup();
|
||||
if (membersTableIsEmpty()) {
|
||||
showEmptyTableNotice();
|
||||
}
|
||||
disableCreateGroupButton();
|
||||
}
|
||||
|
||||
onClickJoin() {
|
||||
this.isMember = true;
|
||||
$(this.buttonElement).off("click");
|
||||
$(this.buttonElement).click(() => this.onClickLeave());
|
||||
$(this.rowElement).prependTo($("#tableMembers").find("tbody"));
|
||||
this.setButtonAppearanceLeaveGroup();
|
||||
hideEmptyTableNotice();
|
||||
enableCreateGroupButton();
|
||||
}
|
||||
|
||||
setButtonAppearanceLeaveGroup() {
|
||||
this.buttonElement.removeClass("btn-danger btn-success btn-secondary");
|
||||
this.buttonElement.addClass("btn-danger");
|
||||
this.buttonElement.empty();
|
||||
this.buttonElement.html(
|
||||
'<i class="bi-person-fill-x"></i> Cancel'
|
||||
);
|
||||
}
|
||||
|
||||
setButtonAppearanceJoinGroup() {
|
||||
this.buttonElement.removeClass("btn-danger btn-success btn-secondary");
|
||||
this.buttonElement.addClass("btn-success");
|
||||
this.buttonElement.empty();
|
||||
this.buttonElement.html(
|
||||
'<i class="bi-person-fill-add"></i> Add'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function showErrorMessage(message) {
|
||||
$("nav").after([
|
||||
'<div class="alert alert-danger alert-dismissible" role="alert">',
|
||||
`<div>${message}</div>`,
|
||||
'<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>',
|
||||
'</div>'
|
||||
].join(''));
|
||||
}
|
||||
|
||||
function getGroupMembers() {
|
||||
let groupMembers = [];
|
||||
for (entry of $(membersTable).find(".userEntry")) {
|
||||
groupMembers.push(entry.id);
|
||||
}
|
||||
return groupMembers;
|
||||
}
|
||||
|
||||
function getGroupName() {
|
||||
return $(groupNameInput).val();
|
||||
}
|
||||
|
||||
function getUserEntries() {
|
||||
let userEntries = [];
|
||||
for (let entry of nonMembersTable.find("tbody").find(".userEntry")) {
|
||||
userEntries.push(new UserEntry(
|
||||
entry.id,
|
||||
false,
|
||||
entry
|
||||
));
|
||||
}
|
||||
|
||||
return userEntries;
|
||||
}
|
||||
|
||||
function membersTableIsEmpty() {
|
||||
return $(membersTable).find("tbody").find(".userEntry").length === 0
|
||||
}
|
||||
|
||||
function hideEmptyTableNotice() {
|
||||
if (emptyTableNotice.parent().length != 0) {
|
||||
emptyTableNotice.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function showEmptyTableNotice() {
|
||||
if (emptyTableNotice.parent().length == 0) {
|
||||
$(membersTable).find("tbody").append($(emptyTableNotice));
|
||||
}
|
||||
}
|
||||
|
||||
function disableCreateGroupButton() {
|
||||
if ($(groupNameInput).val().length == 0 || membersTableIsEmpty()) {
|
||||
createGroupButton[0].disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
function enableCreateGroupButton() {
|
||||
if ($(groupNameInput).val().length > 0 && !membersTableIsEmpty()) {
|
||||
createGroupButton[0].disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function createGroupButtonOnClick() {
|
||||
$.ajax({
|
||||
url: `/api/group/${getGroupName()}`,
|
||||
type: "POST",
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify({
|
||||
"members": getGroupMembers()
|
||||
}),
|
||||
dataType: "json",
|
||||
}).done(function() {
|
||||
window.location.replace(window.location.origin + `/groups/update/${getGroupName()}`);
|
||||
}).fail(function(xhr, status, errorThrown) {
|
||||
console.log(`Error: ${errorThrown}`);
|
||||
console.log(`Status: ${status}`);
|
||||
displayTextInputError(xhr.responseJSON['message']);
|
||||
});
|
||||
}
|
||||
|
||||
function displayTextInputError(message) {
|
||||
$(createGroupButton).after([
|
||||
'<div class="alert alert-danger alert-dismissible m-2" role="alert">',
|
||||
`<div>${message}</div>`,
|
||||
'<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>',
|
||||
'</div>'
|
||||
].join(''));
|
||||
}
|
||||
|
||||
let nonMembersTable = undefined;
|
||||
let membersTable = undefined;
|
||||
let entries = undefined;
|
||||
let emptyTableNotice = undefined;
|
||||
let createGroupButton = undefined;
|
||||
let groupNameInput = undefined;
|
||||
|
||||
$(document).ready(function() {
|
||||
nonMembersTable = $("#tableNonMembers");
|
||||
membersTable = $("#tableMembers");
|
||||
entries = getUserEntries();
|
||||
emptyTableNotice = $("#emptyTableNotice");
|
||||
createGroupButton = $("#createGroupButton");
|
||||
groupNameInput = $("#groupNameInput");
|
||||
disableCreateGroupButton();
|
||||
$(groupNameInput).on("input", function() {
|
||||
enableCreateGroupButton();
|
||||
disableCreateGroupButton();
|
||||
});
|
||||
createGroupButton.click(createGroupButtonOnClick);
|
||||
});
|
134
lumi2/static/js/group_edit.js
Normal file
|
@ -0,0 +1,134 @@
|
|||
class UserEntry {
|
||||
constructor(username, isMember, rowElement) {
|
||||
this.username = username;
|
||||
this.isMember = isMember;
|
||||
this.rowElement = rowElement;
|
||||
this.buttonElement = $(rowElement).find(".toggleMembershipButton");
|
||||
if (isMember) {
|
||||
$(this.buttonElement).click(() => this.onClickLeave());
|
||||
} else {
|
||||
$(this.buttonElement).click(() => this.onClickJoin());
|
||||
}
|
||||
}
|
||||
|
||||
onClickLeave() {
|
||||
this.setButtonAppearanceInProgress();
|
||||
$.ajax({
|
||||
context: {
|
||||
"userEntry": this
|
||||
},
|
||||
url: `/api/group/${groupname}/member/${this.username}`,
|
||||
type: "DELETE",
|
||||
dataType: "json",
|
||||
}).done(function() {
|
||||
this.userEntry.isMember = false;
|
||||
$(this.userEntry.buttonElement).off("click");
|
||||
$(this.userEntry.buttonElement).click(() => this.userEntry.onClickJoin());
|
||||
$(this.userEntry.rowElement).prependTo($("#tableNonMembers").find("tbody"));
|
||||
this.userEntry.setButtonAppearanceJoinGroup();
|
||||
}).fail(function(xhr, status, errorThrown) {
|
||||
console.log(`Error: ${errorThrown}`);
|
||||
console.log(`Status: ${status}`);
|
||||
showErrorMessage(xhr.responseJSON['message']);
|
||||
this.userEntry.setButtonAppearanceLeaveGroup();
|
||||
});
|
||||
}
|
||||
|
||||
onClickJoin() {
|
||||
this.setButtonAppearanceInProgress();
|
||||
$.ajax({
|
||||
context: {
|
||||
"userEntry": this
|
||||
},
|
||||
url: `/api/group/${groupname}/member/${this.username}`,
|
||||
type: "POST",
|
||||
dataType: "json",
|
||||
}).done(function() {
|
||||
this.userEntry.isMember = true;
|
||||
$(this.userEntry.buttonElement).off("click");
|
||||
$(this.userEntry.buttonElement).click(() => this.userEntry.onClickLeave());
|
||||
$(this.userEntry.rowElement).prependTo($("#tableMembers").find("tbody"));
|
||||
this.userEntry.setButtonAppearanceLeaveGroup();
|
||||
}).fail(function(xhr, status, errorThrown) {
|
||||
console.log(`Error: ${errorThrown}`);
|
||||
console.log(`Status: ${status}`);
|
||||
showErrorMessage(xhr.responseJSON['message']);
|
||||
this.userEntry.setButtonAppearanceJoinGroup();
|
||||
});
|
||||
}
|
||||
|
||||
setButtonAppearanceInProgress() {
|
||||
this.buttonElement.removeClass("btn-danger btn-success btn-secondary");
|
||||
this.buttonElement.addClass("btn-secondary");
|
||||
this.buttonElement.empty();
|
||||
this.buttonElement.html(
|
||||
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>' +
|
||||
'<span> Loading...</span>'
|
||||
);
|
||||
this.buttonElement.prop("disabled", true);
|
||||
}
|
||||
|
||||
setButtonAppearanceLeaveGroup() {
|
||||
this.buttonElement.removeClass("btn-danger btn-success btn-secondary");
|
||||
this.buttonElement.addClass("btn-danger");
|
||||
this.buttonElement.empty();
|
||||
this.buttonElement.html(
|
||||
'<i class="bi-person-fill-dash"></i> Remove'
|
||||
);
|
||||
this.buttonElement.prop("disabled", false);
|
||||
}
|
||||
|
||||
setButtonAppearanceJoinGroup() {
|
||||
this.buttonElement.removeClass("btn-danger btn-success btn-secondary");
|
||||
this.buttonElement.addClass("btn-success");
|
||||
this.buttonElement.empty();
|
||||
this.buttonElement.html(
|
||||
'<i class="bi-person-fill-add"></i> Add'
|
||||
);
|
||||
this.buttonElement.prop("disabled", false);
|
||||
}
|
||||
}
|
||||
|
||||
function showErrorMessage(message) {
|
||||
$("nav").after([
|
||||
'<div class="alert alert-danger alert-dismissible" role="alert">',
|
||||
`<div>${message}</div>`,
|
||||
'<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>',
|
||||
'</div>'
|
||||
].join(''));
|
||||
}
|
||||
|
||||
function getUserEntries() {
|
||||
let userEntries = [];
|
||||
|
||||
// Construct member entries
|
||||
for (let entry of membersTable.find("tbody").find(".userEntry")) {
|
||||
userEntries.push(new UserEntry(
|
||||
entry.id,
|
||||
true,
|
||||
entry
|
||||
));
|
||||
}
|
||||
|
||||
// Construct nonmember entries
|
||||
for (let entry of nonMembersTable.find("tbody").find(".userEntry")) {
|
||||
userEntries.push(new UserEntry(
|
||||
entry.id,
|
||||
false,
|
||||
entry
|
||||
));
|
||||
}
|
||||
|
||||
return userEntries;
|
||||
}
|
||||
|
||||
let nonMembersTable = undefined;
|
||||
let membersTable = undefined;
|
||||
let entries = undefined;
|
||||
const groupname = window.location.pathname.split("/").pop();
|
||||
|
||||
$(document).ready(function() {
|
||||
nonMembersTable = $("#tableNonMembers");
|
||||
membersTable = $("#tableMembers");
|
||||
entries = getUserEntries();
|
||||
});
|
2
lumi2/static/robots.txt
Normal file
|
@ -0,0 +1,2 @@
|
|||
User-agent: *
|
||||
Disallow: /
|
20
lumi2/templates/auth/login.html
Normal file
|
@ -0,0 +1,20 @@
|
|||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h1 class="text-center">Log In</h1>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{{ form.csrf_token }}
|
||||
<div class="container row align-items-center border rounded m-2">
|
||||
{{ form.password.label(class="col-sm-2 col-form-label text-center") }}
|
||||
{{ form.password(class="col-sm form-control m-2" + (" is-invalid" if form.password.errors else "")) }}
|
||||
{{ form.submit(class_="col-sm-2 btn btn-primary m-2") }}
|
||||
{% if form.password.errors %}
|
||||
<div class="text-center invalid-feedback">{{ form.password.errors[0] }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
{% endblock content %}
|
|
@ -1,29 +1,85 @@
|
|||
<!doctype html>
|
||||
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<title>{{ config.SITE_TITLE }}</title>
|
||||
<meta name="description" content="{{ config.SITE_DESCRIPTION }}">
|
||||
<meta name="author" content="{{ config.SITE_AUTHOR }}">
|
||||
<title>{{ config.SITE_TITLE }}</title>
|
||||
<meta name="description" content="{{ config.SITE_DESCRIPTION }}">
|
||||
<meta name="author" content="{{ config.SITE_AUTHOR }}">
|
||||
|
||||
<meta property="og:title" content="{{ config.SITE_TITLE }}">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="{{ config.SITE_URL }}">
|
||||
<meta property="og:description" content="{{ config.SITE_DESCRIPTION }}">
|
||||
<meta property="og:image" content="{{ url_for('static', filename='images/og.png') }}">
|
||||
<meta property="og:title" content="{{ config.SITE_TITLE }}">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="{{ request.url }}">
|
||||
<meta property="og:description" content="{{ config.SITE_DESCRIPTION }}">
|
||||
<meta property="og:image" content="{{ url_for('static', filename='images/base/og.png') }}">
|
||||
|
||||
<link rel="icon" href="{{ url_for('static', filename='images/favicon.ico') }}">
|
||||
<link rel="icon" href="{{ url_for('static', filename='images/favicon.svg') }}" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="{{ url_for('static', filename='images/apple-touch-icon.png') }}">
|
||||
<link rel="icon" href="{{ url_for('static', filename='images/base/favicon.ico') }}">
|
||||
<link rel="icon" href="{{ url_for('static', filename='images/base/favicon.svg') }}" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="{{ url_for('static', filename='images/base/apple-touch-icon.png') }}">
|
||||
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
</head>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/bootstrap.css') }}">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.2/font/bootstrap-icons.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
{% block content %}
|
||||
{% endblock content %}
|
||||
</body>
|
||||
<body>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-OERcA2EqjJCMA+/3y+gxIOqMEjwtxJY7qPCqsdltbNJuaOe923+mo//f6V8Qbsw3" crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/jquery@3.6.1/dist/jquery.min.js" integrity="sha256-o88AwQnZB+VDvE9tvIXrMQaPlFFSUTR+nldQm1LuPXQ=" crossorigin="anonymous"></script>
|
||||
<nav class="navbar navbar-expand-lg bg-dark">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand pb-2" 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">
|
||||
{% if g.is_authenticated %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-primary fw-bold fs-3" href="{{ url_for('usermanager.user_list') }}">Users</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-primary fw-bold fs-3" href="{{ url_for('usermanager.group_list') }}">Groups</a>
|
||||
</li>
|
||||
{% endif%}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-primary fs-3" href="{{ url_for('usermanager.about') }}">About</a>
|
||||
</li>
|
||||
</ul>
|
||||
{% if g.is_authenticated %}
|
||||
<div class="d-flex"">
|
||||
<a class="btn btn-outline-primary"
|
||||
href="{{ url_for('auth.logout') }}"
|
||||
role="button">Log Out</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="d-flex"">
|
||||
<a class="btn btn-primary"
|
||||
href="{{ url_for('auth.login') }}"
|
||||
role="button">Log In</a>
|
||||
</div>
|
||||
{% endif%}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{% with messages = get_flashed_messages() %}
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-primary alert-dismissible" role="alert">
|
||||
<div>{{ message }}</div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
<div class="container border rounded my-3">
|
||||
{% block content %}
|
||||
{% endblock content %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
14
lumi2/templates/usermanager/about.html
Normal file
|
@ -0,0 +1,14 @@
|
|||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="text-center display-1">About</h1>
|
||||
<div class="row justify-content-md-centermt-4">
|
||||
<p class="fs-5">
|
||||
Lumi2 is free software, licensed under the <a href="https://www.gnu.org/licenses/agpl-3.0.en.html">GNU Affero General Public License Version 3.0</a>.
|
||||
The source code is publicly <a href="https://git.skyforest.net/jlobbes/lumi2">available here</a>.
|
||||
</p>
|
||||
{% if g.is_authenticated %}
|
||||
<p class="text-muted fs-5">This instance is running Lumi2 Version {{ config['APP_VERSION'] }}.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock content %}
|
64
lumi2/templates/usermanager/group_create.html
Normal file
|
@ -0,0 +1,64 @@
|
|||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mt-4">
|
||||
<div class="col text-center">
|
||||
<h1 class="display-3">Create a new Group</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row align-items-center border rounded m-2">
|
||||
<label for="groupNameInput" class="col-sm-2 col-form-label fw-bold text-center m-2">Group Name</label>
|
||||
<input type="text" class="col-sm form-control m-2" id="groupNameInput">
|
||||
<a class="col-sm-2 btn btn-outline-secondary m-2" href="{{ url_for('usermanager.group_list') }}" role="button">Cancel</a>
|
||||
<button class="col-sm-2 btn btn-primary m-2" type="button" id="createGroupButton">Create Group</button>
|
||||
</div>
|
||||
<div class="row border rounded m-2">
|
||||
<table class="table align-middle" id="tableMembers">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-center" scope="col" colspan="3">Members</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="text-center" id="emptyTableNotice">
|
||||
<th scope="row" colspan="3">
|
||||
<p class="text-danger">You must add at least one member.</p>
|
||||
</th>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="row border rounded mx-2 mb-2 mt-5">
|
||||
<table class="table align-middle" id="tableNonMembers">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-center" scope="col" colspan="3">Other Users</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users|sort %}
|
||||
<tr class="userEntry text-center" id="{{ user.username }}">
|
||||
<th scope="row">
|
||||
<a href="{{ url_for('usermanager.user_view', username=user.username)}}">
|
||||
<img src="{{ user.get_thumbnail_url() }}"
|
||||
alt="Profile picture for user {{ user.username }}"
|
||||
class="img-fluid rounded"
|
||||
style="max-width: 50px"
|
||||
>
|
||||
</a>
|
||||
</th>
|
||||
<td>
|
||||
<a class="text-decoration-none fw-bold" href="{{ url_for('usermanager.user_view', username=user.username)}}">{{ user.username }}</a>
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="toggleMembershipButton btn btn-success">
|
||||
<i class="bi-person-fill-add"></i> Add
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<script src="{{ url_for('static', filename='js/group_create.js') }}"></script>
|
||||
{% endblock content %}
|
22
lumi2/templates/usermanager/group_delete.html
Normal file
|
@ -0,0 +1,22 @@
|
|||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-sm-center">
|
||||
<div class="col-sm text-center align-self-center m-2">
|
||||
<p><b>Are you sure</b> you want to <b class="text-danger">delete</b> the group '{{ groupname }}'?</p>
|
||||
<p class="text-muted">No Users will be deleted.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row justify-content-sm-center">
|
||||
<div class="col-sm text-center align-self-center m-2">
|
||||
<a class="btn btn-secondary"
|
||||
href="{{ url_for('usermanager.group_update', groupname=groupname) }}"
|
||||
role="button">No, take me back!</a>
|
||||
</div>
|
||||
<div class="col-sm text-center align-self-center m-2">
|
||||
<form method="post">
|
||||
<input class="btn btn-danger" type="submit" value="Yes, delete this group.">
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
82
lumi2/templates/usermanager/group_edit.html
Normal file
|
@ -0,0 +1,82 @@
|
|||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mt-4">
|
||||
<div class="col">
|
||||
<h1 class="text-center display-4">Editing group: {{ groupname }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center border-bottom pb-3 mb-1">
|
||||
<a class="btn btn-outline-danger"
|
||||
href="{{ url_for('usermanager.group_delete', groupname=groupname) }}"
|
||||
role="button"
|
||||
><i class="bi-x-square"></i> Delete Group</a>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm border border-primary rounded m-2">
|
||||
<table class="table align-middle" id="tableMembers">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" colspan="3" class="text-center">{{ groupname }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in members|sort %}
|
||||
<tr class="userEntry text-center" id="{{ user.username }}">
|
||||
<th scope="row">
|
||||
<a href="{{ url_for('usermanager.user_view', username=user.username)}}">
|
||||
<img src="{{ user.get_thumbnail_url() }}"
|
||||
alt="Profile picture for user {{ user.username }}"
|
||||
class="img-fluid rounded"
|
||||
style="max-width: 50px"
|
||||
>
|
||||
</a>
|
||||
</th>
|
||||
<td>
|
||||
<a class="text-decoration-none fw-bold" href="{{ url_for('usermanager.user_view', username=user.username)}}">{{ user.username }}</a>
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="toggleMembershipButton btn btn-danger">
|
||||
<i class="bi-person-fill-dash"></i> Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="col-sm border rounded m-2">
|
||||
<table class="table align-middle" id="tableNonMembers">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" colspan="3" class="text-center">Other Users</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in non_members|sort %}
|
||||
<tr class="userEntry text-center" id="{{ user.username }}">
|
||||
<th scope="row">
|
||||
<a href="{{ url_for('usermanager.user_view', username=user.username)}}">
|
||||
<img src="{{ user.get_thumbnail_url() }}"
|
||||
alt="Profile picture for user {{ user.username }}"
|
||||
class="img-fluid rounded"
|
||||
style="max-width: 50px"
|
||||
>
|
||||
</a>
|
||||
</th>
|
||||
<td>
|
||||
<a class="text-decoration-none fw-bold" href="{{ url_for('usermanager.user_view', username=user.username)}}">{{ user.username }}</a>
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="toggleMembershipButton btn btn-success">
|
||||
<i class="bi-person-fill-add"></i> Add
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<script src="{{ url_for('static', filename='js/group_edit.js') }}"></script>
|
||||
{% endblock content %}
|
45
lumi2/templates/usermanager/group_list.html
Normal file
|
@ -0,0 +1,45 @@
|
|||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="text-center mb-2">
|
||||
<h1 class="display-1">Groups</h1>
|
||||
</div>
|
||||
<div class="text-center border-bottom pb-4 mb-1">
|
||||
<a class="btn btn-primary"
|
||||
href="{{ url_for('usermanager.group_create') }}"
|
||||
role="button"
|
||||
><i class="bi-people"></i> Create a new group</a>
|
||||
</div>
|
||||
{% if groups %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-center" scope="col">Group Name</th>
|
||||
<th class="text-center" scope="col">Number of Members</th>
|
||||
<th class="text-muted text-center" scope="col">DN</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for group in groups | sort %}
|
||||
<tr class="clickable" onclick="window.location='{{ url_for('usermanager.group_update', groupname=group.groupname) }}'" style="cursor: pointer">
|
||||
<th class="text-center" scope="row">
|
||||
<a class="text-decoration-none fw-bold fs-4" href="{{ url_for('usermanager.group_update', groupname=group.groupname) }}">{{ group.groupname }}</a>
|
||||
</th>
|
||||
<td class="align-middle fs-5 text-center">
|
||||
{{ group.members|length }}
|
||||
</td>
|
||||
<td class="align-middle fs-5 text-muted text-center">
|
||||
{{ group.get_dn() }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center">
|
||||
<p class="text-muted">There are currently no groups.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock content %}
|
|
@ -1,6 +1,22 @@
|
|||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Welcome to LUMI 2</h1>
|
||||
<p>This site is still under construction.</p>
|
||||
<div class="row justify-content-md-center">
|
||||
<img src="{{ url_for('static', filename='images/base/navbar-logo.svg') }}"
|
||||
alt="Logo for lumi2"
|
||||
class="img-fluid rounded"
|
||||
style="max-width: 400px; width: 100%"
|
||||
>
|
||||
</div>
|
||||
<h1 class="text-center display-1">Welcome</h1>
|
||||
<div class="row justify-content-md-centermt-4">
|
||||
<p class="fs-3">With Lumi2 you can easily manage the users and groups in your LDAP authentication backend.</p>
|
||||
{% if g.is_authenticated %}
|
||||
<p class="fs-3">
|
||||
There are currently {{ user_count }} <a href="{{ url_for('usermanager.user_list') }}">users</a> and {{ group_count }} <a href="{{ url_for('usermanager.group_list') }}">groups</a> registered.
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="fs-3">You are currently not logged in. Please <a href="{{ url_for('auth.login') }}">log in</a> to continue.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
|
35
lumi2/templates/usermanager/user_delete.html
Normal file
|
@ -0,0 +1,35 @@
|
|||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-sm-center">
|
||||
<div class="col-sm text-center align-self-center m-2">
|
||||
<p><b>Are you sure</b> you want to <b class="text-danger">delete</b> the user '{{ username }}'?</p>
|
||||
</div>
|
||||
</div>
|
||||
{% if deleted_groups %}
|
||||
<div class="row justify-content-sm-center border border-warning rounded m-2">
|
||||
<div class="col-sm text-center align-self-center m-2">
|
||||
<p><u class="text-warning">Warning:</u> Since {{ username }} is their only member, the following <b>groups</b> will be deleted as well:</p>
|
||||
</div>
|
||||
<div class="col-sm text-center align-self-center m-2">
|
||||
<ul class="list-group">
|
||||
{% for groupname in deleted_groups %}
|
||||
<li class="list-group-item"><a href="{{ url_for('usermanager.group_update', groupname=groupname) }}">{{ groupname }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="row justify-content-sm-center">
|
||||
<div class="col-sm text-center align-self-center m-2">
|
||||
<a class="btn btn-secondary"
|
||||
href="{{ url_for('usermanager.user_view', username=username) }}"
|
||||
role="button">No, take me back!</a>
|
||||
</div>
|
||||
<div class="col-sm text-center align-self-center m-2">
|
||||
<form method="post">
|
||||
<input class="btn btn-danger" type="submit" value="Yes, delete this user.">
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
117
lumi2/templates/usermanager/user_edit.html
Normal file
|
@ -0,0 +1,117 @@
|
|||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mt-4">
|
||||
<div class="col">
|
||||
<h1 class="text-center display-3">{{ 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 "")) }}
|
||||
{% if form.email.errors %}
|
||||
{% for error in form.email.errors %}
|
||||
<div class="invalid-feedback">{{ error }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ form.first_name.label(class="form-label") }}
|
||||
{{ form.first_name(class="form-control" + (" is-invalid" if form.first_name.errors else "")) }}
|
||||
{% if form.first_name.errors %}
|
||||
{% for error in form.first_name.errors %}
|
||||
<div class="invalid-feedback">{{ error }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ form.last_name.label(class="form-label") }}
|
||||
{{ form.last_name(class="form-control" + (" is-invalid" if form.last_name.errors else "")) }}
|
||||
{% if form.last_name.errors %}
|
||||
{% for error in form.last_name.errors %}
|
||||
<div class="invalid-feedback">{{ error }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ form.display_name.label(class="form-label") }}
|
||||
{{ form.display_name(class="form-control" + (" is-invalid" if form.display_name.errors else "")) }}
|
||||
{% if form.display_name.errors %}
|
||||
{% for error in form.display_name.errors %}
|
||||
<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") }}
|
||||
{{ form.password(class="form-control" + (" is-invalid" if form.password.errors else "")) }}
|
||||
{% if form.password.errors %}
|
||||
{% for error in form.password.errors %}
|
||||
<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") }}
|
||||
{{ form.password_confirmation(class="form-control" + (" is-invalid" if form.password_confirmation.errors else "")) }}
|
||||
{% if form.password_confirmation.errors %}
|
||||
{% for error in form.password_confirmation.errors %}
|
||||
<div class="invalid-feedback">{{ error }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ form.picture.label(class="form-label") }}
|
||||
{{ form.picture(class="form-control" + (" is-invalid" if form.picture.errors else "")) }}
|
||||
{% if form.picture.errors %}
|
||||
{% for error in form.picture.errors %}
|
||||
<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>
|
||||
{% endblock content %}
|
53
lumi2/templates/usermanager/user_list.html
Normal file
|
@ -0,0 +1,53 @@
|
|||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="text-center m-2">
|
||||
<h1 class="display-1">Users</h1>
|
||||
</div>
|
||||
<div class="text-center border-bottom pb-4 mb-1">
|
||||
<a class="btn btn-primary"
|
||||
href="{{ url_for('usermanager.user_create') }}"
|
||||
role="button"
|
||||
><i class="bi-person-plus"></i> Create a new user</a>
|
||||
</div>
|
||||
{% if users %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Picture</th>
|
||||
<th scope="col">Username</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 | sort %}
|
||||
<tr class="clickable" onclick="window.location='{{ url_for('usermanager.user_view', username=user.username) }}'" style="cursor: pointer">
|
||||
<th scope="row">
|
||||
<img src="{{ user.get_thumbnail_url() }}"
|
||||
alt="profile picture for user {{ user.username }}"
|
||||
class="img-fluid rounded"
|
||||
style="max-width: 100px"
|
||||
>
|
||||
</th>
|
||||
<td class="align-middle">
|
||||
<a class="text-decoration-none fw-bold" href="{{ url_for('usermanager.user_view', username=user.username) }}">{{ user.username }}</a>
|
||||
</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 %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center">
|
||||
<p class="text-muted">There are currently no users.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock content %}
|
41
lumi2/templates/usermanager/user_view.html
Normal file
|
@ -0,0 +1,41 @@
|
|||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-sm-center">
|
||||
<div class="col-sm-2 text-center align-self-center m-2">
|
||||
<img src="{{ user.get_picture_url() }}"
|
||||
alt="profile picture for user {{ user.username }}"
|
||||
class="img-thumbnail"
|
||||
style="max-width: 150px"
|
||||
>
|
||||
</div>
|
||||
<div class="col-sm-4 vstack gap-1 align-self-center m-2">
|
||||
<div class="text-muted"><b>DN:</b> {{ user.get_dn() }}</div>
|
||||
<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 class="col-sm-auto vstack gap-1 align-self-center m-2">
|
||||
<a class="btn btn-primary"
|
||||
href="{{ url_for('usermanager.user_update', username=user.username) }}"
|
||||
role="button"><i class="bi-person-gear"></i> Edit</a>
|
||||
<a class="btn btn-outline-danger"
|
||||
href="{{ url_for('usermanager.user_delete', username=user.username) }}"
|
||||
role="button"><i class="bi-person-slash"></i> Delete</a>
|
||||
</div>
|
||||
<div class="col-sm-12 text-center align-self-center m-2">
|
||||
{% with groups = user.get_groups() %}
|
||||
{% if groups %}
|
||||
<p>Member of:</p>
|
||||
{% for group in groups %}
|
||||
<a class="btn btn-secondary" role="button" href="{{ url_for('usermanager.group_update', groupname=group.groupname) }}">{{ group.groupname }}</a>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="text-muted">This user is not a member of any group.</p>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
|
@ -1,14 +1,475 @@
|
|||
"""Views for lumi2."""
|
||||
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from tempfile import TemporaryFile
|
||||
from json import loads, dumps, JSONDecodeError
|
||||
|
||||
from flask import (
|
||||
Blueprint, render_template
|
||||
Blueprint, render_template, abort, request, flash, redirect, url_for,
|
||||
current_app, g, send_from_directory
|
||||
)
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from flask_wtf import FlaskForm
|
||||
from flask_wtf.file import FileField, FileAllowed
|
||||
from wtforms import (
|
||||
ValidationError, StringField, PasswordField, SubmitField, HiddenField
|
||||
)
|
||||
from wtforms.validators import InputRequired, Email, EqualTo
|
||||
|
||||
from lumi2.auth import login_required
|
||||
import lumi2.ldap as ldap
|
||||
from lumi2.usermodel import User, Group
|
||||
from lumi2.exceptions import InvalidStringFormatException, InvalidImageException
|
||||
|
||||
|
||||
bp = Blueprint('usermanager', __name__)
|
||||
|
||||
|
||||
@bp.before_app_first_request
|
||||
def _init_static_images():
|
||||
"""Purges and recreates the static images folder."""
|
||||
|
||||
path_to_image_cache = Path(current_app.instance_path) / "protected" / "images" / "users"
|
||||
if path_to_image_cache.is_dir():
|
||||
shutil.rmtree(path_to_image_cache)
|
||||
path_to_image_cache.mkdir(parents=True)
|
||||
|
||||
conn = ldap.get_connection()
|
||||
for user in ldap.get_users(conn):
|
||||
user._generate_static_images()
|
||||
conn.unbind()
|
||||
|
||||
|
||||
@bp.before_app_first_request
|
||||
def _initialize_ldap_dit():
|
||||
"""Creates the OUs for users and groups if they do not exist yet."""
|
||||
|
||||
conn = ldap.get_connection()
|
||||
if not ldap.ou_exists(conn, current_app.config['LDAP_USERS_OU']):
|
||||
ldap.create_ou(conn, current_app.config['LDAP_USERS_OU'])
|
||||
if not ldap.ou_exists(conn, current_app.config['LDAP_GROUPS_OU']):
|
||||
ldap.create_ou(conn, current_app.config['LDAP_GROUPS_OU'])
|
||||
conn.unbind()
|
||||
|
||||
|
||||
@bp.route('/protected/<path:path_to_file>')
|
||||
@login_required
|
||||
def protected(path_to_file):
|
||||
"""Returns the specified file only if the requesting client is logged in."""
|
||||
|
||||
return send_from_directory(
|
||||
Path(current_app.instance_path) / "protected", path_to_file
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/')
|
||||
def index():
|
||||
"""Home page view."""
|
||||
|
||||
if g.is_authenticated:
|
||||
try:
|
||||
conn = ldap.get_connection()
|
||||
except Exception:
|
||||
abort(500)
|
||||
|
||||
user_count = len(ldap.get_users(conn))
|
||||
group_count = len(ldap.get_groups(conn))
|
||||
conn.unbind()
|
||||
return render_template(
|
||||
'usermanager/index.html',
|
||||
user_count=user_count,
|
||||
group_count=group_count
|
||||
)
|
||||
|
||||
return render_template('usermanager/index.html')
|
||||
|
||||
|
||||
@bp.route('/about')
|
||||
def about():
|
||||
"""About page view."""
|
||||
|
||||
return render_template('usermanager/about.html')
|
||||
|
||||
|
||||
@bp.route('/robots.txt')
|
||||
def robots():
|
||||
"""Serves the 'robots.txt' file."""
|
||||
|
||||
return send_from_directory(current_app.static_folder, "robots.txt")
|
||||
|
||||
|
||||
@bp.route("/users/view/<string:username>")
|
||||
@login_required
|
||||
def user_view(username: str):
|
||||
"""Detail view for a specific User.
|
||||
|
||||
Shows the user's information.
|
||||
"""
|
||||
|
||||
try:
|
||||
conn = ldap.get_connection()
|
||||
except Exception:
|
||||
abort(500)
|
||||
|
||||
try:
|
||||
user = ldap.get_user(conn, username)
|
||||
except ldap.EntryNotFoundException:
|
||||
conn.unbind()
|
||||
abort(404)
|
||||
|
||||
conn.unbind()
|
||||
return render_template('usermanager/user_view.html',user=user)
|
||||
|
||||
|
||||
@bp.route("/users/list")
|
||||
@login_required
|
||||
def user_list():
|
||||
"""Displays a list of all users."""
|
||||
|
||||
try:
|
||||
conn = ldap.get_connection()
|
||||
users = ldap.get_users(conn)
|
||||
conn.unbind()
|
||||
except Exception:
|
||||
abort(500)
|
||||
|
||||
return render_template(
|
||||
'usermanager/user_list.html',
|
||||
users=users,
|
||||
)
|
||||
|
||||
class UserUpdateForm(FlaskForm):
|
||||
@staticmethod
|
||||
def validate_name(form, field) -> None:
|
||||
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:
|
||||
if field.data:
|
||||
try:
|
||||
User.assert_is_valid_password(field.data)
|
||||
except InvalidStringFormatException as e:
|
||||
raise ValidationError(str(e))
|
||||
|
||||
@staticmethod
|
||||
def validate_picture(form, field) -> None:
|
||||
if field.data and field.data.filename:
|
||||
try:
|
||||
Image.open(field.data, formats=['JPEG'])
|
||||
field.data.seek(0)
|
||||
except UnidentifiedImageError as e:
|
||||
raise ValidationError(
|
||||
"Invalid JPEG file. It may be corrupted."
|
||||
)
|
||||
|
||||
email = StringField(
|
||||
'Email',
|
||||
[InputRequired(), Email()]
|
||||
)
|
||||
first_name = StringField(
|
||||
'First Name',
|
||||
[InputRequired(), validate_name]
|
||||
)
|
||||
last_name = StringField(
|
||||
'Last Name',
|
||||
[InputRequired(), validate_name]
|
||||
)
|
||||
display_name = StringField(
|
||||
'Nickname',
|
||||
[InputRequired(), validate_name]
|
||||
)
|
||||
password = PasswordField(
|
||||
'Password',
|
||||
[
|
||||
EqualTo('password_confirmation', message='Passwords must match'),
|
||||
validate_password,
|
||||
],
|
||||
)
|
||||
password_confirmation = PasswordField(
|
||||
'Password (repeat)',
|
||||
)
|
||||
picture = FileField(
|
||||
'Picture',
|
||||
[FileAllowed(['jpg', 'jpeg'], 'JPEG images only.')]
|
||||
)
|
||||
submit = SubmitField(
|
||||
'Update',
|
||||
)
|
||||
|
||||
|
||||
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"))
|
||||
@login_required
|
||||
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"))
|
||||
@login_required
|
||||
def user_update(username: str):
|
||||
"""Update view for a specific User.
|
||||
|
||||
Provides a form which can be used to edit that user's details.
|
||||
"""
|
||||
|
||||
try:
|
||||
conn = ldap.get_connection()
|
||||
except Exception:
|
||||
abort(500)
|
||||
|
||||
try:
|
||||
user = ldap.get_user(conn, username)
|
||||
except ldap.EntryNotFoundException:
|
||||
conn.unbind()
|
||||
abort(404)
|
||||
|
||||
form = UserUpdateForm(obj=user)
|
||||
if form.validate_on_submit():
|
||||
if form.email.data:
|
||||
user.email = form.email.data
|
||||
if form.first_name.data:
|
||||
user.first_name = form.first_name.data
|
||||
if form.last_name.data:
|
||||
user.last_name = form.last_name.data
|
||||
if form.display_name.data:
|
||||
user.display_name = form.display_name.data
|
||||
if form.password.data:
|
||||
user.password_hash = User.generate_password_hash(form.password.data)
|
||||
if form.picture.data and form.picture.data.filename:
|
||||
user.picture = Image.open(form.picture.data, formats=['JPEG'])
|
||||
|
||||
user._generate_static_images(force=True)
|
||||
ldap.update_user(conn, user)
|
||||
conn.unbind()
|
||||
flash(f"Information for user '{user.username}' was updated.")
|
||||
return redirect(url_for('usermanager.user_view', username=user.username))
|
||||
|
||||
conn.unbind()
|
||||
return render_template(
|
||||
'usermanager/user_edit.html',
|
||||
form=form,
|
||||
username=user.username,
|
||||
heading=f"Edit user: {user.username}",
|
||||
is_update=True,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/users/delete/<string:username>", methods=("GET", "POST"))
|
||||
@login_required
|
||||
def user_delete(username: str):
|
||||
"""Deletion view for a specific User.
|
||||
|
||||
Provides a form prompting the confirmation of the specified user.
|
||||
If the user is the sole member of any groups, the groups which get deleted
|
||||
implicitly by the lumi.ldap.delete_user() operation will be listed here as
|
||||
well.
|
||||
"""
|
||||
|
||||
try:
|
||||
conn = ldap.get_connection()
|
||||
except Exception:
|
||||
abort(500)
|
||||
|
||||
try:
|
||||
user = ldap.get_user(conn, username)
|
||||
except ldap.EntryNotFoundException:
|
||||
conn.unbind()
|
||||
abort(404)
|
||||
|
||||
deleted_groups = set()
|
||||
for group in ldap.get_groups_of_user(conn, user):
|
||||
if len(group.members) == 1:
|
||||
deleted_groups.add(group.groupname)
|
||||
|
||||
if request.method == 'POST':
|
||||
ldap.delete_user(conn, user.username)
|
||||
# FIXME delete user's static image folder!!!
|
||||
# currently, the images are only purged on app restart
|
||||
conn.unbind()
|
||||
flash(f"The user '{user.username}' was deleted.")
|
||||
for groupname in deleted_groups:
|
||||
flash(f"The group '{groupname}' was deleted.")
|
||||
return redirect(url_for('usermanager.user_list'))
|
||||
|
||||
return render_template(
|
||||
'usermanager/user_delete.html',
|
||||
username=user.username,
|
||||
deleted_groups=deleted_groups,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/groups/list")
|
||||
@login_required
|
||||
def group_list():
|
||||
"""Displays a list of all groups."""
|
||||
|
||||
try:
|
||||
conn = ldap.get_connection()
|
||||
groups = ldap.get_groups(conn)
|
||||
conn.unbind()
|
||||
except Exception:
|
||||
abort(500)
|
||||
|
||||
return render_template(
|
||||
'usermanager/group_list.html',
|
||||
groups=groups,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/groups/create")
|
||||
@login_required
|
||||
def group_create():
|
||||
"""Creation view for a new group.
|
||||
|
||||
Shows a table allowing adding members to this group.
|
||||
"""
|
||||
|
||||
try:
|
||||
conn = ldap.get_connection()
|
||||
except Exception:
|
||||
abort(500)
|
||||
|
||||
users = ldap.get_users(conn)
|
||||
conn.unbind()
|
||||
|
||||
return render_template(
|
||||
'usermanager/group_create.html',
|
||||
users=users,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/groups/update/<string:groupname>")
|
||||
@login_required
|
||||
def group_update(groupname: str):
|
||||
"""Detail and Update view for a group.
|
||||
|
||||
Shows a table allowing the modification of user memberships for this group.
|
||||
"""
|
||||
|
||||
try:
|
||||
conn = ldap.get_connection()
|
||||
except Exception:
|
||||
abort(500)
|
||||
|
||||
try:
|
||||
group = ldap.get_group(conn, groupname)
|
||||
except ldap.EntryNotFoundException:
|
||||
conn.unbind()
|
||||
abort(404)
|
||||
|
||||
members = {user for user in group.members}
|
||||
non_members = {user for user in ldap.get_users(conn)} - members
|
||||
|
||||
conn.unbind()
|
||||
return render_template(
|
||||
'usermanager/group_edit.html',
|
||||
groupname=group.groupname,
|
||||
members=members,
|
||||
non_members=non_members,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/groups/delete/<string:groupname>", methods=("GET", "POST"))
|
||||
@login_required
|
||||
def group_delete(groupname: str):
|
||||
"""Deletion view for a specific Group.
|
||||
|
||||
Provides a form prompting the confirmation of the specified group.
|
||||
"""
|
||||
|
||||
try:
|
||||
conn = ldap.get_connection()
|
||||
except Exception:
|
||||
abort(500)
|
||||
|
||||
try:
|
||||
group = ldap.get_group(conn, groupname)
|
||||
except ldap.EntryNotFoundException:
|
||||
conn.unbind()
|
||||
abort(404)
|
||||
|
||||
if request.method == 'POST':
|
||||
ldap.delete_group(conn, group.groupname)
|
||||
conn.unbind()
|
||||
flash(f"The group '{group.groupname}' was deleted.")
|
||||
return redirect(url_for('usermanager.group_list'))
|
||||
|
||||
return render_template(
|
||||
'usermanager/group_delete.html',
|
||||
groupname=group.groupname,
|
||||
)
|
||||
|
|
|
@ -2,12 +2,18 @@
|
|||
|
||||
from string import ascii_lowercase, ascii_uppercase, digits, whitespace
|
||||
from base64 import b64encode, b64decode
|
||||
import hashlib
|
||||
from hashlib import sha1
|
||||
from binascii import Error as Base64DecodeError
|
||||
from pathlib import Path
|
||||
from os import urandom
|
||||
|
||||
from PIL import Image
|
||||
from PIL.JpegImagePlugin import JpegImageFile
|
||||
from flask import current_app
|
||||
|
||||
from lumi2.exceptions import InvalidStringFormatException, InvalidImageException
|
||||
import lumi2.ldap as ldap
|
||||
|
||||
class User:
|
||||
"""Class model for a user.
|
||||
|
||||
|
@ -16,7 +22,7 @@ class User:
|
|||
username : str
|
||||
The user's username.
|
||||
password_hash : str
|
||||
Base64-encoded SHA512 hash of the user's password.
|
||||
Base64-encoded SSHA hash of the user's password.
|
||||
email : str
|
||||
The user's email address.
|
||||
first_name : str
|
||||
|
@ -31,7 +37,7 @@ class User:
|
|||
|
||||
|
||||
@staticmethod
|
||||
def is_valid_username(input_str: str) -> bool:
|
||||
def assert_is_valid_username(input_str: str) -> None:
|
||||
"""Checks whether the input string is a valid username.
|
||||
|
||||
Valid usernames can contain only uppercase/lowercase latin characters,
|
||||
|
@ -44,38 +50,74 @@ class User:
|
|||
input_str : str
|
||||
The string whose validity as a username to check.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
True if input_str is a valid username and False otherwise.
|
||||
|
||||
Raises
|
||||
------
|
||||
TypeError
|
||||
If input_str is not of type string.
|
||||
InvalidStringFormatException
|
||||
If input_str is not a valid username.
|
||||
"""
|
||||
|
||||
if not isinstance(input_str, str):
|
||||
raise TypeError(f"Expected a string but got: '{type(input_str)}'.")
|
||||
|
||||
if not len(input_str):
|
||||
return False
|
||||
raise InvalidStringFormatException("Username must contain at least one character.")
|
||||
if len(input_str) > 64:
|
||||
return False
|
||||
raise InvalidStringFormatException("Username must not exceed 64 characters in length.")
|
||||
|
||||
if input_str[0] not in ascii_lowercase + ascii_uppercase:
|
||||
return False
|
||||
raise InvalidStringFormatException("Username must start with a letter.")
|
||||
|
||||
valid_chars = ascii_lowercase + ascii_uppercase + digits + "-_."
|
||||
for char in input_str:
|
||||
if char not in valid_chars:
|
||||
return False
|
||||
|
||||
return True
|
||||
raise InvalidStringFormatException(
|
||||
f"Invalid character in username: '{char}'."
|
||||
)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def is_valid_password_hash(input_str: str) -> bool:
|
||||
def assert_is_valid_password(input_str: str) -> None:
|
||||
"""Checks whether the input string is a valid password.
|
||||
|
||||
A valid password may not contain any whitespace.
|
||||
They must be at least 8 characters long and at 64 characters at most.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
input_str : str
|
||||
The string whose validity as a password to check.
|
||||
|
||||
Raises
|
||||
------
|
||||
TypeError
|
||||
If input_str is not of type string.
|
||||
InvalidStringFormatException
|
||||
If input_str is not a valid password.
|
||||
"""
|
||||
|
||||
if not isinstance(input_str, str):
|
||||
raise TypeError(f"Expected a string but got: '{type(input_str)}'.")
|
||||
|
||||
if len(input_str) < 8:
|
||||
raise InvalidStringFormatException(
|
||||
"Password must be at least 8 characters in length."
|
||||
)
|
||||
if len(input_str) > 64:
|
||||
raise InvalidStringFormatException(
|
||||
"Password may not be longer than 8 characters."
|
||||
)
|
||||
|
||||
for char in input_str:
|
||||
if char in whitespace:
|
||||
raise InvalidStringFormatException(
|
||||
"Password my not contain any whitespace."
|
||||
)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def assert_is_valid_password_hash(input_str: str) -> None:
|
||||
"""Checks whether the input string is a valid password hash.
|
||||
|
||||
A valid password hash is a non-empty string containing base64-decodeable
|
||||
|
@ -86,141 +128,150 @@ class User:
|
|||
input_str : str
|
||||
The string whose validity as a password hash to check.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
True if input_str is a valid password hash and False otherwise.
|
||||
|
||||
Raises
|
||||
------
|
||||
TypeError
|
||||
If input_str is not of type string.
|
||||
InvalidStringFormatException
|
||||
If input_str is not a valid password hash.
|
||||
"""
|
||||
|
||||
if not isinstance(input_str, str):
|
||||
raise TypeError(f"Expected a string but got: '{type(input_str)}'.")
|
||||
|
||||
if not len(input_str):
|
||||
return False
|
||||
raise InvalidStringFormatException(
|
||||
"Password hash must be at least one character in length."
|
||||
)
|
||||
|
||||
try:
|
||||
b64decode(input_str, validate=True)
|
||||
return True
|
||||
except Base64DecodeError:
|
||||
return False
|
||||
except Base64DecodeError as e:
|
||||
raise InvalidStringFormatException from e
|
||||
|
||||
|
||||
@staticmethod
|
||||
def is_valid_email(input_str: str) -> bool:
|
||||
def assert_is_valid_email(input_str: str) -> None:
|
||||
"""Checks whether the input string is a valid email address.
|
||||
|
||||
WARNING: this validation is very rudimentary. Proper validation requires
|
||||
Very rudimentary check, proper validation would require
|
||||
a validation email to be sent and confirmed by the user.
|
||||
A valid email address contains no whitespace, at least one '@' character,
|
||||
and a '.' character somewhere after the '@'.
|
||||
The maximum length for a valid email address is 64 characters.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
input_str : str
|
||||
The string whose validity as an email address to check.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
True if input_str is a valid email address and False otherwise.
|
||||
|
||||
Raises
|
||||
------
|
||||
TypeError
|
||||
If input_str is not of type string.
|
||||
InvalidStringFormatException
|
||||
If input_str is not a valid email address.
|
||||
"""
|
||||
|
||||
if not isinstance(input_str, str):
|
||||
raise TypeError(f"Expected a string but got: '{type(input_str)}'.")
|
||||
|
||||
if '@' not in input_str:
|
||||
return False
|
||||
raise InvalidStringFormatException(
|
||||
"Invalid email address: no '@' found."
|
||||
)
|
||||
if '.' not in input_str:
|
||||
return False
|
||||
raise InvalidStringFormatException(
|
||||
"Invalid email address: no top-level-domain found."
|
||||
)
|
||||
if '.' not in input_str.split('@')[1]:
|
||||
return False
|
||||
raise InvalidStringFormatException(
|
||||
"Invalid email address: no top-level-domain found."
|
||||
)
|
||||
|
||||
if len(input_str) > 64:
|
||||
raise InvalidStringFormatException(
|
||||
"Invalid email address: may not be longer than 64 characters."
|
||||
)
|
||||
|
||||
for char in input_str:
|
||||
if char in whitespace:
|
||||
return False
|
||||
|
||||
return True
|
||||
raise InvalidStringFormatException(
|
||||
"Invalid email address: no whitespace permitted."
|
||||
)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def is_valid_person_name(input_str: str) -> bool:
|
||||
def assert_is_valid_name(input_str: str) -> None:
|
||||
"""Checks whether the input string is valid as a first/last/display name.
|
||||
|
||||
Valid names cannot contain whitespace and must be at least one character
|
||||
long.
|
||||
long, and 64 characters at most.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
input_str : str
|
||||
The string whose validity as a first-/last-/displayname to check.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
True if input_str is a valid name and False otherwise.
|
||||
|
||||
Raises
|
||||
------
|
||||
TypeError
|
||||
If input_str is not of type string.
|
||||
InvalidStringFormatException
|
||||
If input_str is not a valid person's name.
|
||||
"""
|
||||
|
||||
if not isinstance(input_str, str):
|
||||
raise TypeError(f"Expected a string but got: '{type(input_str)}'.")
|
||||
|
||||
if not len(input_str):
|
||||
return False
|
||||
raise InvalidStringFormatException(
|
||||
"Invalid name: must be at least 1 character in length."
|
||||
)
|
||||
if len(input_str) > 64:
|
||||
raise InvalidStringFormatException(
|
||||
"Invalid name: may not be longer than 64 characters."
|
||||
)
|
||||
|
||||
for char in input_str:
|
||||
if char in whitespace:
|
||||
return False
|
||||
|
||||
return True
|
||||
raise InvalidStringFormatException(
|
||||
"Invalid name: may not contain whitespace."
|
||||
)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def is_valid_picture(input_image: Image.Image) -> bool:
|
||||
def assert_is_valid_picture(input_image: JpegImageFile) -> None:
|
||||
"""Checks whether the input image is a valid Image object.
|
||||
|
||||
TBD - unsure which formats and filesizes to allow here.
|
||||
Valid images must be of type JpegImageFile.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
input_image : PIL.Image
|
||||
The Image whose validity to check.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
True if input_image is a valid Image and False otherwise.
|
||||
input_image : PIL.Image.JpegImageFile
|
||||
The Image object whose validity to check.
|
||||
|
||||
Raises
|
||||
------
|
||||
TypeError
|
||||
If input_image is not of type PIL.Image.
|
||||
InvalidImageException
|
||||
If the input image's type is not PIL.Image.JpegImageFile.
|
||||
"""
|
||||
|
||||
if not isinstance(input_image, Image.Image):
|
||||
raise TypeError(f"Expected a PIL Image but got: '{type(input_image)}'.")
|
||||
|
||||
# TODO implement some integrity checks
|
||||
# TODO implement some filesize restrictions
|
||||
return True
|
||||
if not isinstance(input_image, JpegImageFile):
|
||||
raise InvalidImageException(
|
||||
"User picture must be in JPEG format."
|
||||
)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def generate_password_hash(password: str) -> str:
|
||||
"""Generates a base64-encoded SHA512 hash of the input string.
|
||||
"""Generates a base64-encoded SSHA hash of the input string.
|
||||
|
||||
The 4-byte salt is appended to the digest prior to base64-encoding.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
|
@ -230,7 +281,7 @@ class User:
|
|||
Returns
|
||||
-------
|
||||
str
|
||||
A base64-encoded SHA512 hash digest of the input string.
|
||||
A base64-encoded SSHA hash digest of the input string.
|
||||
|
||||
Raises
|
||||
------
|
||||
|
@ -245,9 +296,11 @@ class User:
|
|||
if not len(password):
|
||||
raise ValueError("Input string cannot be empty.")
|
||||
|
||||
hash_bytes = hashlib.sha512()
|
||||
salt = urandom(4)
|
||||
hash_bytes = sha1()
|
||||
hash_bytes.update(bytes(password, "UTF-8"))
|
||||
return b64encode(hash_bytes.digest()).decode("ASCII")
|
||||
hash_bytes.update(salt)
|
||||
return b64encode(hash_bytes.digest() + salt).decode("ASCII")
|
||||
|
||||
|
||||
@staticmethod
|
||||
|
@ -260,7 +313,7 @@ class User:
|
|||
The default user profile picture.
|
||||
"""
|
||||
|
||||
image_path = f"{current_app.static_folder}/assets/default_user_icon.jpg"
|
||||
image_path = f"{current_app.static_folder}/images/default/user.jpg"
|
||||
return Image.open(image_path)
|
||||
|
||||
|
||||
|
@ -270,38 +323,87 @@ class User:
|
|||
first_name: str, last_name: str, display_name = None,
|
||||
picture = None,
|
||||
):
|
||||
"""Constructor for User objects.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
username : str
|
||||
The username, valid as described by User.assert_is_valid_username().
|
||||
password_hash : str
|
||||
The user's base64-encoded SSHA-hashed password (without the
|
||||
'{SSHA}'-prefix expected by LDAP).
|
||||
Must be valid as described by User.assert_is_valid_password_hash().
|
||||
email : str
|
||||
The User's email address.
|
||||
Must be valid as described by User.assert_is_valid_email().
|
||||
first_name : str
|
||||
The User's first name.
|
||||
Must be valid as described by User.assert_is_valid_name().
|
||||
last_name : str
|
||||
The User's last name.
|
||||
Must be valid as described by User.assert_is_valid_name().
|
||||
display_name : str = first_name
|
||||
The User's nickname. If unspecified, gets set to the User's first
|
||||
name.
|
||||
Must be valid as described by User.assert_is_valid_name().
|
||||
picture : PIL.Image.JpegImageFile
|
||||
The User's JPEG picture. If unspecified, a default user picture is
|
||||
used. Must be valid as described by User.asser_is_valid_picture().
|
||||
"""
|
||||
|
||||
try:
|
||||
User.assert_is_valid_username(username)
|
||||
User.assert_is_valid_password_hash(password_hash)
|
||||
User.assert_is_valid_email(email)
|
||||
User.assert_is_valid_name(first_name)
|
||||
User.assert_is_valid_name(last_name)
|
||||
if display_name is not None:
|
||||
User.assert_is_valid_name(display_name)
|
||||
if picture is not None:
|
||||
User.assert_is_valid_picture(picture)
|
||||
except (InvalidStringFormatException, InvalidImageException) as e:
|
||||
raise ValueError from e
|
||||
|
||||
if not User.is_valid_username(username):
|
||||
raise ValueError(f"Not a valid username: '{username}'.")
|
||||
self.username = username
|
||||
|
||||
if not User.is_valid_password_hash(password_hash):
|
||||
raise ValueError(f"Not a valid password hash: '{password_hash}'.")
|
||||
self.password_hash = password_hash
|
||||
|
||||
if not User.is_valid_email(email):
|
||||
raise ValueError(f"Not a valid email address: '{email}'.")
|
||||
self.email = email
|
||||
|
||||
for name in [first_name, last_name]:
|
||||
if not User.is_valid_person_name(name):
|
||||
raise ValueError(f"Not a valid name: '{name}'.")
|
||||
self.first_name = first_name
|
||||
self.last_name = last_name
|
||||
self.display_name = display_name if display_name is not None else first_name
|
||||
self.picture = picture if picture is not None else User._get_default_picture()
|
||||
|
||||
if display_name is not None:
|
||||
if not User.is_valid_person_name(display_name):
|
||||
raise ValueError(f"Not a valid display name: '{display_name}'.")
|
||||
self.display_name = display_name
|
||||
else:
|
||||
self.display_name = first_name
|
||||
|
||||
if picture is not None:
|
||||
if not User.is_valid_picture(picture):
|
||||
raise ValueError(f"Not a valid image: '{picture}'.")
|
||||
self.picture = picture
|
||||
else:
|
||||
self.picture = User._get_default_picture()
|
||||
def _generate_static_images(self, force=False) -> None:
|
||||
"""Generates the static images for this User's picture on disc.
|
||||
|
||||
The user's full profile picture and a thumbnail are written to
|
||||
'protected/images/user/<username>/full.jpg'
|
||||
and 'protected/images/user/<username>/thumbnail.jpg' respectively.
|
||||
The thumbnail's fixed size is 512x512 px.
|
||||
|
||||
If the parameter force is set to True, existing images are overwritten.
|
||||
Otherwise, if the images already exist on disk, image generation is skipped.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
force : bool = False
|
||||
Whether or not existing images on disk should be regenerated.
|
||||
"""
|
||||
|
||||
path_to_image_folder = Path(current_app.instance_path) / "protected" / "images" / "users" / self.username
|
||||
path_to_full_image = path_to_image_folder / "full.jpg"
|
||||
path_to_thumbnail = path_to_image_folder / "thumbnail.jpg"
|
||||
|
||||
if not path_to_image_folder.is_dir():
|
||||
path_to_image_folder.mkdir(parents=True)
|
||||
|
||||
if not path_to_full_image.is_file() or force:
|
||||
self.picture.save(path_to_full_image)
|
||||
|
||||
if not path_to_thumbnail.is_file() or force:
|
||||
thumb = self.picture.copy()
|
||||
thumb.thumbnail((256, 256))
|
||||
thumb.save(path_to_thumbnail)
|
||||
|
||||
|
||||
def get_dn(self) -> str:
|
||||
|
@ -320,6 +422,63 @@ class User:
|
|||
return "uid=" + self.username + ',' + current_app.config['LDAP_USERS_OU']
|
||||
|
||||
|
||||
def get_picture_url(self):
|
||||
"""Returns the URL to this user's static profile picture image file."""
|
||||
|
||||
return f'/protected/images/users/{self.username}/full.jpg'
|
||||
|
||||
|
||||
def get_thumbnail_url(self):
|
||||
"""Returns the URL to this user's static profile thumbnail image file."""
|
||||
|
||||
return f'/protected/images/users/{self.username}/thumbnail.jpg'
|
||||
|
||||
|
||||
def get_groups(self):
|
||||
"""Returns the set of groups which this user is a member of."""
|
||||
|
||||
conn = ldap.get_connection()
|
||||
groups = ldap.get_groups_of_user(conn, self)
|
||||
conn.unbind()
|
||||
return groups
|
||||
|
||||
|
||||
def check_password(self, password_plaintext: str) -> bool:
|
||||
"""Checks the input plaintext password against this User's password hash.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
password_plaintext : str
|
||||
The plaintext password to check against this User's salted password
|
||||
hash.
|
||||
|
||||
Returns
|
||||
-------
|
||||
True : bool
|
||||
If the input password_plaintext is this User's password.
|
||||
False : bool
|
||||
Otherwise.
|
||||
|
||||
Raises
|
||||
------
|
||||
TypeError
|
||||
If password_plaintext is not of type string.
|
||||
"""
|
||||
|
||||
if not isinstance(password_plaintext, str):
|
||||
raise TypeError(f"Expected a string but got: '{type(password_plaintext)}'.")
|
||||
|
||||
password_hash_bytes = b64decode(self.password_hash)
|
||||
digest_bytes = password_hash_bytes[:20]
|
||||
salt = password_hash_bytes[20:]
|
||||
|
||||
validation_hash = sha1()
|
||||
validation_hash.update(bytes(password_plaintext, "UTF-8"))
|
||||
validation_hash.update(salt)
|
||||
|
||||
return validation_hash.digest() == digest_bytes
|
||||
|
||||
|
||||
def __eq__(self, other):
|
||||
return self.username == other.username
|
||||
|
||||
|
@ -351,48 +510,57 @@ class Group:
|
|||
"""
|
||||
|
||||
@staticmethod
|
||||
def is_valid_groupname(input_str: str) -> bool:
|
||||
def assert_is_valid_groupname(input_str: str) -> None:
|
||||
"""Checks whether the input string is a valid group name.
|
||||
|
||||
A valid group name consists of only alphanumeric characters, starts with
|
||||
a latin character and has minimum length 1.
|
||||
A valid group name consists of only alphabetic characters, has minimum
|
||||
length 1 and maximum length 64.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
input_str : str
|
||||
The string to check for validity as a group name.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
True if input_str is a valid group name, and False otherwise.
|
||||
|
||||
Raises
|
||||
------
|
||||
TypeError
|
||||
If input_str is not of type string.
|
||||
InvalidStringFormatException
|
||||
If input_str is not a valid group name.
|
||||
"""
|
||||
|
||||
if not isinstance(input_str, str):
|
||||
raise TypeError(f"Expected a string but got: '{type(input_str)}'.")
|
||||
|
||||
if not len(input_str):
|
||||
return False
|
||||
|
||||
if input_str[0] not in ascii_lowercase + ascii_uppercase:
|
||||
return False
|
||||
|
||||
raise InvalidStringFormatException(
|
||||
"Invalid group name: must contain at least one character."
|
||||
)
|
||||
for char in input_str:
|
||||
if not char in ascii_uppercase + ascii_lowercase + digits:
|
||||
return False
|
||||
|
||||
return True
|
||||
if not char in ascii_uppercase + ascii_lowercase:
|
||||
raise InvalidStringFormatException(
|
||||
f"Invalid character in group name: '{char}'."
|
||||
)
|
||||
|
||||
|
||||
def __init__(self, groupname: str, members: set[User]):
|
||||
"""Constructor for Group objects.
|
||||
|
||||
if not Group.is_valid_groupname(groupname):
|
||||
raise ValueError("Not a valid group name: '{groupname}'.")
|
||||
Groups must always have at least one member (an LDAP limitation).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
groupname : str
|
||||
The Group's name.
|
||||
Must be valid as described by Group.assert_is_valid_groupname().
|
||||
members : set[User]
|
||||
A set conaining Users who are members of this Group.
|
||||
The set must contain at least one member.
|
||||
"""
|
||||
try:
|
||||
Group.assert_is_valid_groupname(groupname)
|
||||
except InvalidStringFormatException as e:
|
||||
raise ValueError from e
|
||||
self.groupname = groupname
|
||||
|
||||
if not isinstance(members, set):
|
||||
|
|
314
lumi2/webapi.py
Normal file
|
@ -0,0 +1,314 @@
|
|||
from json import JSONEncoder, JSONDecoder, loads, dumps, JSONDecodeError
|
||||
|
||||
from flask import request, g
|
||||
from flask_restful import Resource, abort
|
||||
|
||||
import lumi2.ldap as ldap
|
||||
from lumi2.usermodel import User, Group
|
||||
from lumi2.exceptions import InvalidStringFormatException
|
||||
|
||||
|
||||
def _assert_is_authenticated():
|
||||
if not g.is_authenticated:
|
||||
abort(401, message="You are not logged in.")
|
||||
|
||||
|
||||
class UserEncoder(JSONEncoder):
|
||||
def default(self, obj):
|
||||
if isinstance(obj, User):
|
||||
return {
|
||||
"username": obj.username,
|
||||
"password_hash": obj.password_hash,
|
||||
"email": obj.email,
|
||||
"first_name": obj.first_name,
|
||||
"last_name": obj.last_name,
|
||||
"display_name": obj.display_name,
|
||||
"picture": obj.get_picture_url(),
|
||||
}
|
||||
|
||||
return JSONEncoder.default(self, obj)
|
||||
|
||||
|
||||
class GroupEncoder(JSONEncoder):
|
||||
def default(self, obj):
|
||||
if isinstance(obj, Group):
|
||||
return {
|
||||
"groupname": obj.groupname,
|
||||
"members": [user.username for user in obj.members],
|
||||
}
|
||||
return JSONEncoder.default(self, obj)
|
||||
|
||||
|
||||
class UserResource(Resource):
|
||||
"""The UserResource is used for API access to users."""
|
||||
|
||||
def get(self, username):
|
||||
"""Returns the specified user in JSON format."""
|
||||
|
||||
_assert_is_authenticated()
|
||||
|
||||
try:
|
||||
conn = ldap.get_connection()
|
||||
except:
|
||||
return 500
|
||||
try:
|
||||
user = ldap.get_user(conn, username)
|
||||
except ldap.EntryNotFoundException:
|
||||
return {"message": f"User '{username}' does not exist."}, 400
|
||||
|
||||
return {
|
||||
"username": user.username,
|
||||
"password_hash": user.password_hash,
|
||||
"email": user.email,
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"display_name": user.display_name,
|
||||
"picture": user.get_picture_url(),
|
||||
}, 200
|
||||
|
||||
|
||||
class GroupResource(Resource):
|
||||
"""The GroupResource represents a Group object in the REST API."""
|
||||
|
||||
def get(self, groupname: str):
|
||||
"""Retrieves the group specified by the groupname as a JSON object.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
groupname : str
|
||||
The name of the group to be retrieved.
|
||||
|
||||
Returns
|
||||
-------
|
||||
json : str , status : int
|
||||
A JSON string and HTTP status code.
|
||||
"""
|
||||
|
||||
_assert_is_authenticated()
|
||||
|
||||
try:
|
||||
conn = ldap.get_connection()
|
||||
except:
|
||||
return 500
|
||||
try:
|
||||
group = ldap.get_group(conn, groupname)
|
||||
except ldap.EntryNotFoundException:
|
||||
return {"message": f"Group '{groupname}' does not exist."}, 404
|
||||
|
||||
return {
|
||||
"groupname": group.groupname,
|
||||
"members": [user.username for user in group.members],
|
||||
}, 200
|
||||
|
||||
|
||||
def post(self, groupname: str):
|
||||
"""Creates the specified Group with the members listed in the JSON data.
|
||||
|
||||
The request is expected to contain JSON data in the following format:
|
||||
{
|
||||
"members": [
|
||||
"alice",
|
||||
"bob",
|
||||
"charlie"
|
||||
]
|
||||
}
|
||||
|
||||
Returns
|
||||
-------
|
||||
json : str , status : int
|
||||
A JSON string and HTTP status code.
|
||||
"""
|
||||
|
||||
_assert_is_authenticated()
|
||||
|
||||
group_dict = request.get_json()
|
||||
if not isinstance(group_dict, dict):
|
||||
return {"message": f"Invalid format: expected an object but got: '{type(group_dict)}'."}, 400
|
||||
if len(group_dict.keys()) != 1:
|
||||
return {"message": "Invalid number of keys in Group object: expected exactly one key."}, 400
|
||||
if "members" not in group_dict.keys():
|
||||
return {"message": "Expected a key called 'members' in the object."}, 400
|
||||
|
||||
try:
|
||||
Group.assert_is_valid_groupname(groupname)
|
||||
except InvalidStringFormatException as e:
|
||||
return {"message": f"Invalid group name: {e}"}, 400
|
||||
|
||||
member_usernames = group_dict['members']
|
||||
if not isinstance(member_usernames, list):
|
||||
return {"message": "Expected the value for 'members' to be a list."}, 400
|
||||
if not len(member_usernames):
|
||||
return {"message": "Group must have at least one member."}, 400
|
||||
members = set()
|
||||
|
||||
try:
|
||||
conn = ldap.get_connection()
|
||||
except:
|
||||
return 500
|
||||
|
||||
for username in member_usernames:
|
||||
if not isinstance(username, str):
|
||||
conn.unbind()
|
||||
return {"message": "Member list may contain only strings."}, 400
|
||||
try:
|
||||
members.add(ldap.get_user(conn, username))
|
||||
except ldap.EntryNotFoundException:
|
||||
conn.unbind()
|
||||
return {"message": f"No such user: '{username}'."}, 400
|
||||
|
||||
group = Group(groupname, members)
|
||||
|
||||
try:
|
||||
# Make sure the requested group does not exist yet
|
||||
group = ldap.get_group(conn, group.groupname)
|
||||
conn.unbind()
|
||||
return {"message": f"Group '{group.groupname}' already exists."}, 400
|
||||
except ldap.EntryNotFoundException:
|
||||
pass
|
||||
|
||||
ldap.create_group(conn, group)
|
||||
conn.unbind()
|
||||
return {
|
||||
"groupname": group.groupname,
|
||||
"members": [user.username for user in group.members],
|
||||
}, 200
|
||||
|
||||
|
||||
def delete(self, groupname):
|
||||
"""Deletes the specified Group.
|
||||
|
||||
Returns
|
||||
-------
|
||||
json : str , status : int
|
||||
A JSON string and HTTP status code.
|
||||
"""
|
||||
|
||||
_assert_is_authenticated()
|
||||
|
||||
try:
|
||||
conn = ldap.get_connection()
|
||||
except:
|
||||
return 500
|
||||
|
||||
try:
|
||||
# Make sure the requested exists
|
||||
group = ldap.get_group(conn, groupname)
|
||||
except ldap.EntryNotFoundException:
|
||||
conn.unbind()
|
||||
return {"message": f"Group '{groupname}' does not exist."}, 404
|
||||
|
||||
ldap.delete_group(conn, groupname)
|
||||
conn.unbind()
|
||||
return None, 200
|
||||
|
||||
|
||||
class GroupMemberResource(Resource):
|
||||
"""This resource represents the member of a Group."""
|
||||
|
||||
def post(self, groupname: str, username: str):
|
||||
"""Adds the specified user to the specified Group.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
username : str
|
||||
The username of the User who will be added to the specified Group.
|
||||
groupname : str
|
||||
The name of the Group to which the specified User will be added.
|
||||
|
||||
Returns
|
||||
-------
|
||||
json : str , status : int
|
||||
A JSON string and HTTP status code.
|
||||
If the request was handled successfully, the POST-data is
|
||||
replied to the client and HTTP code 200 is returned.
|
||||
If a failure occurred while processing the request, an appropriate
|
||||
error message and HTTP error code are returned.
|
||||
"""
|
||||
|
||||
_assert_is_authenticated()
|
||||
|
||||
try:
|
||||
conn = ldap.get_connection()
|
||||
except:
|
||||
return 500
|
||||
|
||||
try:
|
||||
group = ldap.get_group(conn, groupname)
|
||||
except ldap.EntryNotFoundException:
|
||||
conn.unbind()
|
||||
return {"message": f"Group '{groupname}' does not exist."}, 404
|
||||
|
||||
try:
|
||||
user = ldap.get_user(conn, username)
|
||||
except ldap.EntryNotFoundException:
|
||||
conn.unbind()
|
||||
return {"message": f"User '{username}' does not exist."}, 404
|
||||
|
||||
if user in group.members:
|
||||
conn.unbind()
|
||||
return {"message": f"User '{username}' is already a member of the Group '{group.groupname}'."}, 400
|
||||
|
||||
group.members.add(user)
|
||||
ldap.update_group(conn, group)
|
||||
conn.unbind()
|
||||
return {
|
||||
"groupname": group.groupname,
|
||||
"members": [user.username for user in group.members],
|
||||
}, 200
|
||||
|
||||
|
||||
def delete(self, groupname: str, username: str):
|
||||
"""Removes the specified User from the specified Group.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
username : str
|
||||
The username of the User who will be removed from the specified Group.
|
||||
groupname : str
|
||||
The name of the Group from which the specified User will be removed.
|
||||
|
||||
Returns
|
||||
-------
|
||||
json : str , status : int
|
||||
A JSON string and HTTP status code.
|
||||
If the request was handled successfully, the POST-data is
|
||||
replied to the client and HTTP code 200 is returned.
|
||||
If a failure occurred while processing the request, an appropriate
|
||||
error message and HTTP error code are returned.
|
||||
"""
|
||||
|
||||
_assert_is_authenticated()
|
||||
|
||||
try:
|
||||
conn = ldap.get_connection()
|
||||
except:
|
||||
return 500
|
||||
|
||||
try:
|
||||
group = ldap.get_group(conn, groupname)
|
||||
except ldap.EntryNotFoundException:
|
||||
conn.unbind()
|
||||
return {"message": f"Group '{groupname}' does not exist."}, 404
|
||||
|
||||
try:
|
||||
user = ldap.get_user(conn, username)
|
||||
except ldap.EntryNotFoundException:
|
||||
conn.unbind()
|
||||
return {"message": f"User '{username}' does not exist."}, 404
|
||||
|
||||
if user not in group.members:
|
||||
conn.unbind()
|
||||
return {"message": f"User '{username}' is not a member of the Group '{group.groupname}'."}, 400
|
||||
|
||||
if len(group.members) == 1:
|
||||
conn.unbind()
|
||||
return {
|
||||
"message": f"Cannot remove user '{username}', because they are currently the only member " \
|
||||
f"of '{group.groupname}'. Empty groups are not permitted in LDAP, so either " \
|
||||
f"delete the group or add another user before removing '{username}'."
|
||||
}, 400
|
||||
|
||||
group.members.remove(user)
|
||||
ldap.update_group(conn, group)
|
||||
conn.unbind()
|
||||
return None, 200
|
428
package-lock.json
generated
Normal file
|
@ -0,0 +1,428 @@
|
|||
{
|
||||
"name": "lumi2",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "lumi2",
|
||||
"version": "1.0.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"bootstrap": "^5.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"sass": "^1.69.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@popperjs/core": {
|
||||
"version": "2.11.6",
|
||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.6.tgz",
|
||||
"integrity": "sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw==",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/popperjs"
|
||||
}
|
||||
},
|
||||
"node_modules/anymatch": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"normalize-path": "^3.0.0",
|
||||
"picomatch": "^2.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
|
||||
"integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/bootstrap": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.2.3.tgz",
|
||||
"integrity": "sha512-cEKPM+fwb3cT8NzQZYEu4HilJ3anCrWqh3CHAok1p9jXqMPsPTBhU25fBckEJHJ/p+tTxTFTsFQGM+gaHpi3QQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/twbs"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/bootstrap"
|
||||
}
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@popperjs/core": "^2.11.6"
|
||||
}
|
||||
},
|
||||
"node_modules/braces": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
|
||||
"integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"fill-range": "^7.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "3.5.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
|
||||
"integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"anymatch": "~3.1.2",
|
||||
"braces": "~3.0.2",
|
||||
"glob-parent": "~5.1.2",
|
||||
"is-binary-path": "~2.1.0",
|
||||
"is-glob": "~4.0.1",
|
||||
"normalize-path": "~3.0.0",
|
||||
"readdirp": "~3.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.10.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
|
||||
"integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"to-regex-range": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/glob-parent": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/immutable": {
|
||||
"version": "4.3.4",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.4.tgz",
|
||||
"integrity": "sha512-fsXeu4J4i6WNWSikpI88v/PcVflZz+6kMhUfIwc5SY+poQRPnaf5V7qds6SUyUN3cVxEzuCab7QIoLOQ+DQ1wA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/is-binary-path": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"binary-extensions": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-glob": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"is-extglob": "^2.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-number": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-path": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"picomatch": "^2.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sass": {
|
||||
"version": "1.69.7",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.69.7.tgz",
|
||||
"integrity": "sha512-rzj2soDeZ8wtE2egyLXgOOHQvaC2iosZrkF6v3EUG+tBwEvhqUCzm0VP3k9gHF9LXbSrRhT5SksoI56Iw8NPnQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"chokidar": ">=3.0.0 <4.0.0",
|
||||
"immutable": "^4.0.0",
|
||||
"source-map-js": ">=0.6.2 <2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"sass": "sass.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
|
||||
"integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"is-number": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@popperjs/core": {
|
||||
"version": "2.11.6",
|
||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.6.tgz",
|
||||
"integrity": "sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw==",
|
||||
"peer": true
|
||||
},
|
||||
"anymatch": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"normalize-path": "^3.0.0",
|
||||
"picomatch": "^2.0.4"
|
||||
}
|
||||
},
|
||||
"binary-extensions": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
|
||||
"integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
|
||||
"dev": true
|
||||
},
|
||||
"bootstrap": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.2.3.tgz",
|
||||
"integrity": "sha512-cEKPM+fwb3cT8NzQZYEu4HilJ3anCrWqh3CHAok1p9jXqMPsPTBhU25fBckEJHJ/p+tTxTFTsFQGM+gaHpi3QQ==",
|
||||
"requires": {}
|
||||
},
|
||||
"braces": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
|
||||
"integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"fill-range": "^7.0.1"
|
||||
}
|
||||
},
|
||||
"chokidar": {
|
||||
"version": "3.5.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
|
||||
"integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"anymatch": "~3.1.2",
|
||||
"braces": "~3.0.2",
|
||||
"fsevents": "~2.3.2",
|
||||
"glob-parent": "~5.1.2",
|
||||
"is-binary-path": "~2.1.0",
|
||||
"is-glob": "~4.0.1",
|
||||
"normalize-path": "~3.0.0",
|
||||
"readdirp": "~3.6.0"
|
||||
}
|
||||
},
|
||||
"fill-range": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
|
||||
"integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"to-regex-range": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"glob-parent": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"is-glob": "^4.0.1"
|
||||
}
|
||||
},
|
||||
"immutable": {
|
||||
"version": "4.3.4",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.4.tgz",
|
||||
"integrity": "sha512-fsXeu4J4i6WNWSikpI88v/PcVflZz+6kMhUfIwc5SY+poQRPnaf5V7qds6SUyUN3cVxEzuCab7QIoLOQ+DQ1wA==",
|
||||
"dev": true
|
||||
},
|
||||
"is-binary-path": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"binary-extensions": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||
"dev": true
|
||||
},
|
||||
"is-glob": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"is-extglob": "^2.1.1"
|
||||
}
|
||||
},
|
||||
"is-number": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||
"dev": true
|
||||
},
|
||||
"normalize-path": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
||||
"dev": true
|
||||
},
|
||||
"picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"dev": true
|
||||
},
|
||||
"readdirp": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"picomatch": "^2.2.1"
|
||||
}
|
||||
},
|
||||
"sass": {
|
||||
"version": "1.69.7",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.69.7.tgz",
|
||||
"integrity": "sha512-rzj2soDeZ8wtE2egyLXgOOHQvaC2iosZrkF6v3EUG+tBwEvhqUCzm0VP3k9gHF9LXbSrRhT5SksoI56Iw8NPnQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"chokidar": ">=3.0.0 <4.0.0",
|
||||
"immutable": "^4.0.0",
|
||||
"source-map-js": ">=0.6.2 <2.0.0"
|
||||
}
|
||||
},
|
||||
"source-map-js": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
|
||||
"integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==",
|
||||
"dev": true
|
||||
},
|
||||
"to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"is-number": "^7.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
21
package.json
Normal file
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"name": "lumi2",
|
||||
"version": "1.0.0",
|
||||
"description": "A minimalistic frontend for LDAP.",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"build": "sass scss/bootstrap.scss lumi2/static/css/bootstrap.css"
|
||||
},
|
||||
"author": "LUMI 2 Development Team",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"bootstrap": "^5.2.3"
|
||||
},
|
||||
"directories": {
|
||||
"doc": "docs",
|
||||
"test": "tests"
|
||||
},
|
||||
"devDependencies": {
|
||||
"sass": "^1.69.7"
|
||||
}
|
||||
}
|
|
@ -1,5 +1,13 @@
|
|||
Flask==2.2.2
|
||||
Werkzeug==2.2.2
|
||||
ldap3==2.9.1
|
||||
pytest==7.2.0
|
||||
coverage==6.5.0
|
||||
Pillow==9.3.0
|
||||
WTForms==3.0.1
|
||||
wtforms[email]==3.0.1
|
||||
Flask-WTF==1.0.1
|
||||
Flask-RESTful==0.3.9
|
||||
Faker==15.3.3
|
||||
requests==2.28.1
|
||||
waitress==2.1.2
|
||||
|
|
46
scss/bootstrap.scss
vendored
Normal file
|
@ -0,0 +1,46 @@
|
|||
// Colors
|
||||
$body-bg: #FCF7E3;
|
||||
$body-color: #3A3533;
|
||||
$link-color: #3890a9;
|
||||
|
||||
$primary: #72BAB0;
|
||||
$secondary: #C8C395;
|
||||
$success: #56BB80;
|
||||
$info: #799F93;
|
||||
$warning: #DBB263;
|
||||
$danger: #B9413C;
|
||||
$light: #FCFAF3;
|
||||
$dark: #234350;
|
||||
|
||||
// Fonts
|
||||
$font-family-base: Montserrat;
|
||||
|
||||
$font-weight-lighter: 300;
|
||||
$font-weight-light: 400;
|
||||
$font-weight-normal: 500;
|
||||
$font-weight-semibold: 600;
|
||||
$font-weight-bold: 700;
|
||||
$font-weight-bolder: 800;
|
||||
|
||||
$display-font-weight: 500;
|
||||
|
||||
// Borders
|
||||
$border-width: 2px;
|
||||
$border-widths: (
|
||||
1: 2px,
|
||||
2: 3px,
|
||||
3: 4px,
|
||||
4: 5px,
|
||||
5: 6px
|
||||
);
|
||||
$border-color: #DEDABF;
|
||||
|
||||
|
||||
// Forms
|
||||
$input-border-color: #A49E68;
|
||||
|
||||
// List groups
|
||||
$list-group-bg: $body-bg;
|
||||
$list-group-border-color: rgba($info, 0.5);
|
||||
|
||||
@import "../node_modules/bootstrap/scss/bootstrap";
|
20
shell.nix
Normal file
|
@ -0,0 +1,20 @@
|
|||
{ pkgs ? import <nixpkgs> {} }:
|
||||
pkgs.mkShell {
|
||||
nativeBuildInputs = with pkgs; [
|
||||
python311Packages.flask
|
||||
python311Packages.ldap3
|
||||
python311Packages.flask-wtf
|
||||
python311Packages.wtforms
|
||||
python311Packages.flask-restful
|
||||
python311Packages.pytest
|
||||
python311Packages.coverage
|
||||
python311Packages.pillow
|
||||
python311Packages.requests
|
||||
python311Packages.faker
|
||||
python311Packages.waitress
|
||||
];
|
||||
|
||||
#shellHook = ''
|
||||
# export MPLBACKEND=QtAgg
|
||||
#'';
|
||||
}
|
|
@ -98,7 +98,7 @@ def connection():
|
|||
- displayName (nickname)
|
||||
- mail (email)
|
||||
- jpegPhoto (profile picture)
|
||||
- password (sha512 hash of password 'test')
|
||||
- password (SSHA hash of password 'test')
|
||||
|
||||
Both groups are of type 'groupOfUniqueNames'. Alice is a member of both
|
||||
groups. Bob is a member of 'employees'.
|
||||
|
|
54
tests/fakedata.py
Normal file
|
@ -0,0 +1,54 @@
|
|||
"""Generates fake user accounts."""
|
||||
|
||||
from io import BytesIO
|
||||
from time import sleep
|
||||
|
||||
import requests
|
||||
from PIL import Image
|
||||
from faker import Faker
|
||||
|
||||
import lumi2.ldap as ldap
|
||||
|
||||
|
||||
def get_random_avatar() -> Image.Image:
|
||||
"""Returns a PIL JPEG Image of an AI-generated cat."""
|
||||
|
||||
url = "https://thispersondoesnotexist.com/"
|
||||
response = requests.get(url)
|
||||
if response.status_code != 200:
|
||||
raise RuntimeError(
|
||||
f"Request to '{url}' failed with code {response.status_code}."
|
||||
)
|
||||
|
||||
return Image.open(BytesIO(response.content))
|
||||
|
||||
|
||||
def generate_random_user():
|
||||
"""Generates a randomized user object and returns it."""
|
||||
from lumi2.usermodel import User
|
||||
|
||||
faker = Faker()
|
||||
|
||||
return User(
|
||||
faker.user_name(),
|
||||
User.generate_password_hash(faker.password()),
|
||||
faker.email(),
|
||||
faker.first_name(),
|
||||
faker.last_name(),
|
||||
picture=get_random_avatar(),
|
||||
)
|
||||
|
||||
|
||||
def populate_users(num_of_users: int = 10) -> None:
|
||||
"""Populates the DIT with the specified number of randomly generated users."""
|
||||
|
||||
conn = ldap.get_connection()
|
||||
for i in range(num_of_users):
|
||||
print(f"Creating a random user... ({i+1}/{num_of_users})")
|
||||
user = generate_random_user()
|
||||
ldap.create_user(conn, user)
|
||||
user._generate_static_images()
|
||||
# Delay to give 'thispersondoesnotexist.com' time to generate a new image
|
||||
if i < num_of_users - 1:
|
||||
sleep(1)
|
||||
conn.unbind()
|
|
@ -95,7 +95,7 @@
|
|||
"alice"
|
||||
],
|
||||
"userPassword": [
|
||||
"{SHA512}7iaw3Ur350mqGo7jwQrpkj9hiYB3Lkc/iBml1JQODbJ6wYX4oOHV+E+IvIh/1nsUNzLDBMxfqa2Ob1f1ACio/w=="
|
||||
"{SSHA}LitOJVWDVqqglNuHS0o1ypQjyd4TUP4K"
|
||||
]
|
||||
},
|
||||
"dn": "uid=alice,ou=users,dc=example,dc=com",
|
||||
|
@ -126,7 +126,7 @@
|
|||
"alice"
|
||||
],
|
||||
"userPassword": [
|
||||
"{SHA512}7iaw3Ur350mqGo7jwQrpkj9hiYB3Lkc/iBml1JQODbJ6wYX4oOHV+E+IvIh/1nsUNzLDBMxfqa2Ob1f1ACio/w=="
|
||||
"{SSHA}LitOJVWDVqqglNuHS0o1ypQjyd4TUP4K"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
@ -156,7 +156,7 @@
|
|||
"bobbuilder"
|
||||
],
|
||||
"userPassword": [
|
||||
"{SHA512}7iaw3Ur350mqGo7jwQrpkj9hiYB3Lkc/iBml1JQODbJ6wYX4oOHV+E+IvIh/1nsUNzLDBMxfqa2Ob1f1ACio/w=="
|
||||
"{SSHA}LitOJVWDVqqglNuHS0o1ypQjyd4TUP4K"
|
||||
]
|
||||
},
|
||||
"dn": "uid=bobbuilder,ou=users,dc=example,dc=com",
|
||||
|
@ -187,7 +187,7 @@
|
|||
"bobbuilder"
|
||||
],
|
||||
"userPassword": [
|
||||
"{SHA512}7iaw3Ur350mqGo7jwQrpkj9hiYB3Lkc/iBml1JQODbJ6wYX4oOHV+E+IvIh/1nsUNzLDBMxfqa2Ob1f1ACio/w=="
|
||||
"{SSHA}LitOJVWDVqqglNuHS0o1ypQjyd4TUP4K"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
|
|
@ -322,6 +322,23 @@ def test_delete_group(app, connection):
|
|||
ll.delete_group(connection, "employees")
|
||||
|
||||
|
||||
def test_update_group(app, connection):
|
||||
with app.app_context():
|
||||
employees = ll.get_group(connection, "employees")
|
||||
user0 = lu.User(
|
||||
username="user0",
|
||||
password_hash=lu.User.generate_password_hash("password"),
|
||||
email="valid@example.com",
|
||||
first_name="Test",
|
||||
last_name="User",
|
||||
)
|
||||
employees.members.add(user0)
|
||||
with pytest.raises(ll.EntryNotFoundException):
|
||||
ll.update_group(connection, employees)
|
||||
ll.create_user(connection, user0)
|
||||
ll.update_group(connection, employees)
|
||||
assert user0 in ll.get_group(connection, "employees").members
|
||||
|
||||
|
||||
def test_get_group(app, connection):
|
||||
with app.app_context():
|
||||
|
|