Compare commits
296 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f04530700 | ||
|
|
caf67fdf2b | ||
|
|
034794d58d | ||
|
|
e53ce92c96 | ||
|
|
630ac5fd8c | ||
|
|
b269fa0fc7 | ||
|
|
208cc7192e | ||
|
|
80e9145a4f | ||
|
|
78d370d3f4 | ||
|
|
f279a14693 | ||
|
|
b54bf3c4d5 | ||
|
|
582abba793 | ||
|
|
94da42ffb9 | ||
|
|
08d3aef177 | ||
|
|
7671b61a6b | ||
|
|
47b308f9b7 | ||
|
|
1a5931c3df | ||
|
|
b3d771e063 | ||
|
|
134796aa9f | ||
|
|
1598f096e9 | ||
|
|
99ed6eface | ||
|
|
ce6bf7c548 | ||
|
|
5677ff798f | ||
|
|
e47004097a | ||
|
|
5e3a4f3446 | ||
|
|
8e61ee60d5 | ||
|
|
a426453d7f | ||
|
|
1ac9bd0e68 | ||
|
|
a83c305e51 | ||
|
|
7b171cf59a | ||
|
|
b237c71b99 | ||
|
|
2eff37684d | ||
|
|
836823a5cd | ||
|
|
e1d4df0b04 | ||
|
|
70bbe7f5ad | ||
|
|
6d796df097 | ||
|
|
6cd6b412fe | ||
|
|
042429a11d | ||
|
|
c440df631f | ||
|
|
3247ffc8ea | ||
|
|
ef17c280b1 | ||
|
|
d0cdfa97c7 | ||
|
|
f0bbcfd2c8 | ||
|
|
08b7c6ce33 | ||
|
|
719708dfd0 | ||
|
|
b82cb83318 | ||
|
|
d9f4adcb0e | ||
|
|
e5bc06a617 | ||
|
|
af49871801 | ||
|
|
7d1f5abc13 | ||
|
|
31a8ba24a0 | ||
|
|
9e1b58d033 | ||
|
|
1acc8cd78c | ||
|
|
3140af63de | ||
|
|
829ebf59f7 | ||
|
|
4ce145bac2 | ||
|
|
6ef229f3d0 | ||
|
|
19b4fd520a | ||
|
|
70146e0b70 | ||
|
|
a804368806 | ||
|
|
3ec42fffaa | ||
|
|
95727335a7 | ||
|
|
79f9a3a5c2 | ||
|
|
7daebc308b | ||
|
|
50017cff36 | ||
|
|
f812c9e666 | ||
|
|
87a35af693 | ||
|
|
4c4a397f66 | ||
|
|
d720a7812a | ||
|
|
d2dec56cca | ||
|
|
ab2da7b975 | ||
|
|
8f69e4badd | ||
|
|
5bd00f24a2 | ||
|
|
ab9ee8d962 | ||
|
|
852e1586e7 | ||
|
|
23b388f3b8 | ||
|
|
22ae2c7124 | ||
|
|
5ad63d31d3 | ||
|
|
d55d4487ed | ||
|
|
4283d27da6 | ||
|
|
4576cf9f2c | ||
|
|
d1d5d38b32 | ||
|
|
e5ce98c874 | ||
|
|
96b7dbb1c5 | ||
|
|
9408b12bc7 | ||
|
|
4e85a4718f | ||
|
|
d1f1eb8e80 | ||
|
|
da364746c4 | ||
|
|
d672f68049 | ||
|
|
dcca768b6c | ||
|
|
ea69b4bead | ||
|
|
7b4188a376 | ||
|
|
252132430c | ||
|
|
7f9bc95c5c | ||
|
|
69fca82a86 | ||
|
|
9a30cac7b0 | ||
|
|
558bb37354 | ||
|
|
5b74852193 | ||
|
|
d18cf1ac37 | ||
|
|
96f55ff28e | ||
|
|
825f37d360 | ||
|
|
8eb27c5267 | ||
|
|
18d9dd6ff9 | ||
|
|
308521c632 | ||
|
|
86b2b5148d | ||
|
|
b9e0e4a6dc | ||
|
|
1b8849ead1 | ||
|
|
1fe635384f | ||
|
|
df16d66753 | ||
|
|
65e2c24928 | ||
|
|
c4b8621e2a | ||
|
|
88a9f8a97b | ||
|
|
fc91d59b99 | ||
|
|
aad4711056 | ||
|
|
c7c6d95334 | ||
|
|
84b4c66309 | ||
|
|
923d77072b | ||
|
|
758aa7f7f7 | ||
|
|
866a74fa29 | ||
|
|
36a51070b3 | ||
|
|
585b65e11d | ||
|
|
2c8fe2a481 | ||
|
|
1b67bad270 | ||
|
|
afe91c7cc0 | ||
|
|
bd1b7e8809 | ||
|
|
ae9b04d4d2 | ||
|
|
bd6184554a | ||
|
|
834d68a47e | ||
|
|
05dbe6818d | ||
|
|
80dfeb1293 | ||
|
|
bf64c091cc | ||
|
|
b4d7ada317 | ||
|
|
a07f7ac389 | ||
|
|
46b8f2a8a5 | ||
|
|
91ada70c7d | ||
|
|
b2cfc0ed03 | ||
|
|
8d44717588 | ||
|
|
f44e8b7659 | ||
|
|
07523219d1 | ||
|
|
7f76e2095d | ||
|
|
313fe3e0b7 | ||
|
|
c817b31dfc | ||
|
|
9e038f5218 | ||
|
|
9e479d38fe | ||
|
|
2593606f16 | ||
|
|
1b91cc8ac2 | ||
|
|
28607c4744 | ||
|
|
dce73f91ef | ||
|
|
07de6062ca | ||
|
|
c9997d4c17 | ||
|
|
322bf26db5 | ||
|
|
98acd68f06 | ||
|
|
733f990858 | ||
|
|
bebb00aa2e | ||
|
|
193a0fd710 | ||
|
|
3650a438df | ||
|
|
5bee73180d | ||
|
|
672dd96e7e | ||
|
|
62104b417a | ||
|
|
562ad524c4 | ||
|
|
ea498df78b | ||
|
|
1ce239103c | ||
|
|
81036943c2 | ||
|
|
21e51c3d38 | ||
|
|
e92947fc3b | ||
|
|
94d45f7320 | ||
|
|
d04305433f | ||
|
|
63cbf30dd7 | ||
|
|
96eb17a963 | ||
|
|
8f2c5b397c | ||
|
|
648848c816 | ||
|
|
58b9c28a0b | ||
|
|
c3d18dbbe8 | ||
|
|
1e6a0edcfb | ||
|
|
d56de80381 | ||
|
|
3fa100be0c | ||
|
|
df1169e06d | ||
|
|
0ae1597ecd | ||
|
|
d722be8896 | ||
|
|
9018e6fa34 | ||
|
|
807fd10d13 | ||
|
|
f979e16b95 | ||
|
|
955a559c21 | ||
|
|
e458aca3e3 | ||
|
|
692bbb00f1 | ||
|
|
260b545a54 | ||
|
|
3a43b7a4c2 | ||
|
|
c87adfeecc | ||
|
|
d7cc10fa00 | ||
|
|
14531fa258 | ||
|
|
1e5603dce2 | ||
|
|
c64d32e2c0 | ||
|
|
665e525f0a | ||
|
|
09a0522e2d | ||
|
|
e89b1538af | ||
|
|
a3a27f0049 | ||
|
|
a4408cfacc | ||
|
|
a3216a4550 | ||
|
|
2668ea4553 | ||
|
|
dd7e392626 | ||
|
|
80fc94c4db | ||
|
|
ffc59af345 | ||
|
|
03ad10dfc5 | ||
|
|
eb26019a52 | ||
|
|
69d0308f46 | ||
|
|
ba0dc33583 | ||
|
|
e0c0efcb2f | ||
|
|
e3b1810229 | ||
|
|
e81c87f288 | ||
|
|
234cb70b97 | ||
|
|
201e3a93eb | ||
|
|
27144ee37e | ||
|
|
2477439ecc | ||
|
|
ff66e918cf | ||
|
|
ee7dc39afa | ||
|
|
4c69f917e7 | ||
|
|
8d19678e39 | ||
|
|
bf42517077 | ||
|
|
35aa656677 | ||
|
|
0be440efc8 | ||
|
|
eefe65c042 | ||
|
|
a42a532929 | ||
|
|
3bb07db63f | ||
|
|
32850d4ff9 | ||
|
|
92178d2e77 | ||
|
|
d592b10c87 | ||
|
|
188a92d124 | ||
|
|
3aaf53442b | ||
|
|
01d4b6e1fc | ||
|
|
a2dfca0e37 | ||
|
|
b3f64c6efe | ||
|
|
32f28d664e | ||
|
|
412f4fa644 | ||
|
|
4ffa565e51 | ||
|
|
2f9ea4f10f | ||
|
|
123fdc5baf | ||
|
|
5402aa5aa2 | ||
|
|
8069516283 | ||
|
|
6c21f2ef4b | ||
|
|
516893f1f7 | ||
|
|
1660cb1fbb | ||
|
|
7e1ce10df1 | ||
|
|
b6ee918ca9 | ||
|
|
24efd61464 | ||
|
|
0b6b274cfa | ||
|
|
8b01271e94 | ||
|
|
d536addf0a | ||
|
|
2ca083541e | ||
|
|
686bdc0cb1 | ||
|
|
60c594438c | ||
|
|
b130965264 | ||
|
|
697a64991d | ||
|
|
3acc448048 | ||
|
|
0e3c5120da | ||
|
|
7707367c35 | ||
|
|
122e08790f | ||
|
|
64556fc744 | ||
|
|
134a9366f5 | ||
|
|
f69b729eb2 | ||
|
|
2ac47d5c85 | ||
|
|
26d3d84de0 | ||
|
|
b413935932 | ||
|
|
e6ae726304 | ||
|
|
520277b611 | ||
|
|
8cdfedddbd | ||
|
|
5312400a3f | ||
|
|
551f5abc4b | ||
|
|
10d826fc46 | ||
|
|
252bd6cf39 | ||
|
|
ba44dea7b6 | ||
|
|
b9c823e01a | ||
|
|
c108921dcf | ||
|
|
36eed1e091 | ||
|
|
897704fab3 | ||
|
|
9f70910283 | ||
|
|
3e3c9b97ae | ||
|
|
8c1ea11b95 | ||
|
|
cd0ab378ef | ||
|
|
5a27ae4862 | ||
|
|
05719642ca | ||
|
|
5c584536b5 | ||
|
|
4ba0db4e9e | ||
|
|
5e4ed9ee17 | ||
|
|
c399ff2bfa | ||
|
|
9e37a06514 | ||
|
|
294ce77a47 | ||
|
|
24c6b4a879 | ||
|
|
2c2696a8c3 | ||
|
|
479d1e7635 | ||
|
|
3a723460e5 | ||
|
|
8011756658 | ||
|
|
46546dac27 | ||
|
|
9a869a1474 | ||
|
|
09797695aa | ||
|
|
4f2cf45427 | ||
|
|
901eb7f469 |
2
.config/nextest.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[profile.default]
|
||||
fail-fast = false
|
||||
26
.devcontainer/Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
||||
FROM rust:1.72
|
||||
|
||||
ARG USERNAME=lldapdev
|
||||
# We need to keep the user as 1001 to match the GitHub runner's UID.
|
||||
# See https://github.com/actions/checkout/issues/956.
|
||||
ARG USER_UID=1001
|
||||
ARG USER_GID=$USER_UID
|
||||
|
||||
# Create the user
|
||||
RUN groupadd --gid $USER_GID $USERNAME \
|
||||
&& useradd --uid $USER_UID --gid $USER_GID -m $USERNAME \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y sudo \
|
||||
&& echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME \
|
||||
&& chmod 0440 /etc/sudoers.d/$USERNAME
|
||||
|
||||
RUN apt update && \
|
||||
apt install -y --no-install-recommends libssl-dev musl-dev make perl curl gzip && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN RUSTFLAGS=-Ctarget-feature=-crt-static cargo install wasm-pack \
|
||||
&& rustup target add wasm32-unknown-unknown
|
||||
|
||||
USER $USERNAME
|
||||
ENV CARGO_HOME=/home/$USERNAME/.cargo
|
||||
ENV SHELL=/bin/bash
|
||||
8
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "LLDAP dev",
|
||||
"build": { "dockerfile": "Dockerfile" },
|
||||
"forwardPorts": [
|
||||
3890,
|
||||
17170
|
||||
]
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
.git/*
|
||||
.github/*
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# Don't track cargo generated files
|
||||
target/*
|
||||
@@ -17,6 +18,7 @@ Dockerfile
|
||||
*.md
|
||||
LICENSE
|
||||
CHANGELOG.md
|
||||
README.md
|
||||
docs/*
|
||||
example_configs/*
|
||||
|
||||
@@ -28,12 +30,24 @@ package.json
|
||||
# Pre-build binaries
|
||||
*.tar.gz
|
||||
|
||||
# VSCode dirs
|
||||
.vscode
|
||||
.devcontainer
|
||||
|
||||
# Created databases
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
# These are backup files generated by rustfmt
|
||||
**/*.rs.bk
|
||||
|
||||
# Various config files that shouldn't be tracked
|
||||
.env
|
||||
lldap_config.toml
|
||||
server_key
|
||||
users.db*
|
||||
screenshot.png
|
||||
recipe.json
|
||||
lldap_config.toml
|
||||
cert.pem
|
||||
key.pem
|
||||
|
||||
10
.gitattributes
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
example_configs/** linguist-documentation
|
||||
docs/** linguist-documentation
|
||||
*.md linguist-documentation
|
||||
lldap_config.docker_template.toml linguist-documentation
|
||||
|
||||
schema.graphql linguist-generated
|
||||
|
||||
.github/** -linguist-detectable
|
||||
.devcontainer/** -linguist-detectable
|
||||
.config/** -linguist-detectable
|
||||
1
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* @nitnelave
|
||||
5
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [lldap]
|
||||
|
||||
custom: ['https://bmc.link/nitnelave']
|
||||
2
.github/codecov.yml
vendored
@@ -10,3 +10,5 @@ ignore:
|
||||
- "docs"
|
||||
- "example_configs"
|
||||
- "migration-tool"
|
||||
- "scripts"
|
||||
- "set-password"
|
||||
|
||||
68
.github/workflows/Dockerfile.ci
vendored
@@ -1,68 +0,0 @@
|
||||
FROM debian:bullseye AS lldap
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
ARG TARGETPLATFORM
|
||||
RUN apt update && apt install -y wget
|
||||
WORKDIR /dim
|
||||
COPY bin/ bin/
|
||||
COPY web/ web/
|
||||
|
||||
RUN mkdir -p target/
|
||||
RUN mkdir -p /lldap/app
|
||||
|
||||
RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \
|
||||
mv bin/amd64-bin/lldap target/lldap && \
|
||||
mv bin/amd64-bin/migration-tool target/migration-tool && \
|
||||
chmod +x target/lldap && \
|
||||
chmod +x target/migration-tool && \
|
||||
ls -la target/ . && \
|
||||
pwd \
|
||||
; fi
|
||||
|
||||
RUN if [ "${TARGETPLATFORM}" = "linux/arm64" ]; then \
|
||||
mv bin/aarch64-bin/lldap target/lldap && \
|
||||
mv bin/aarch64-bin/migration-tool target/migration-tool && \
|
||||
chmod +x target/lldap && \
|
||||
chmod +x target/migration-tool && \
|
||||
ls -la target/ . && \
|
||||
pwd \
|
||||
; fi
|
||||
|
||||
RUN if [ "${TARGETPLATFORM}" = "linux/arm/v7" ]; then \
|
||||
mv bin/armhf-bin/lldap target/lldap && \
|
||||
mv bin/armhf-bin/migration-tool target/migration-tool && \
|
||||
chmod +x target/lldap && \
|
||||
chmod +x target/migration-tool && \
|
||||
ls -la target/ . && \
|
||||
pwd \
|
||||
; fi
|
||||
|
||||
# Web and App dir
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
COPY lldap_config.docker_template.toml /lldap/
|
||||
RUN cp target/lldap /lldap/ && \
|
||||
cp target/migration-tool /lldap/ && \
|
||||
cp -R web/index.html \
|
||||
web/pkg \
|
||||
web/static \
|
||||
/lldap/app/
|
||||
|
||||
RUN set -x \
|
||||
&& for file in $(cat /lldap/app/static/libraries.txt); do wget -P app/static "$file"; done \
|
||||
&& for file in $(cat /lldap/app/static/fonts/fonts.txt); do wget -P app/static/fonts "$file"; done \
|
||||
&& chmod a+r -R .
|
||||
|
||||
FROM debian:bullseye
|
||||
ENV UID=1000
|
||||
ENV GID=1000
|
||||
ENV USER=lldap
|
||||
RUN apt update && \
|
||||
apt install -y --no-install-recommends tini ca-certificates && \
|
||||
apt clean && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
groupadd -g $GID $USER && useradd --system -m -g $USER --uid $UID $USER
|
||||
COPY --from=lldap --chown=$CONTAINERUSER:$CONTAINERUSER /lldap /app
|
||||
COPY --from=lldap --chown=$CONTAINERUSER:$CONTAINERUSER /docker-entrypoint.sh /docker-entrypoint.sh
|
||||
WORKDIR /app
|
||||
USER $USER
|
||||
ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"]
|
||||
CMD ["run", "--config-file", "/data/lldap_config.toml"]
|
||||
114
.github/workflows/Dockerfile.ci.alpine
vendored
Normal file
@@ -0,0 +1,114 @@
|
||||
FROM debian:bullseye AS lldap
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
ARG TARGETPLATFORM
|
||||
RUN apt update && apt install -y wget
|
||||
WORKDIR /dim
|
||||
COPY bin/ bin/
|
||||
COPY web/ web/
|
||||
|
||||
RUN mkdir -p target/
|
||||
RUN mkdir -p /lldap/app
|
||||
|
||||
RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \
|
||||
mv bin/x86_64-unknown-linux-musl-lldap-bin/lldap target/lldap && \
|
||||
mv bin/x86_64-unknown-linux-musl-lldap_migration_tool-bin/lldap_migration_tool target/lldap_migration_tool && \
|
||||
mv bin/x86_64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
|
||||
chmod +x target/lldap && \
|
||||
chmod +x target/lldap_migration_tool && \
|
||||
chmod +x target/lldap_set_password && \
|
||||
ls -la target/ . && \
|
||||
pwd \
|
||||
; fi
|
||||
|
||||
RUN if [ "${TARGETPLATFORM}" = "linux/arm64" ]; then \
|
||||
mv bin/aarch64-unknown-linux-musl-lldap-bin/lldap target/lldap && \
|
||||
mv bin/aarch64-unknown-linux-musl-lldap_migration_tool-bin/lldap_migration_tool target/lldap_migration_tool && \
|
||||
mv bin/aarch64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
|
||||
chmod +x target/lldap && \
|
||||
chmod +x target/lldap_migration_tool && \
|
||||
chmod +x target/lldap_set_password && \
|
||||
ls -la target/ . && \
|
||||
pwd \
|
||||
; fi
|
||||
|
||||
RUN if [ "${TARGETPLATFORM}" = "linux/arm/v7" ]; then \
|
||||
mv bin/armv7-unknown-linux-musleabihf-lldap-bin/lldap target/lldap && \
|
||||
mv bin/armv7-unknown-linux-musleabihf-lldap_migration_tool-bin/lldap_migration_tool target/lldap_migration_tool && \
|
||||
mv bin/armv7-unknown-linux-musleabihf-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
|
||||
chmod +x target/lldap && \
|
||||
chmod +x target/lldap_migration_tool && \
|
||||
chmod +x target/lldap_set_password && \
|
||||
ls -la target/ . && \
|
||||
pwd \
|
||||
; fi
|
||||
|
||||
# Web and App dir
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
COPY lldap_config.docker_template.toml /lldap/
|
||||
COPY web/index_local.html web/index.html
|
||||
RUN cp target/lldap /lldap/ && \
|
||||
cp target/lldap_migration_tool /lldap/ && \
|
||||
cp target/lldap_set_password /lldap/ && \
|
||||
cp -R web/index.html \
|
||||
web/pkg \
|
||||
web/static \
|
||||
/lldap/app/
|
||||
|
||||
WORKDIR /lldap
|
||||
RUN set -x \
|
||||
&& for file in $(cat /lldap/app/static/libraries.txt); do wget -P app/static "$file"; done \
|
||||
&& for file in $(cat /lldap/app/static/fonts/fonts.txt); do wget -P app/static/fonts "$file"; done \
|
||||
&& chmod a+r -R .
|
||||
|
||||
FROM alpine:3.16
|
||||
WORKDIR /app
|
||||
ENV UID=1000
|
||||
ENV GID=1000
|
||||
ENV USER=lldap
|
||||
ENV GOSU_VERSION 1.14
|
||||
# Fetch gosu from git
|
||||
RUN set -eux; \
|
||||
\
|
||||
apk add --no-cache --virtual .gosu-deps \
|
||||
ca-certificates \
|
||||
dpkg \
|
||||
gnupg \
|
||||
; \
|
||||
\
|
||||
dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')"; \
|
||||
wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch"; \
|
||||
wget -O /usr/local/bin/gosu.asc "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch.asc"; \
|
||||
\
|
||||
# verify the signature
|
||||
export GNUPGHOME="$(mktemp -d)"; \
|
||||
gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; \
|
||||
gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \
|
||||
command -v gpgconf && gpgconf --kill all || :; \
|
||||
rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \
|
||||
\
|
||||
# clean up fetch dependencies
|
||||
apk del --no-network .gosu-deps; \
|
||||
\
|
||||
chmod +x /usr/local/bin/gosu; \
|
||||
# verify that the binary works
|
||||
gosu --version; \
|
||||
gosu nobody true
|
||||
RUN apk add --no-cache tini ca-certificates bash tzdata && \
|
||||
addgroup -g $GID $USER && \
|
||||
adduser \
|
||||
--disabled-password \
|
||||
--gecos "" \
|
||||
--home "$(pwd)" \
|
||||
--ingroup "$USER" \
|
||||
--no-create-home \
|
||||
--uid "$UID" \
|
||||
"$USER" && \
|
||||
mkdir -p /data && \
|
||||
chown $USER:$USER /data
|
||||
COPY --from=lldap --chown=$USER:$USER /lldap /app
|
||||
COPY --from=lldap --chown=$USER:$USER /docker-entrypoint.sh /docker-entrypoint.sh
|
||||
VOLUME ["/data"]
|
||||
WORKDIR /app
|
||||
ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"]
|
||||
CMD ["run", "--config-file", "/data/lldap_config.toml"]
|
||||
HEALTHCHECK CMD ["/app/lldap", "healthcheck", "--config-file", "/data/lldap_config.toml"]
|
||||
79
.github/workflows/Dockerfile.ci.debian
vendored
Normal file
@@ -0,0 +1,79 @@
|
||||
FROM debian:bullseye AS lldap
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
ARG TARGETPLATFORM
|
||||
RUN apt update && apt install -y wget
|
||||
WORKDIR /dim
|
||||
COPY bin/ bin/
|
||||
COPY web/ web/
|
||||
|
||||
RUN mkdir -p target/
|
||||
RUN mkdir -p /lldap/app
|
||||
|
||||
RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \
|
||||
mv bin/x86_64-unknown-linux-musl-lldap-bin/lldap target/lldap && \
|
||||
mv bin/x86_64-unknown-linux-musl-lldap_migration_tool-bin/lldap_migration_tool target/lldap_migration_tool && \
|
||||
mv bin/x86_64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
|
||||
chmod +x target/lldap && \
|
||||
chmod +x target/lldap_migration_tool && \
|
||||
chmod +x target/lldap_set_password && \
|
||||
ls -la target/ . && \
|
||||
pwd \
|
||||
; fi
|
||||
|
||||
RUN if [ "${TARGETPLATFORM}" = "linux/arm64" ]; then \
|
||||
mv bin/aarch64-unknown-linux-musl-lldap-bin/lldap target/lldap && \
|
||||
mv bin/aarch64-unknown-linux-musl-lldap_migration_tool-bin/lldap_migration_tool target/lldap_migration_tool && \
|
||||
mv bin/aarch64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
|
||||
chmod +x target/lldap && \
|
||||
chmod +x target/lldap_migration_tool && \
|
||||
chmod +x target/lldap_set_password && \
|
||||
ls -la target/ . && \
|
||||
pwd \
|
||||
; fi
|
||||
|
||||
RUN if [ "${TARGETPLATFORM}" = "linux/arm/v7" ]; then \
|
||||
mv bin/armv7-unknown-linux-musleabihf-lldap-bin/lldap target/lldap && \
|
||||
mv bin/armv7-unknown-linux-musleabihf-lldap_migration_tool-bin/lldap_migration_tool target/lldap_migration_tool && \
|
||||
mv bin/armv7-unknown-linux-musleabihf-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
|
||||
chmod +x target/lldap && \
|
||||
chmod +x target/lldap_migration_tool && \
|
||||
chmod +x target/lldap_set_password && \
|
||||
ls -la target/ . && \
|
||||
pwd \
|
||||
; fi
|
||||
|
||||
# Web and App dir
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
COPY lldap_config.docker_template.toml /lldap/
|
||||
COPY web/index_local.html web/index.html
|
||||
RUN cp target/lldap /lldap/ && \
|
||||
cp target/lldap_migration_tool /lldap/ && \
|
||||
cp target/lldap_set_password /lldap/ && \
|
||||
cp -R web/index.html \
|
||||
web/pkg \
|
||||
web/static \
|
||||
/lldap/app/
|
||||
|
||||
WORKDIR /lldap
|
||||
RUN set -x \
|
||||
&& for file in $(cat /lldap/app/static/libraries.txt); do wget -P app/static "$file"; done \
|
||||
&& for file in $(cat /lldap/app/static/fonts/fonts.txt); do wget -P app/static/fonts "$file"; done \
|
||||
&& chmod a+r -R .
|
||||
|
||||
FROM debian:bullseye-slim
|
||||
ENV UID=1000
|
||||
ENV GID=1000
|
||||
ENV USER=lldap
|
||||
RUN apt update && \
|
||||
apt install -y --no-install-recommends tini openssl ca-certificates gosu tzdata && \
|
||||
apt clean && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
groupadd -g $GID $USER && useradd --system -m -g $USER --uid $UID $USER && \
|
||||
mkdir -p /data && chown $USER:$USER /data
|
||||
COPY --from=lldap --chown=$USER:$USER /lldap /app
|
||||
COPY --from=lldap --chown=$USER:$USER /docker-entrypoint.sh /docker-entrypoint.sh
|
||||
VOLUME ["/data"]
|
||||
WORKDIR /app
|
||||
ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"]
|
||||
CMD ["run", "--config-file", "/data/lldap_config.toml"]
|
||||
HEALTHCHECK CMD ["/app/lldap", "healthcheck", "--config-file", "/data/lldap_config.toml"]
|
||||
40
.github/workflows/Dockerfile.dev
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
# Keep tracking base image
|
||||
FROM rust:1.71-slim-bookworm
|
||||
|
||||
# Set needed env path
|
||||
ENV PATH="/opt/armv7l-linux-musleabihf-cross/:/opt/armv7l-linux-musleabihf-cross/bin/:/opt/aarch64-linux-musl-cross/:/opt/aarch64-linux-musl-cross/bin/:/opt/x86_64-linux-musl-cross/:/opt/x86_64-linux-musl-cross/bin/:$PATH"
|
||||
|
||||
# Set building env
|
||||
ENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse \
|
||||
CARGO_NET_GIT_FETCH_WITH_CLI=true \
|
||||
CARGO_TARGET_ARMV7_UNKNOWN_LINUX_MUSLEABIHF_LINKER=armv7l-linux-musleabihf-gcc \
|
||||
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-linux-musl-gcc \
|
||||
CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER=x86_64-linux-musl-gcc \
|
||||
CC_armv7_unknown_linux_musleabihf=armv7l-linux-musleabihf-gcc \
|
||||
CC_x86_64_unknown_linux_musl=x86_64-linux-musl-gcc \
|
||||
CC_aarch64_unknown_linux_musl=aarch64-linux-musl-gcc
|
||||
|
||||
### Install Additional Build Tools
|
||||
RUN apt update && \
|
||||
apt install -y --no-install-recommends curl git wget make perl pkg-config tar jq gzip && \
|
||||
apt clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
### Add musl-gcc aarch64, x86_64 and armv7l
|
||||
RUN wget -c https://musl.cc/x86_64-linux-musl-cross.tgz && \
|
||||
tar zxf ./x86_64-linux-musl-cross.tgz -C /opt && \
|
||||
wget -c https://musl.cc/aarch64-linux-musl-cross.tgz && \
|
||||
tar zxf ./aarch64-linux-musl-cross.tgz -C /opt && \
|
||||
wget -c http://musl.cc/armv7l-linux-musleabihf-cross.tgz && \
|
||||
tar zxf ./armv7l-linux-musleabihf-cross.tgz -C /opt && \
|
||||
rm ./x86_64-linux-musl-cross.tgz && \
|
||||
rm ./aarch64-linux-musl-cross.tgz && \
|
||||
rm ./armv7l-linux-musleabihf-cross.tgz
|
||||
|
||||
### Add musl target
|
||||
RUN rustup target add x86_64-unknown-linux-musl && \
|
||||
rustup target add aarch64-unknown-linux-musl && \
|
||||
rustup target add armv7-unknown-linux-musleabihf
|
||||
|
||||
|
||||
CMD ["bash"]
|
||||
653
.github/workflows/docker-build-static.yml
vendored
Normal file
@@ -0,0 +1,653 @@
|
||||
name: Docker Static
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- 'example_configs/**'
|
||||
release:
|
||||
types:
|
||||
- 'published'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- 'example_configs/**'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
msg:
|
||||
description: "Set message"
|
||||
default: "Manual trigger"
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
|
||||
### CI Docs
|
||||
|
||||
# build-ui , create/compile the web
|
||||
### install wasm
|
||||
### run app/build.sh
|
||||
### upload artifacts
|
||||
|
||||
# build-bin
|
||||
## build-armhf, build-aarch64, build-amd64 , create binary for respective arch
|
||||
#######################################################################################
|
||||
# GitHub actions randomly timeout when downloading musl-gcc, using custom dev image #
|
||||
# Look into .github/workflows/Dockerfile.dev for development image details #
|
||||
# Using lldap dev image based on https://hub.docker.com/_/rust and musl-gcc bundled #
|
||||
# lldap/rust-dev:latest #
|
||||
#######################################################################################
|
||||
# Cargo build
|
||||
### armv7, aarch64 and amd64 is musl based
|
||||
|
||||
# build-ui,builds-armhf, build-aarch64, build-amd64 will upload artifacts will be used next job
|
||||
|
||||
# lldap-test
|
||||
### will run lldap with postgres, mariadb and sqlite backend, do selfcheck command.
|
||||
|
||||
# Build docker image
|
||||
### Triplet docker image arch with debian and alpine base
|
||||
# build-docker-image job will fetch artifacts and run Dockerfile.ci then push the image.
|
||||
### Look into .github/workflows/Dockerfile.ci.debian or .github/workflowds/Dockerfile.ci.alpine
|
||||
|
||||
# Create release artifacts
|
||||
### Fetch artifacts
|
||||
### Clean up web artifact
|
||||
### Setup folder structure
|
||||
### Compress
|
||||
### Upload
|
||||
|
||||
# cache based on Cargo.lock per cargo target
|
||||
|
||||
jobs:
|
||||
pre_job:
|
||||
continue-on-error: true
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
should_skip: ${{ steps.skip_check.outputs.should_skip }}
|
||||
steps:
|
||||
- id: skip_check
|
||||
uses: fkirc/skip-duplicate-actions@master
|
||||
with:
|
||||
concurrent_skipping: 'outdated_runs'
|
||||
skip_after_successful_duplicate: ${{ github.ref != 'refs/heads/main' }}
|
||||
paths_ignore: '["**/*.md", "**/docs/**", "example_configs/**", "*.sh", ".gitignore", "lldap_config.docker_template.toml"]'
|
||||
do_not_skip: '["workflow_dispatch", "schedule"]'
|
||||
cancel_others: true
|
||||
|
||||
build-ui:
|
||||
runs-on: ubuntu-latest
|
||||
needs: pre_job
|
||||
if: ${{ needs.pre_job.outputs.should_skip != 'true' || github.event_name == 'release' }}
|
||||
container:
|
||||
image: lldap/rust-dev:latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4.0.0
|
||||
- uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
/usr/local/cargo/bin
|
||||
/usr/local/cargo/registry/index
|
||||
/usr/local/cargo/registry/cache
|
||||
/usr/local/cargo/git/db
|
||||
target
|
||||
key: lldap-ui-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
lldap-ui-
|
||||
- name: Add wasm target (rust)
|
||||
run: rustup target add wasm32-unknown-unknown
|
||||
- name: Install wasm-pack with cargo
|
||||
run: cargo install wasm-pack || true
|
||||
env:
|
||||
RUSTFLAGS: ""
|
||||
- name: Build frontend
|
||||
run: ./app/build.sh
|
||||
- name: Check build path
|
||||
run: ls -al app/
|
||||
- name: Upload ui artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: ui
|
||||
path: app/
|
||||
|
||||
|
||||
build-bin:
|
||||
runs-on: ubuntu-latest
|
||||
needs: pre_job
|
||||
if: ${{ needs.pre_job.outputs.should_skip != 'true' || github.event_name == 'release' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
target: [armv7-unknown-linux-musleabihf, aarch64-unknown-linux-musl, x86_64-unknown-linux-musl]
|
||||
container:
|
||||
image: lldap/rust-dev:latest
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: -Ctarget-feature=+crt-static
|
||||
CARGO_HOME: ${GITHUB_WORKSPACE}/.cargo
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4.0.0
|
||||
- uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
.cargo/bin
|
||||
.cargo/registry/index
|
||||
.cargo/registry/cache
|
||||
.cargo/git/db
|
||||
target
|
||||
key: lldap-bin-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
lldap-bin-${{ matrix.target }}-
|
||||
- name: Compile ${{ matrix.target }} lldap and tools
|
||||
run: cargo build --target=${{ matrix.target }} --release -p lldap -p lldap_migration_tool -p lldap_set_password
|
||||
- name: Check path
|
||||
run: ls -al target/release
|
||||
- name: Upload ${{ matrix.target}} lldap artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: ${{ matrix.target}}-lldap-bin
|
||||
path: target/${{ matrix.target }}/release/lldap
|
||||
- name: Upload ${{ matrix.target }} migration tool artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: ${{ matrix.target }}-lldap_migration_tool-bin
|
||||
path: target/${{ matrix.target }}/release/lldap_migration_tool
|
||||
- name: Upload ${{ matrix.target }} password tool artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: ${{ matrix.target }}-lldap_set_password-bin
|
||||
path: target/${{ matrix.target }}/release/lldap_set_password
|
||||
|
||||
lldap-database-init-test:
|
||||
needs: [build-ui,build-bin]
|
||||
name: LLDAP database init test
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
mariadb:
|
||||
image: mariadb:latest
|
||||
ports:
|
||||
- 3306:3306
|
||||
env:
|
||||
MARIADB_USER: lldapuser
|
||||
MARIADB_PASSWORD: lldappass
|
||||
MARIADB_DATABASE: lldap
|
||||
MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: 1
|
||||
options: >-
|
||||
--name mariadb
|
||||
--health-cmd="mariadb-admin ping" --health-interval=5s --health-timeout=2s --health-retries=3
|
||||
|
||||
postgresql:
|
||||
image: postgres:latest
|
||||
ports:
|
||||
- 5432:5432
|
||||
env:
|
||||
POSTGRES_USER: lldapuser
|
||||
POSTGRES_PASSWORD: lldappass
|
||||
POSTGRES_DB: lldap
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
--name postgresql
|
||||
|
||||
steps:
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: x86_64-unknown-linux-musl-lldap-bin
|
||||
path: bin/
|
||||
|
||||
- name: Set executables to LLDAP
|
||||
run: chmod +x bin/lldap
|
||||
|
||||
- name: Run lldap with postgres DB and healthcheck
|
||||
run: |
|
||||
bin/lldap run &
|
||||
sleep 10s
|
||||
bin/lldap healthcheck
|
||||
env:
|
||||
LLDAP_database_url: postgres://lldapuser:lldappass@localhost/lldap
|
||||
LLDAP_ldap_port: 3890
|
||||
LLDAP_http_port: 17170
|
||||
|
||||
|
||||
- name: Run lldap with mariadb DB (MySQL Compatible) and healthcheck
|
||||
run: |
|
||||
bin/lldap run &
|
||||
sleep 10s
|
||||
bin/lldap healthcheck
|
||||
env:
|
||||
LLDAP_database_url: mysql://lldapuser:lldappass@localhost/lldap
|
||||
LLDAP_ldap_port: 3891
|
||||
LLDAP_http_port: 17171
|
||||
|
||||
|
||||
- name: Run lldap with sqlite DB and healthcheck
|
||||
run: |
|
||||
bin/lldap run &
|
||||
sleep 10s
|
||||
bin/lldap healthcheck
|
||||
env:
|
||||
LLDAP_database_url: sqlite://users.db?mode=rwc
|
||||
LLDAP_ldap_port: 3892
|
||||
LLDAP_http_port: 17172
|
||||
|
||||
- name: Check DB container logs
|
||||
run: |
|
||||
docker logs -n 20 mariadb
|
||||
docker logs -n 20 postgresql
|
||||
|
||||
lldap-database-migration-test:
|
||||
needs: [build-ui,build-bin]
|
||||
name: LLDAP database migration test
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgresql:
|
||||
image: postgres:latest
|
||||
ports:
|
||||
- 5432:5432
|
||||
env:
|
||||
POSTGRES_USER: lldapuser
|
||||
POSTGRES_PASSWORD: lldappass
|
||||
POSTGRES_DB: lldap
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
--name postgresql
|
||||
|
||||
mariadb:
|
||||
image: mariadb:latest
|
||||
ports:
|
||||
- 3306:3306
|
||||
env:
|
||||
MARIADB_USER: lldapuser
|
||||
MARIADB_PASSWORD: lldappass
|
||||
MARIADB_DATABASE: lldap
|
||||
MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: 1
|
||||
options: >-
|
||||
--name mariadb
|
||||
--health-cmd="mariadb-admin ping" --health-interval=5s --health-timeout=2s --health-retries=3
|
||||
|
||||
|
||||
mysql:
|
||||
image: mysql:latest
|
||||
ports:
|
||||
- 3307:3306
|
||||
env:
|
||||
MYSQL_USER: lldapuser
|
||||
MYSQL_PASSWORD: lldappass
|
||||
MYSQL_DATABASE: lldap
|
||||
MYSQL_ALLOW_EMPTY_PASSWORD: 1
|
||||
options: >-
|
||||
--name mysql
|
||||
--health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3
|
||||
|
||||
|
||||
steps:
|
||||
- name: Checkout scripts
|
||||
uses: actions/checkout@v4.0.0
|
||||
with:
|
||||
sparse-checkout: 'scripts'
|
||||
|
||||
- name: Download LLDAP artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: x86_64-unknown-linux-musl-lldap-bin
|
||||
path: bin/
|
||||
|
||||
- name: Download LLDAP set password
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: x86_64-unknown-linux-musl-lldap_set_password-bin
|
||||
path: bin/
|
||||
|
||||
- name: Set executables to LLDAP and LLDAP set password
|
||||
run: |
|
||||
chmod +x bin/lldap
|
||||
chmod +x bin/lldap_set_password
|
||||
|
||||
- name: Install sqlite3 and ldap-utils for exporting and searching dummy user
|
||||
run: sudo apt update && sudo apt install -y sqlite3 ldap-utils
|
||||
|
||||
- name: Run lldap with sqlite DB and healthcheck
|
||||
run: |
|
||||
bin/lldap run &
|
||||
sleep 10s
|
||||
bin/lldap healthcheck
|
||||
env:
|
||||
LLDAP_database_url: sqlite://users.db?mode=rwc
|
||||
LLDAP_ldap_port: 3890
|
||||
LLDAP_http_port: 17170
|
||||
LLDAP_LDAP_USER_PASS: ldappass
|
||||
LLDAP_JWT_SECRET: somejwtsecret
|
||||
|
||||
- name: Create dummy user
|
||||
run: |
|
||||
TOKEN=$(curl -X POST -H "Content-Type: application/json" -d '{"username": "admin", "password": "ldappass"}' http://localhost:17170/auth/simple/login | jq -r .token)
|
||||
echo "$TOKEN"
|
||||
curl 'http://localhost:17170/api/graphql' -H 'Content-Type: application/json' -H "Authorization: Bearer ${TOKEN//[$'\t\r\n ']}" --data-binary '{"query":"mutation{\n createUser(user:\n {\n id: \"dummyuser\",\n email: \"dummyuser@example.com\"\n }\n )\n {\n id\n email\n }\n}\n\n\n"}' --compressed
|
||||
bin/lldap_set_password --base-url http://localhost:17170 --admin-username admin --admin-password ldappass --token $TOKEN --username dummyuser --password dummypassword
|
||||
|
||||
- name: Test Dummy User, This will be checked again after importing
|
||||
run: |
|
||||
ldapsearch -H ldap://localhost:3890 -LLL -D "uid=dummyuser,ou=people,dc=example,dc=com" -w 'dummypassword' -s "One" -b "ou=people,dc=example,dc=com"
|
||||
|
||||
- name: Stop LLDAP sqlite
|
||||
run: pkill lldap
|
||||
|
||||
- name: Export and Converting to Postgress
|
||||
run: |
|
||||
bash ./scripts/sqlite_dump_commands.sh | sqlite3 ./users.db > ./dump.sql
|
||||
sed -i -r -e "s/X'([[:xdigit:]]+'[^'])/'\\\x\\1/g" -e ":a; s/(INSERT INTO user_attribute_schema\(.*\) VALUES\(.*),1([^']*\);)$/\1,true\2/; s/(INSERT INTO user_attribute_schema\(.*\) VALUES\(.*),0([^']*\);)$/\1,false\2/; ta" -e '1s/^/BEGIN;\n/' -e '$aCOMMIT;' ./dump.sql
|
||||
|
||||
- name: Create schema on postgres
|
||||
run: |
|
||||
bin/lldap create_schema -d postgres://lldapuser:lldappass@localhost:5432/lldap
|
||||
|
||||
- name: Copy converted db to postgress and import
|
||||
run: |
|
||||
docker cp ./dump.sql postgresql:/tmp/dump.sql
|
||||
docker exec postgresql bash -c "psql -U lldapuser -d lldap < /tmp/dump.sql" | tee import.log
|
||||
rm ./dump.sql
|
||||
! grep ERROR import.log > /dev/null
|
||||
|
||||
- name: Export and Converting to mariadb
|
||||
run: |
|
||||
bash ./scripts/sqlite_dump_commands.sh | sqlite3 ./users.db > ./dump.sql
|
||||
cp ./dump.sql ./dump-no-sed.sql
|
||||
sed -i -r -e "s/([^']'[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{9})\+00:00'([^'])/\1'\2/g" \-e 's/^INSERT INTO "?([a-zA-Z0-9_]+)"?/INSERT INTO `\1`/' -e '1s/^/START TRANSACTION;\n/' -e '$aCOMMIT;' ./dump.sql
|
||||
sed -i '1 i\SET FOREIGN_KEY_CHECKS = 0;' ./dump.sql
|
||||
|
||||
- name: Create schema on mariadb
|
||||
run: bin/lldap create_schema -d mysql://lldapuser:lldappass@localhost:3306/lldap
|
||||
|
||||
- name: Copy converted db to mariadb and import
|
||||
run: |
|
||||
docker cp ./dump.sql mariadb:/tmp/dump.sql
|
||||
docker exec mariadb bash -c "mariadb -ulldapuser -plldappass -f lldap < /tmp/dump.sql" | tee import.log
|
||||
rm ./dump.sql
|
||||
! grep ERROR import.log > /dev/null
|
||||
|
||||
- name: Export and Converting to mysql
|
||||
run: |
|
||||
bash ./scripts/sqlite_dump_commands.sh | sqlite3 ./users.db > ./dump.sql
|
||||
sed -i -r -e 's/^INSERT INTO "?([a-zA-Z0-9_]+)"?/INSERT INTO `\1`/' -e '1s/^/START TRANSACTION;\n/' -e '$aCOMMIT;' ./dump.sql
|
||||
sed -i '1 i\SET FOREIGN_KEY_CHECKS = 0;' ./dump.sql
|
||||
|
||||
- name: Create schema on mysql
|
||||
run: bin/lldap create_schema -d mysql://lldapuser:lldappass@localhost:3307/lldap
|
||||
|
||||
- name: Copy converted db to mysql and import
|
||||
run: |
|
||||
docker cp ./dump.sql mysql:/tmp/dump.sql
|
||||
docker exec mysql bash -c "mysql -ulldapuser -plldappass -f lldap < /tmp/dump.sql" | tee import.log
|
||||
rm ./dump.sql
|
||||
! grep ERROR import.log > /dev/null
|
||||
|
||||
- name: Run lldap with postgres DB and healthcheck again
|
||||
run: |
|
||||
bin/lldap run &
|
||||
sleep 10s
|
||||
bin/lldap healthcheck
|
||||
env:
|
||||
LLDAP_database_url: postgres://lldapuser:lldappass@localhost:5432/lldap
|
||||
LLDAP_ldap_port: 3891
|
||||
LLDAP_http_port: 17171
|
||||
LLDAP_LDAP_USER_PASS: ldappass
|
||||
LLDAP_JWT_SECRET: somejwtsecret
|
||||
|
||||
- name: Run lldap with mariaDB and healthcheck again
|
||||
run: |
|
||||
bin/lldap run &
|
||||
sleep 10s
|
||||
bin/lldap healthcheck
|
||||
env:
|
||||
LLDAP_database_url: mysql://lldapuser:lldappass@localhost:3306/lldap
|
||||
LLDAP_ldap_port: 3892
|
||||
LLDAP_http_port: 17172
|
||||
LLDAP_JWT_SECRET: somejwtsecret
|
||||
|
||||
- name: Run lldap with mysql and healthcheck again
|
||||
run: |
|
||||
bin/lldap run &
|
||||
sleep 10s
|
||||
bin/lldap healthcheck
|
||||
env:
|
||||
LLDAP_database_url: mysql://lldapuser:lldappass@localhost:3307/lldap
|
||||
LLDAP_ldap_port: 3893
|
||||
LLDAP_http_port: 17173
|
||||
LLDAP_JWT_SECRET: somejwtsecret
|
||||
|
||||
- name: Test Dummy User Postgres
|
||||
run: ldapsearch -H ldap://localhost:3891 -LLL -D "uid=dummyuser,ou=people,dc=example,dc=com" -w 'dummypassword' -s "One" -b "ou=people,dc=example,dc=com"
|
||||
- name: Test Dummy User MariaDB
|
||||
run: ldapsearch -H ldap://localhost:3892 -LLL -D "uid=dummyuser,ou=people,dc=example,dc=com" -w 'dummypassword' -s "One" -b "ou=people,dc=example,dc=com"
|
||||
- name: Test Dummy User MySQL
|
||||
run: ldapsearch -H ldap://localhost:3893 -LLL -D "uid=dummyuser,ou=people,dc=example,dc=com" -w 'dummypassword' -s "One" -b "ou=people,dc=example,dc=com"
|
||||
|
||||
build-docker-image:
|
||||
needs: [build-ui, build-bin]
|
||||
name: Build Docker image
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
container: ["debian","alpine"]
|
||||
include:
|
||||
- container: alpine
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
type=ref,event=pr
|
||||
type=semver,pattern=v{{version}}
|
||||
type=semver,pattern=v{{major}}
|
||||
type=semver,pattern=v{{major}}.{{minor}}
|
||||
type=semver,pattern=v{{version}},suffix=
|
||||
type=semver,pattern=v{{major}},suffix=
|
||||
type=semver,pattern=v{{major}}.{{minor}},suffix=
|
||||
type=raw,value=latest,enable={{ is_default_branch }}
|
||||
type=raw,value=stable,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
type=raw,value=stable,enable=${{ startsWith(github.ref, 'refs/tags/v') }},suffix=
|
||||
type=raw,value=latest,enable={{ is_default_branch }},suffix=
|
||||
- container: debian
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
tags: |
|
||||
type=ref,event=pr
|
||||
type=semver,pattern=v{{version}}
|
||||
type=semver,pattern=v{{major}}
|
||||
type=semver,pattern=v{{major}}.{{minor}}
|
||||
type=raw,value=latest,enable={{ is_default_branch }}
|
||||
type=raw,value=stable,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4.0.0
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
path: bin
|
||||
|
||||
- name: Download llap ui artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: ui
|
||||
path: web
|
||||
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
- uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Docker ${{ matrix.container }} meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
# list of Docker images to use as base name for tags
|
||||
images: |
|
||||
nitnelave/lldap
|
||||
lldap/lldap
|
||||
ghcr.io/lldap/lldap
|
||||
# Wanted Docker tags
|
||||
# vX-alpine
|
||||
# vX.Y-alpine
|
||||
# vX.Y.Z-alpine
|
||||
# latest
|
||||
# latest-alpine
|
||||
# stable
|
||||
# stable-alpine
|
||||
#################
|
||||
# vX-debian
|
||||
# vX.Y-debian
|
||||
# vX.Y.Z-debian
|
||||
# latest-debian
|
||||
# stable-debian
|
||||
#################
|
||||
# Check matrix for tag list definition
|
||||
flavor: |
|
||||
latest=false
|
||||
suffix=-${{ matrix.container }}
|
||||
tags: ${{ matrix.tags }}
|
||||
|
||||
# Docker login to nitnelave/lldap and lldap/lldap
|
||||
- name: Login to Nitnelave/LLDAP Docker Hub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: nitnelave
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
||||
########################################
|
||||
#### docker image build ####
|
||||
########################################
|
||||
- name: Build ${{ matrix.container }} Docker Image
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
platforms: ${{ matrix.platforms }}
|
||||
file: ./.github/workflows/Dockerfile.ci.${{ matrix.container }}
|
||||
tags: |
|
||||
${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha,mode=max
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Update repo description
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: peter-evans/dockerhub-description@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
repository: nitnelave/lldap
|
||||
|
||||
- name: Update lldap repo description
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: peter-evans/dockerhub-description@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
repository: lldap/lldap
|
||||
|
||||
###############################################################
|
||||
### Download artifacts, clean up ui, upload to release page ###
|
||||
###############################################################
|
||||
create-release-artifacts:
|
||||
needs: [build-ui, build-bin]
|
||||
name: Create release artifacts
|
||||
if: github.event_name == 'release'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
path: bin/
|
||||
- name: Check file
|
||||
run: ls -alR bin/
|
||||
- name: Fixing Filename
|
||||
run: |
|
||||
mv bin/aarch64-unknown-linux-musl-lldap-bin/lldap bin/aarch64-lldap
|
||||
mv bin/x86_64-unknown-linux-musl-lldap-bin/lldap bin/amd64-lldap
|
||||
mv bin/armv7-unknown-linux-musleabihf-lldap-bin/lldap bin/armhf-lldap
|
||||
mv bin/aarch64-unknown-linux-musl-lldap_migration_tool-bin/lldap_migration_tool bin/aarch64-lldap_migration_tool
|
||||
mv bin/x86_64-unknown-linux-musl-lldap_migration_tool-bin/lldap_migration_tool bin/amd64-lldap_migration_tool
|
||||
mv bin/armv7-unknown-linux-musleabihf-lldap_migration_tool-bin/lldap_migration_tool bin/armhf-lldap_migration_tool
|
||||
mv bin/aarch64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password bin/aarch64-lldap_set_password
|
||||
mv bin/x86_64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password bin/amd64-lldap_set_password
|
||||
mv bin/armv7-unknown-linux-musleabihf-lldap_set_password-bin/lldap_set_password bin/armhf-lldap_set_password
|
||||
chmod +x bin/*-lldap
|
||||
chmod +x bin/*-lldap_migration_tool
|
||||
chmod +x bin/*-lldap_set_password
|
||||
|
||||
- name: Download llap ui artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: ui
|
||||
path: web
|
||||
- name: UI (web) artifacts cleanup
|
||||
run: mkdir app && mv web/index.html app/index.html && mv web/static app/static && mv web/pkg app/pkg
|
||||
- name: Fetch web components
|
||||
run: |
|
||||
sudo apt update
|
||||
sudo apt install wget
|
||||
for file in $(cat app/static/libraries.txt); do wget -P app/static "$file"; done
|
||||
for file in $(cat app/static/fonts/fonts.txt); do wget -P app/static/fonts "$file"; done
|
||||
chmod a+r -R .
|
||||
|
||||
- name: Setup LLDAP dir for packing
|
||||
run: |
|
||||
mkdir aarch64-lldap
|
||||
mkdir amd64-lldap
|
||||
mkdir armhf-lldap
|
||||
mv bin/aarch64-lldap aarch64-lldap/lldap
|
||||
mv bin/amd64-lldap amd64-lldap/lldap
|
||||
mv bin/armhf-lldap armhf-lldap/lldap
|
||||
mv bin/aarch64-lldap_migration_tool aarch64-lldap/lldap_migration_tool
|
||||
mv bin/amd64-lldap_migration_tool amd64-lldap/lldap_migration_tool
|
||||
mv bin/armhf-lldap_migration_tool armhf-lldap/lldap_migration_tool
|
||||
mv bin/aarch64-lldap_set_password aarch64-lldap/lldap_set_password
|
||||
mv bin/amd64-lldap_set_password amd64-lldap/lldap_set_password
|
||||
mv bin/armhf-lldap_set_password armhf-lldap/lldap_set_password
|
||||
cp -r app aarch64-lldap/
|
||||
cp -r app amd64-lldap/
|
||||
cp -r app armhf-lldap/
|
||||
ls -alR aarch64-lldap/
|
||||
ls -alR amd64-lldap/
|
||||
ls -alR armhf-lldap/
|
||||
|
||||
- name: Packing LLDAP and Web UI
|
||||
run: |
|
||||
tar -czvf aarch64-lldap.tar.gz aarch64-lldap/
|
||||
tar -czvf amd64-lldap.tar.gz amd64-lldap/
|
||||
tar -czvf armhf-lldap.tar.gz armhf-lldap/
|
||||
|
||||
|
||||
- name: Upload compressed release
|
||||
uses: ncipollo/release-action@v1
|
||||
id: create_release
|
||||
with:
|
||||
allowUpdates: true
|
||||
artifacts: aarch64-lldap.tar.gz,
|
||||
amd64-lldap.tar.gz,
|
||||
armhf-lldap.tar.gz
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
410
.github/workflows/docker-build.yml
vendored
@@ -1,410 +0,0 @@
|
||||
name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
release:
|
||||
types:
|
||||
- 'published'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
msg:
|
||||
description: "Set message"
|
||||
default: "Manual trigger"
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_DIR: $GITHUB_WORKSPACE/.sccache
|
||||
SCCACHE_VERSION: v0.3.0
|
||||
LINK: https://github.com/mozilla/sccache/releases/download
|
||||
|
||||
# In total 5 jobs, all of the jobs are containerized
|
||||
# ---
|
||||
|
||||
# build-ui , create/compile the web
|
||||
## Use rustlang/rust:nighlty image
|
||||
### Install nodejs from nodesource repo
|
||||
### install wasm
|
||||
### install rollup
|
||||
### run app/build.sh
|
||||
### upload artifacts
|
||||
|
||||
# builds-armhf, build-aarch64, build-amd64 create binary for respective arch
|
||||
## Use rustlang/rust:nightly image
|
||||
### Add non native architecture dpkg --add-architecture XXX
|
||||
### Install dev tool gcc g++, etc per respective arch
|
||||
### Cargo build
|
||||
### Upload artifacts
|
||||
|
||||
## the CARGO_ env
|
||||
#CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER: arm-linux-gnueabihf-gcc
|
||||
#OPENSSL_INCLUDE_DIR: "/usr/include/openssl/"
|
||||
#OPENSSL_LIB_DIR: "/usr/lib/arm-linux-gnueabihf/"
|
||||
# This will determine which architecture lib will be used.
|
||||
|
||||
# build-ui,builds-armhf, build-aarch64, build-amd64 will upload artifacts will be used next job
|
||||
# build-docker-image job will fetch artifacts and run Dockerfile.ci then push the image.
|
||||
|
||||
# On current https://hub.docker.com/_/rust
|
||||
# 1-bullseye, 1.61-bullseye, 1.61.0-bullseye, bullseye, 1, 1.61, 1.61.0, latest
|
||||
|
||||
# cache
|
||||
## .sccache
|
||||
## cargo
|
||||
## target
|
||||
|
||||
jobs:
|
||||
build-ui:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: rust:1.61
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: -Ctarget-feature=-crt-static
|
||||
steps:
|
||||
- name: install runtime
|
||||
run: apt update && apt install -y gcc-x86-64-linux-gnu g++-x86-64-linux-gnu libc6-dev libssl-dev
|
||||
- name: setup node repo LTS
|
||||
run: curl -fsSL https://deb.nodesource.com/setup_lts.x | bash -
|
||||
- name: install nodejs
|
||||
run: apt install -y nodejs && npm -g install npm
|
||||
- name: smoke test
|
||||
run: rustc --version
|
||||
- name: Install sccache (ubuntu-latest)
|
||||
run: |
|
||||
SCCACHE_FILE=sccache-$SCCACHE_VERSION-x86_64-unknown-linux-musl
|
||||
mkdir -p $HOME/.local/bin
|
||||
curl -L "$LINK/$SCCACHE_VERSION/$SCCACHE_FILE.tar.gz" | tar xz
|
||||
mv -f $SCCACHE_FILE/sccache $HOME/.local/bin/sccache
|
||||
chmod +x $HOME/.local/bin/sccache
|
||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
- uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
.sccache
|
||||
/usr/local/cargo
|
||||
target
|
||||
key: lldap-ui-${{ github.sha }}
|
||||
restore-keys: |
|
||||
lldap-ui-
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
- name: install cargo wasm
|
||||
run: cargo install wasm-pack
|
||||
- name: install rollup nodejs
|
||||
run: npm install -g rollup
|
||||
- name: build frontend
|
||||
run: ./app/build.sh
|
||||
- name: check path
|
||||
run: ls -al app/
|
||||
- name: upload ui artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: ui
|
||||
path: app/
|
||||
|
||||
build-armhf:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: rust:1.61
|
||||
env:
|
||||
CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER: arm-linux-gnueabihf-gcc
|
||||
OPENSSL_INCLUDE_DIR: "/usr/include/openssl/"
|
||||
OPENSSL_LIB_DIR: "/usr/lib/arm-linux-gnueabihf/"
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: -Ctarget-feature=-crt-static
|
||||
steps:
|
||||
- name: add armhf architecture
|
||||
run: dpkg --add-architecture armhf
|
||||
- name: install runtime
|
||||
run: apt update && apt install -y gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf libc6-armhf-cross libc6-dev-armhf-cross libssl-dev:armhf tar
|
||||
- name: smoke test
|
||||
run: rustc --version
|
||||
- name: add armhf target
|
||||
run: rustup target add armv7-unknown-linux-gnueabihf
|
||||
- name: smoke test
|
||||
run: rustc --version
|
||||
- name: Install sccache (ubuntu-latest)
|
||||
run: |
|
||||
SCCACHE_FILE=sccache-$SCCACHE_VERSION-x86_64-unknown-linux-musl
|
||||
mkdir -p $HOME/.local/bin
|
||||
curl -L "$LINK/$SCCACHE_VERSION/$SCCACHE_FILE.tar.gz" | tar xz
|
||||
mv -f $SCCACHE_FILE/sccache $HOME/.local/bin/sccache
|
||||
chmod +x $HOME/.local/bin/sccache
|
||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
- uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
.sccache
|
||||
/usr/local/cargo
|
||||
target
|
||||
key: lldap-bin-armhf-${{ github.sha }}
|
||||
restore-keys: |
|
||||
lldap-bin-armhf-
|
||||
- name: compile armhf
|
||||
run: cargo build --target=armv7-unknown-linux-gnueabihf --release -p lldap -p migration-tool
|
||||
- name: check path
|
||||
run: ls -al target/release
|
||||
- name: upload armhf lldap artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: armhf-lldap-bin
|
||||
path: target/armv7-unknown-linux-gnueabihf/release/lldap
|
||||
- name: upload armhfmigration-tool artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: armhf-migration-tool-bin
|
||||
path: target/armv7-unknown-linux-gnueabihf/release/migration-tool
|
||||
|
||||
|
||||
build-aarch64:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: rust:1.61
|
||||
env:
|
||||
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
|
||||
OPENSSL_INCLUDE_DIR: "/usr/include/openssl/"
|
||||
OPENSSL_LIB_DIR: "/usr/lib/aarch64-linux-gnu/"
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: -Ctarget-feature=-crt-static
|
||||
steps:
|
||||
- name: add arm64 architecture
|
||||
run: dpkg --add-architecture arm64
|
||||
- name: install runtime
|
||||
run: apt update && apt install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libc6-arm64-cross libc6-dev-arm64-cross libssl-dev:arm64 tar
|
||||
- name: smoke test
|
||||
run: rustc --version
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
- name: add arm64 target
|
||||
run: rustup target add aarch64-unknown-linux-gnu
|
||||
- name: smoke test
|
||||
run: rustc --version
|
||||
- name: Install sccache (ubuntu-latest)
|
||||
run: |
|
||||
SCCACHE_FILE=sccache-$SCCACHE_VERSION-x86_64-unknown-linux-musl
|
||||
mkdir -p $HOME/.local/bin
|
||||
curl -L "$LINK/$SCCACHE_VERSION/$SCCACHE_FILE.tar.gz" | tar xz
|
||||
mv -f $SCCACHE_FILE/sccache $HOME/.local/bin/sccache
|
||||
chmod +x $HOME/.local/bin/sccache
|
||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
- uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
.sccache
|
||||
/usr/local/cargo
|
||||
target
|
||||
key: lldap-bin-aarch64-${{ github.sha }}
|
||||
restore-keys: |
|
||||
lldap-bin-aarch64-
|
||||
- name: compile aarch64
|
||||
run: cargo build --target=aarch64-unknown-linux-gnu --release -p lldap -p migration-tool
|
||||
- name: check path
|
||||
run: ls -al target/aarch64-unknown-linux-gnu/release/
|
||||
- name: upload aarch64 lldap artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: aarch64-lldap-bin
|
||||
path: target/aarch64-unknown-linux-gnu/release/lldap
|
||||
- name: upload aarch64 migration-tool artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: aarch64-migration-tool-bin
|
||||
path: target/aarch64-unknown-linux-gnu/release/migration-tool
|
||||
|
||||
build-amd64:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: rust:1.61
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: -Ctarget-feature=-crt-static
|
||||
steps:
|
||||
- name: install runtime
|
||||
run: apt update && apt install -y gcc-x86-64-linux-gnu g++-x86-64-linux-gnu libc6-dev libssl-dev tar
|
||||
- name: smoke test
|
||||
run: rustc --version
|
||||
- name: Install sccache (ubuntu-latest)
|
||||
run: |
|
||||
SCCACHE_FILE=sccache-$SCCACHE_VERSION-x86_64-unknown-linux-musl
|
||||
mkdir -p $HOME/.local/bin
|
||||
curl -L "$LINK/$SCCACHE_VERSION/$SCCACHE_FILE.tar.gz" | tar xz
|
||||
mv -f $SCCACHE_FILE/sccache $HOME/.local/bin/sccache
|
||||
chmod +x $HOME/.local/bin/sccache
|
||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
- name: cargo & sscache cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
.sccache
|
||||
/usr/local/cargo
|
||||
target
|
||||
key: lldap-bin-amd64-${{ github.sha }}
|
||||
restore-keys: |
|
||||
lldap-bin-amd64-
|
||||
#- name: add cargo chef
|
||||
# run: cargo install cargo-chef
|
||||
#- name: chef prepare
|
||||
# run: cargo chef prepare --recipe-path recipe.json
|
||||
#- name: cook?
|
||||
# run: cargo chef cook --release --recipe-path recipe.json
|
||||
- name: compile amd64
|
||||
run: cargo build --target=x86_64-unknown-linux-gnu --release -p lldap -p migration-tool
|
||||
- name: check path
|
||||
run: ls -al target/x86_64-unknown-linux-gnu/release/
|
||||
- name: upload amd64 lldap artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: amd64-lldap-bin
|
||||
path: target/x86_64-unknown-linux-gnu/release/lldap
|
||||
- name: upload amd64 migration-tool artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: amd64-migration-tool-bin
|
||||
path: target/x86_64-unknown-linux-gnu/release/migration-tool
|
||||
|
||||
|
||||
build-docker-image:
|
||||
needs: [build-ui,build-armhf,build-aarch64,build-amd64]
|
||||
name: Build Docker image
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: fetch repo
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Download armhf lldap artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: armhf-lldap-bin
|
||||
path: bin/armhf-bin
|
||||
- name: Download armhf migration-tool artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: armhf-migration-tool-bin
|
||||
path: bin/armhf-bin
|
||||
|
||||
- name: Download aarch64 lldap artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: aarch64-lldap-bin
|
||||
path: bin/aarch64-bin
|
||||
- name: Download aarch64 migration-tool artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: aarch64-migration-tool-bin
|
||||
path: bin/aarch64-bin
|
||||
|
||||
- name: Download amd64 lldap artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: amd64-lldap-bin
|
||||
path: bin/amd64-bin
|
||||
- name: Download amd64 migration-tool artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: amd64-migration-tool-bin
|
||||
path: bin/amd64-bin
|
||||
|
||||
- name: check bin path
|
||||
run: ls -al bin/
|
||||
|
||||
- name: Download llap ui artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: ui
|
||||
path: web
|
||||
|
||||
- name: setup qemu
|
||||
uses: docker/setup-qemu-action@v2
|
||||
- uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
# list of Docker images to use as base name for tags
|
||||
images: |
|
||||
nitnelave/lldap
|
||||
# generate Docker tags based on the following events/attributes
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=sha
|
||||
- name: Cache Docker layers
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-buildx-
|
||||
|
||||
- name: parse tag
|
||||
uses: gacts/github-slug@v1
|
||||
id: slug
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push latest
|
||||
if: github.event_name != 'release'
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
file: ./.github/workflows/Dockerfile.ci
|
||||
tags: nitnelave/lldap:latest
|
||||
#cache-from: type=gha
|
||||
#cache-to: type=gha,mode=max
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache-new
|
||||
|
||||
- name: Build and push release
|
||||
if: github.event_name == 'release'
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
push: true
|
||||
# Tag as latest, stable, semver, major, major.minor and major.minor.patch.
|
||||
file: ./.github/workflows/Dockerfile.ci
|
||||
tags: nitnelave/lldap:stable, nitnelave/lldap:v${{ steps.slug.outputs.version-semantic }}, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}.${{ steps.slug.outputs.version-patch }}
|
||||
#cache-from: type=gha
|
||||
#cache-to: type=gha,mode=max
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache-new
|
||||
|
||||
- name: Move cache
|
||||
run: |
|
||||
rm -rf /tmp/.buildx-cache
|
||||
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
|
||||
|
||||
- name: Update repo description
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: peter-evans/dockerhub-description@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
repository: nitnelave/lldap
|
||||
|
||||
20
.github/workflows/release-bot.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
name: Release Bot
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: nflaig/release-comment-on-pr@master
|
||||
with:
|
||||
token: ${{ secrets.RELEASE_BOT_TOKEN }}
|
||||
message: |
|
||||
Thank you everyone for the contribution!
|
||||
This feature is now available in the latest release, [${releaseTag}](${releaseUrl}).
|
||||
You can support LLDAP by starring our repo, contributing some configuration examples and becoming a sponsor.
|
||||
31
.github/workflows/rust.yml
vendored
@@ -13,7 +13,6 @@ jobs:
|
||||
pre_job:
|
||||
continue-on-error: true
|
||||
runs-on: ubuntu-latest
|
||||
# Map a step output to a job output
|
||||
outputs:
|
||||
should_skip: ${{ steps.skip_check.outputs.should_skip }}
|
||||
steps:
|
||||
@@ -22,7 +21,7 @@ jobs:
|
||||
with:
|
||||
concurrent_skipping: 'outdated_runs'
|
||||
skip_after_successful_duplicate: 'true'
|
||||
paths_ignore: '["**/*.md", "**/docs/**", "example_configs/**", "*.sh"]'
|
||||
paths_ignore: '["**/*.md", "**/docs/**", "example_configs/**", "*.sh", ".dockerignore", ".gitignore", "lldap_config.docker_template.toml", "Dockerfile"]'
|
||||
do_not_skip: '["workflow_dispatch", "schedule"]'
|
||||
cancel_others: true
|
||||
|
||||
@@ -34,8 +33,8 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v3
|
||||
- uses: Swatinem/rust-cache@v1
|
||||
uses: actions/checkout@v4.0.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Build
|
||||
run: cargo build --verbose --workspace
|
||||
- name: Run tests
|
||||
@@ -53,9 +52,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4.0.0
|
||||
|
||||
- uses: Swatinem/rust-cache@v1
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Run cargo clippy
|
||||
uses: actions-rs/cargo@v1
|
||||
@@ -70,9 +69,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4.0.0
|
||||
|
||||
- uses: Swatinem/rust-cache@v1
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Run cargo fmt
|
||||
uses: actions-rs/cargo@v1
|
||||
@@ -82,19 +81,21 @@ jobs:
|
||||
|
||||
coverage:
|
||||
name: Code coverage
|
||||
needs: pre_job
|
||||
needs:
|
||||
- pre_job
|
||||
- test
|
||||
if: ${{ needs.pre_job.outputs.should_skip != 'true' || (github.event_name == 'push' && github.ref == 'refs/heads/main') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4.0.0
|
||||
|
||||
- name: Install Rust
|
||||
run: rustup toolchain install nightly --component llvm-tools-preview && rustup component add llvm-tools-preview --toolchain stable-x86_64-unknown-linux-gnu
|
||||
|
||||
- uses: taiki-e/install-action@cargo-llvm-cov
|
||||
|
||||
- uses: Swatinem/rust-cache@v1
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Generate code coverage for unit test
|
||||
run: cargo llvm-cov --workspace --no-report
|
||||
@@ -102,6 +103,14 @@ jobs:
|
||||
run: cargo llvm-cov --no-run --lcov --output-path lcov.info
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
if: github.ref != 'refs/heads/main' || github.event_name != 'push'
|
||||
with:
|
||||
files: lcov.info
|
||||
fail_ci_if_error: true
|
||||
- name: Upload coverage to Codecov (main)
|
||||
uses: codecov/codecov-action@v3
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
with:
|
||||
files: lcov.info
|
||||
fail_ci_if_error: true
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
1
.gitignore
vendored
@@ -23,6 +23,7 @@ server_key
|
||||
*.tar.gz
|
||||
|
||||
# Misc
|
||||
.vscode
|
||||
.env
|
||||
recipe.json
|
||||
lldap_config.toml
|
||||
|
||||
168
CHANGELOG.md
@@ -5,7 +5,173 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
## [0.5.0] 2023-09-14
|
||||
|
||||
### Breaking
|
||||
|
||||
- Emails and UUIDs are now enforced to be unique.
|
||||
- If you have several users with the same email, you'll have to disambiguate
|
||||
them. You can do that by either issuing SQL commands directly
|
||||
(`UPDATE users SET email = 'x@x' WHERE user_id = 'bob';`), or by reverting
|
||||
to a 0.4.x version of LLDAP and editing the user through the web UI.
|
||||
An error will prevent LLDAP 0.5+ from starting otherwise.
|
||||
- This was done to prevent account takeover for systems that allow to
|
||||
login via email.
|
||||
|
||||
### Added
|
||||
|
||||
- The server private key can be set as a seed from an env variable (#504).
|
||||
- This is especially useful when you have multiple containers, they don't
|
||||
need to share a writeable folder.
|
||||
- Added support for changing the password through a plain LDAP Modify
|
||||
operation (as opposed to an extended operation), to allow Jellyfin
|
||||
to change password (#620).
|
||||
- Allow creating a user with multiple objectClass (#612).
|
||||
- Emails now have a message ID (#608).
|
||||
- Added a warning for browsers that have WASM/JS disabled (#639).
|
||||
- Added support for querying OUs in LDAP (#669).
|
||||
- Added a button to clear the avatar in the UI (#358).
|
||||
|
||||
|
||||
### Changed
|
||||
|
||||
- Groups are now sorted by name in the web UI (#623).
|
||||
- ARM build now uses musl (#584).
|
||||
- Improved logging.
|
||||
- Default admin user is only created if there are no admins (#563).
|
||||
- That allows you to remove the default admin, making it harder to
|
||||
bruteforce.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed URL parsing with a trailing slash in the password setting utility
|
||||
(#597).
|
||||
|
||||
In addition to all that, there was significant progress towards #67,
|
||||
user-defined attributes. That complex feature will unblock integration with many
|
||||
systems, including PAM authentication.
|
||||
|
||||
### New services
|
||||
|
||||
- Ejabberd
|
||||
- Ergo
|
||||
- LibreNMS
|
||||
- Mealie
|
||||
- MinIO
|
||||
- OpnSense
|
||||
- PfSense
|
||||
- PowerDnsAdmin
|
||||
- Proxmox
|
||||
- Squid
|
||||
- Tandoor recipes
|
||||
- TheLounge
|
||||
- Zabbix-web
|
||||
- Zulip
|
||||
|
||||
## [0.4.3] 2023-04-11
|
||||
|
||||
The repository has changed from `nitnelave/lldap` to `lldap/lldap`, both on GitHub
|
||||
and on DockerHub (although we will keep publishing the images to
|
||||
`nitnelave/lldap` for the foreseeable future). All data on GitHub has been
|
||||
migrated, and the new docker images are available both on DockerHub and on the
|
||||
GHCR under `lldap/lldap`.
|
||||
|
||||
### Added
|
||||
|
||||
- EC private keys are not supported for LDAPS.
|
||||
|
||||
### Changed
|
||||
|
||||
- SMTP user no longer has a default value (and instead defaults to unauthenticated).
|
||||
|
||||
### Fixed
|
||||
|
||||
- WASM payload is now delivered uncompressed to Safari due to a Safari bug.
|
||||
- Password reset no longer redirects to login page.
|
||||
- NextCloud config should add the "mail" attribute.
|
||||
- GraphQL parameters are now urldecoded, to support special characters in usernames.
|
||||
- Healthcheck correctly checks the server certificate.
|
||||
|
||||
### New services
|
||||
|
||||
- Home Assistant
|
||||
- Shaarli
|
||||
|
||||
## [0.4.2] - 2023-03-27
|
||||
|
||||
### Added
|
||||
|
||||
- Add support for MySQL/MariaDB/PostgreSQL, in addition to SQLite.
|
||||
- Healthcheck command for docker setups.
|
||||
- User creation through LDAP.
|
||||
- IPv6 support.
|
||||
- Dev container for VsCode.
|
||||
- Add support for DN LDAP filters.
|
||||
- Add support for SubString LDAP filters.
|
||||
- Add support for LdapCompare operation.
|
||||
- Add support for unencrypted/unauthenticated SMTP connection.
|
||||
- Add a command to setup the database schema.
|
||||
- Add a tool to set a user's password from the command line.
|
||||
- Added consistent release artifacts.
|
||||
|
||||
### Changed
|
||||
|
||||
- Payload is now compressed, reducing the size to 700kb.
|
||||
- entryUUID is returned in the default LDAP fields.
|
||||
- Slightly improved support for LDAP browsing tools.
|
||||
- Password reset can be identified by email (instead of just username).
|
||||
- Various front-end improvements, and support for dark mode.
|
||||
- Add content-type header to the password reset email, fixing rendering issues in some clients.
|
||||
- Identify groups with "cn" instead of "uid" in memberOf field.
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed dependency on nodejs/rollup.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Email is now using the async API.
|
||||
- Fix handling of empty/null names (display, first, last).
|
||||
- Obscured old password field when changing password.
|
||||
- Respect user setting to disable password resets.
|
||||
- Fix handling of "present" filters with unknown attributes.
|
||||
- Fix handling of filters that could lead to an ambiguous SQL query.
|
||||
|
||||
### New services
|
||||
|
||||
- Authentik
|
||||
- Dell iDRAC
|
||||
- Dex
|
||||
- Kanboard
|
||||
- NextCloud + OIDC or Authelia
|
||||
- Nexus
|
||||
- SUSE Rancher
|
||||
- VaultWarden
|
||||
- WeKan
|
||||
- WikiJS
|
||||
- ZendTo
|
||||
|
||||
### Dependencies (highlights)
|
||||
|
||||
- Upgraded Yew to 0.19
|
||||
- Upgraded actix to 0.13
|
||||
- Upgraded clap to 4
|
||||
- Switched from sea-query to sea-orm 0.11
|
||||
|
||||
## [0.4.1] - 2022-10-10
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for STARTTLS for SMTP.
|
||||
- Added support for user profile pictures, including importing them from OpenLDAP.
|
||||
- Added support for every config value to be specified in a file.
|
||||
- Added support for PKCS1 keys.
|
||||
|
||||
### Changed
|
||||
|
||||
- The `dn` attribute is no longer returned as an attribute (it's still part of the response).
|
||||
- Empty attributes are no longer returned.
|
||||
- The docker image now uses the locally-downloaded assets.
|
||||
|
||||
## [0.4.0] - 2022-07-08
|
||||
|
||||
|
||||
3463
Cargo.lock
generated
23
Cargo.toml
@@ -3,16 +3,23 @@ members = [
|
||||
"server",
|
||||
"auth",
|
||||
"app",
|
||||
"migration-tool"
|
||||
"migration-tool",
|
||||
"set-password",
|
||||
]
|
||||
|
||||
default-members = ["server"]
|
||||
|
||||
# TODO: remove when there's a new release.
|
||||
[patch.crates-io.yew_form]
|
||||
git = 'https://github.com/sassman/yew_form/'
|
||||
rev = '67050812695b7a8a90b81b0637e347fc6629daed'
|
||||
resolver = "2"
|
||||
|
||||
[patch.crates-io.yew_form_derive]
|
||||
git = 'https://github.com/sassman/yew_form/'
|
||||
rev = '67050812695b7a8a90b81b0637e347fc6629daed'
|
||||
[profile.release]
|
||||
lto = true
|
||||
|
||||
[profile.release.package.lldap_app]
|
||||
opt-level = 's'
|
||||
|
||||
[patch.crates-io.opaque-ke]
|
||||
git = 'https://github.com/nitnelave/opaque-ke/'
|
||||
branch = 'zeroize_1.5'
|
||||
|
||||
[patch.crates-io.lber]
|
||||
git = 'https://github.com/inejge/ldap3/'
|
||||
|
||||
47
Dockerfile
@@ -1,5 +1,5 @@
|
||||
# Build image
|
||||
FROM rust:alpine3.14 AS chef
|
||||
FROM rust:alpine3.16 AS chef
|
||||
|
||||
RUN set -x \
|
||||
# Add user
|
||||
@@ -11,7 +11,7 @@ RUN set -x \
|
||||
--uid 10001 \
|
||||
app \
|
||||
# Install required packages
|
||||
&& apk add npm openssl-dev musl-dev make perl curl
|
||||
&& apk add openssl-dev musl-dev make perl curl gzip
|
||||
|
||||
USER app
|
||||
WORKDIR /app
|
||||
@@ -19,7 +19,6 @@ WORKDIR /app
|
||||
RUN set -x \
|
||||
# Install build tools
|
||||
&& RUSTFLAGS=-Ctarget-feature=-crt-static cargo install wasm-pack cargo-chef \
|
||||
&& npm install rollup \
|
||||
&& rustup target add wasm32-unknown-unknown
|
||||
|
||||
# Prepare the dependency list.
|
||||
@@ -32,27 +31,58 @@ FROM chef AS builder
|
||||
COPY --from=planner /tmp/recipe.json recipe.json
|
||||
RUN cargo chef cook --release -p lldap_app --target wasm32-unknown-unknown \
|
||||
&& cargo chef cook --release -p lldap \
|
||||
&& cargo chef cook --release -p migration-tool
|
||||
&& cargo chef cook --release -p lldap_migration_tool \
|
||||
&& cargo chef cook --release -p lldap_set_password
|
||||
|
||||
# Copy the source and build the app and server.
|
||||
COPY --chown=app:app . .
|
||||
RUN cargo build --release -p lldap -p migration-tool \
|
||||
RUN cargo build --release -p lldap -p lldap_migration_tool -p lldap_set_password \
|
||||
# Build the frontend.
|
||||
&& ./app/build.sh
|
||||
|
||||
# Final image
|
||||
FROM alpine:3.14
|
||||
FROM alpine:3.16
|
||||
|
||||
ENV GOSU_VERSION 1.14
|
||||
# Fetch gosu from git
|
||||
RUN set -eux; \
|
||||
\
|
||||
apk add --no-cache --virtual .gosu-deps \
|
||||
ca-certificates \
|
||||
dpkg \
|
||||
gnupg \
|
||||
; \
|
||||
\
|
||||
dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')"; \
|
||||
wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch"; \
|
||||
wget -O /usr/local/bin/gosu.asc "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch.asc"; \
|
||||
\
|
||||
# verify the signature
|
||||
export GNUPGHOME="$(mktemp -d)"; \
|
||||
gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; \
|
||||
gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \
|
||||
command -v gpgconf && gpgconf --kill all || :; \
|
||||
rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \
|
||||
\
|
||||
# clean up fetch dependencies
|
||||
apk del --no-network .gosu-deps; \
|
||||
\
|
||||
chmod +x /usr/local/bin/gosu; \
|
||||
# verify that the binary works
|
||||
gosu --version; \
|
||||
gosu nobody true
|
||||
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/app/index_local.html app/index.html
|
||||
COPY --from=builder /app/app/static app/static
|
||||
COPY --from=builder /app/app/pkg app/pkg
|
||||
COPY --from=builder /app/target/release/lldap /app/target/release/migration-tool ./
|
||||
COPY --from=builder /app/target/release/lldap /app/target/release/lldap_migration_tool /app/target/release/lldap_set_password ./
|
||||
COPY docker-entrypoint.sh lldap_config.docker_template.toml ./
|
||||
|
||||
RUN set -x \
|
||||
&& apk add --no-cache bash \
|
||||
&& apk add --no-cache bash tzdata \
|
||||
&& for file in $(cat app/static/libraries.txt); do wget -P app/static "$file"; done \
|
||||
&& for file in $(cat app/static/fonts/fonts.txt); do wget -P app/static/fonts "$file"; done \
|
||||
&& chmod a+r -R .
|
||||
@@ -64,3 +94,4 @@ EXPOSE ${LDAP_PORT} ${HTTP_PORT}
|
||||
|
||||
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
||||
CMD ["run", "--config-file", "/data/lldap_config.toml"]
|
||||
HEALTHCHECK CMD ["/app/lldap", "healthcheck", "--config-file", "/data/lldap_config.toml"]
|
||||
|
||||
277
README.md
@@ -13,6 +13,7 @@
|
||||
<a href="https://discord.gg/h5PEdRMNyP">
|
||||
<img alt="Discord" src="https://img.shields.io/discord/898492935446876200?label=discord&logo=discord" />
|
||||
</a>
|
||||
|
||||
<a href="https://twitter.com/nitnelave1?ref_src=twsrc%5Etfw">
|
||||
<img
|
||||
src="https://img.shields.io/twitter/follow/nitnelave1?style=social"
|
||||
@@ -23,25 +24,34 @@
|
||||
src="https://img.shields.io/badge/unsafe-forbidden-success.svg"
|
||||
alt="Unsafe forbidden"/>
|
||||
</a>
|
||||
<a href="https://app.codecov.io/gh/nitnelave/lldap">
|
||||
<img alt="Codecov" src="https://img.shields.io/codecov/c/github/nitnelave/lldap" />
|
||||
<a href="https://app.codecov.io/gh/lldap/lldap">
|
||||
<img alt="Codecov" src="https://img.shields.io/codecov/c/github/lldap/lldap" />
|
||||
</a>
|
||||
<br/>
|
||||
<a href="https://www.buymeacoffee.com/nitnelave" target="_blank">
|
||||
<img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" >
|
||||
</a>
|
||||
</p>
|
||||
|
||||
- [About](#About)
|
||||
- [Installation](#Installation)
|
||||
- [With Docker](#With-Docker)
|
||||
- [From source](#From-source)
|
||||
- [Cross-compilation](#Cross-compilation)
|
||||
- [Client configuration](#Client-configuration)
|
||||
- [Compatible services](#compatible-services)
|
||||
- [General configuration guide](#general-configuration-guide)
|
||||
- [Sample cient configurations](#Sample-client-configurations)
|
||||
- [Comparisons with other services](#Comparisons-with-other-services)
|
||||
- [vs OpenLDAP](#vs-openldap)
|
||||
- [vs FreeIPA](#vs-freeipa)
|
||||
- [I can't log in!](#i-cant-log-in)
|
||||
- [Contributions](#Contributions)
|
||||
- [About](#about)
|
||||
- [Installation](#installation)
|
||||
- [With Docker](#with-docker)
|
||||
- [With Kubernetes](#with-kubernetes)
|
||||
- [From source](#from-source)
|
||||
- [Backend](#backend)
|
||||
- [Frontend](#frontend)
|
||||
- [Cross-compilation](#cross-compilation)
|
||||
- [Client configuration](#client-configuration)
|
||||
- [Compatible services](#compatible-services)
|
||||
- [General configuration guide](#general-configuration-guide)
|
||||
- [Sample client configurations](#sample-client-configurations)
|
||||
- [Migrating from SQLite](#migrating-from-sqlite)
|
||||
- [Comparisons with other services](#comparisons-with-other-services)
|
||||
- [vs OpenLDAP](#vs-openldap)
|
||||
- [vs FreeIPA](#vs-freeipa)
|
||||
- [vs Kanidm](#vs-kanidm)
|
||||
- [I can't log in!](#i-cant-log-in)
|
||||
- [Contributions](#contributions)
|
||||
|
||||
## About
|
||||
|
||||
@@ -62,10 +72,11 @@ edit their own details or reset their password by email.
|
||||
|
||||
The goal is _not_ to provide a full LDAP server; if you're interested in that,
|
||||
check out OpenLDAP. This server is a user management system that is:
|
||||
* simple to setup (no messing around with `slapd`),
|
||||
* simple to manage (friendly web UI),
|
||||
* low resources,
|
||||
* opinionated with basic defaults so you don't have to understand the
|
||||
|
||||
- simple to setup (no messing around with `slapd`),
|
||||
- simple to manage (friendly web UI),
|
||||
- low resources,
|
||||
- opinionated with basic defaults so you don't have to understand the
|
||||
subtleties of LDAP.
|
||||
|
||||
It mostly targets self-hosting servers, with open-source components like
|
||||
@@ -76,6 +87,9 @@ For more features (OAuth/OpenID support, reverse proxy, ...) you can install
|
||||
other components (KeyCloak, Authelia, ...) using this server as the source of
|
||||
truth for users, via LDAP.
|
||||
|
||||
By default, the data is stored in SQLite, but you can swap the backend with
|
||||
MySQL/MariaDB or PostgreSQL.
|
||||
|
||||
## Installation
|
||||
|
||||
### With Docker
|
||||
@@ -90,6 +104,8 @@ Configure the server by copying the `lldap_config.docker_template.toml` to
|
||||
Environment variables should be prefixed with `LLDAP_` to override the
|
||||
configuration.
|
||||
|
||||
If the `lldap_config.toml` doesn't exist when starting up, LLDAP will use default one. The default admin password is `password`, you can change the password later using the web interface.
|
||||
|
||||
Secrets can also be set through a file. The filename should be specified by the
|
||||
variables `LLDAP_JWT_SECRET_FILE` or `LLDAP_LDAP_USER_PASS_FILE`, and the file
|
||||
contents are loaded into the respective configuration parameters. Note that
|
||||
@@ -97,7 +113,14 @@ contents are loaded into the respective configuration parameters. Note that
|
||||
|
||||
Example for docker compose:
|
||||
|
||||
- You can use either the `:latest` tag image or `:stable` as used in this example.
|
||||
- `:latest` tag image contains recently pushed code or feature tests, in which some instability can be expected.
|
||||
- If `UID` and `GID` no defined LLDAP will use default `UID` and `GID` number `1000`.
|
||||
- If no `TZ` is set, default `UTC` timezone will be used.
|
||||
|
||||
```yaml
|
||||
version: "3"
|
||||
|
||||
volumes:
|
||||
lldap_data:
|
||||
driver: local
|
||||
@@ -105,11 +128,11 @@ volumes:
|
||||
services:
|
||||
lldap:
|
||||
image: nitnelave/lldap:stable
|
||||
# Change this to the user:group you want.
|
||||
user: "33:33"
|
||||
ports:
|
||||
# For LDAP
|
||||
- "3890:3890"
|
||||
# For LDAPS (LDAP Over SSL), enable port if LLDAP_LDAPS_OPTIONS__ENABLED set true, look env below
|
||||
#- "6360:6360"
|
||||
# For the web front-end
|
||||
- "17170:17170"
|
||||
volumes:
|
||||
@@ -117,29 +140,69 @@ services:
|
||||
# Alternatively, you can mount a local folder
|
||||
# - "./lldap_data:/data"
|
||||
environment:
|
||||
- UID=####
|
||||
- GID=####
|
||||
- TZ=####/####
|
||||
- LLDAP_JWT_SECRET=REPLACE_WITH_RANDOM
|
||||
- LLDAP_LDAP_USER_PASS=REPLACE_WITH_PASSWORD
|
||||
- LLDAP_LDAP_BASE_DN=dc=example,dc=com
|
||||
# If using LDAPS, set enabled true and configure cert and key path
|
||||
# - LLDAP_LDAPS_OPTIONS__ENABLED=true
|
||||
# - LLDAP_LDAPS_OPTIONS__CERT_FILE=/path/to/certfile.crt
|
||||
# - LLDAP_LDAPS_OPTIONS__KEY_FILE=/path/to/keyfile.key
|
||||
# You can also set a different database:
|
||||
# - LLDAP_DATABASE_URL=mysql://mysql-user:password@mysql-server/my-database
|
||||
# - LLDAP_DATABASE_URL=postgres://postgres-user:password@postgres-server/my-database
|
||||
```
|
||||
|
||||
Then the service will listen on two ports, one for LDAP and one for the web
|
||||
front-end.
|
||||
|
||||
### With Kubernetes
|
||||
|
||||
See https://github.com/Evantage-WS/lldap-kubernetes for a LLDAP deployment for Kubernetes
|
||||
|
||||
### From source
|
||||
|
||||
#### Backend
|
||||
|
||||
To compile the project, you'll need:
|
||||
|
||||
- curl and gzip: `sudo apt install curl gzip`
|
||||
- Rust/Cargo: [rustup.rs](https://rustup.rs/)
|
||||
|
||||
Then you can compile the server (and the migration tool if you want):
|
||||
|
||||
```shell
|
||||
cargo build --release -p lldap -p lldap_migration_tool
|
||||
```
|
||||
|
||||
The resulting binaries will be in `./target/release/`. Alternatively, you can
|
||||
just run `cargo run -- run` to run the server.
|
||||
|
||||
#### Frontend
|
||||
|
||||
To bring up the server, you'll need to compile the frontend. In addition to
|
||||
cargo, you'll need:
|
||||
`cargo`, you'll need:
|
||||
|
||||
* WASM-pack: `cargo install wasm-pack`
|
||||
* rollup.js: `npm install rollup`
|
||||
- WASM-pack: `cargo install wasm-pack`
|
||||
|
||||
Then you can build the frontend files with `./app/build.sh` (you'll need to run
|
||||
this after every front-end change to update the WASM package served).
|
||||
Then you can build the frontend files with
|
||||
|
||||
To bring up the server, just run `cargo run`. The default config is in
|
||||
`src/infra/configuration.rs`, but you can override it by creating an
|
||||
`lldap_config.toml`, setting environment variables or passing arguments to
|
||||
`cargo run`.
|
||||
```shell
|
||||
./app/build.sh
|
||||
````
|
||||
|
||||
(you'll need to run this after every front-end change to update the WASM
|
||||
package served).
|
||||
|
||||
The default config is in `src/infra/configuration.rs`, but you can override it
|
||||
by creating an `lldap_config.toml`, setting environment variables or passing
|
||||
arguments to `cargo run`. Have a look at the docker template:
|
||||
`lldap_config.docker_template.toml`.
|
||||
|
||||
You can also install it as a systemd service, see
|
||||
[lldap.service](example_configs/lldap.service).
|
||||
|
||||
### Cross-compilation
|
||||
|
||||
@@ -176,14 +239,15 @@ the config).
|
||||
### General configuration guide
|
||||
|
||||
To configure the services that will talk to LLDAP, here are the values:
|
||||
- The LDAP user DN is from the configuration. By default,
|
||||
`cn=admin,ou=people,dc=example,dc=com`.
|
||||
- The LDAP password is from the configuration (same as to log in to the web
|
||||
UI).
|
||||
- The users are all located in `ou=people,` + the base DN, so by default user
|
||||
`bob` is at `cn=bob,ou=people,dc=example,dc=com`.
|
||||
- Similarly, the groups are located in `ou=groups`, so the group `family`
|
||||
will be at `cn=family,ou=groups,dc=example,dc=com`.
|
||||
|
||||
- The LDAP user DN is from the configuration. By default,
|
||||
`cn=admin,ou=people,dc=example,dc=com`.
|
||||
- The LDAP password is from the configuration (same as to log in to the web
|
||||
UI).
|
||||
- The users are all located in `ou=people,` + the base DN, so by default user
|
||||
`bob` is at `cn=bob,ou=people,dc=example,dc=com`.
|
||||
- Similarly, the groups are located in `ou=groups`, so the group `family`
|
||||
will be at `cn=family,ou=groups,dc=example,dc=com`.
|
||||
|
||||
Testing group membership through `memberOf` is supported, so you can have a
|
||||
filter like: `(memberOf=cn=admins,ou=groups,dc=example,dc=com)`.
|
||||
@@ -198,75 +262,120 @@ administration access to many services.
|
||||
Some specific clients have been tested to work and come with sample
|
||||
configuration files, or guides. See the [`example_configs`](example_configs)
|
||||
folder for help with:
|
||||
- [Apache Guacamole](example_configs/apacheguacamole.md)
|
||||
- [Authelia](example_configs/authelia_config.yml)
|
||||
- [Bookstack](example_configs/bookstack.env.example)
|
||||
- [Calibre-Web](example_configs/calibre_web.md)
|
||||
- [Dolibarr](example_configs/dolibarr.md)
|
||||
- [Emby](example_configs/emby.md)
|
||||
- [Gitea](example_configs/gitea.md)
|
||||
- [Grafana](example_configs/grafana_ldap_config.toml)
|
||||
- [Jellyfin](example_configs/jellyfin.md)
|
||||
- [Jisti Meet](example_configs/jitsi_meet.conf)
|
||||
- [KeyCloak](example_configs/keycloak.md)
|
||||
- [Matrix](example_configs/matrix_synapse.yml)
|
||||
- [Organizr](example_configs/Organizr.md)
|
||||
- [Portainer](example_configs/portainer.md)
|
||||
- [Seafile](example_configs/seafile.md)
|
||||
- [Syncthing](example_configs/syncthing.md)
|
||||
- [WG Portal](example_configs/wg_portal.env.example)
|
||||
|
||||
- [Airsonic Advanced](example_configs/airsonic-advanced.md)
|
||||
- [Apache Guacamole](example_configs/apacheguacamole.md)
|
||||
- [Authelia](example_configs/authelia_config.yml)
|
||||
- [Authentik](example_configs/authentik.md)
|
||||
- [Bookstack](example_configs/bookstack.env.example)
|
||||
- [Calibre-Web](example_configs/calibre_web.md)
|
||||
- [Dell iDRAC](example_configs/dell_idrac.md)
|
||||
- [Dex](example_configs/dex_config.yml)
|
||||
- [Dokuwiki](example_configs/dokuwiki.md)
|
||||
- [Dolibarr](example_configs/dolibarr.md)
|
||||
- [Ejabberd](example_configs/ejabberd.md)
|
||||
- [Emby](example_configs/emby.md)
|
||||
- [Ergo IRCd](example_configs/ergo.md)
|
||||
- [Gitea](example_configs/gitea.md)
|
||||
- [Grafana](example_configs/grafana_ldap_config.toml)
|
||||
- [Hedgedoc](example_configs/hedgedoc.md)
|
||||
- [Jellyfin](example_configs/jellyfin.md)
|
||||
- [Jitsi Meet](example_configs/jitsi_meet.conf)
|
||||
- [KeyCloak](example_configs/keycloak.md)
|
||||
- [LibreNMS](example_configs/librenms.md)
|
||||
- [Matrix](example_configs/matrix_synapse.yml)
|
||||
- [Mealie](example_configs/mealie.md)
|
||||
- [MinIO](example_configs/minio.md)
|
||||
- [Nextcloud](example_configs/nextcloud.md)
|
||||
- [Nexus](example_configs/nexus.md)
|
||||
- [Organizr](example_configs/Organizr.md)
|
||||
- [Portainer](example_configs/portainer.md)
|
||||
- [PowerDNS Admin](example_configs/powerdns_admin.md)
|
||||
- [Proxmox VE](example_configs/proxmox.md)
|
||||
- [Rancher](example_configs/rancher.md)
|
||||
- [Seafile](example_configs/seafile.md)
|
||||
- [Shaarli](example_configs/shaarli.md)
|
||||
- [Squid](example_configs/squid.md)
|
||||
- [Syncthing](example_configs/syncthing.md)
|
||||
- [TheLounge](example_configs/thelounge.md)
|
||||
- [Vaultwarden](example_configs/vaultwarden.md)
|
||||
- [WeKan](example_configs/wekan.md)
|
||||
- [WG Portal](example_configs/wg_portal.env.example)
|
||||
- [WikiJS](example_configs/wikijs.md)
|
||||
- [XBackBone](example_configs/xbackbone_config.php)
|
||||
- [Zendto](example_configs/zendto.md)
|
||||
- [Zulip](example_configs/zulip.md)
|
||||
|
||||
## Migrating from SQLite
|
||||
|
||||
If you started with an SQLite database and would like to migrate to
|
||||
MySQL/MariaDB or PostgreSQL, check out the [DB
|
||||
migration docs](/docs/database_migration.md).
|
||||
|
||||
## Comparisons with other services
|
||||
|
||||
### vs OpenLDAP
|
||||
|
||||
OpenLDAP is a monster of a service that implements all of LDAP and all of its
|
||||
extensions, plus some of its own. That said, if you need all that flexibility,
|
||||
it might be what you need! Note that installation can be a bit painful
|
||||
(figuring out how to use `slapd`) and people have mixed experiences following
|
||||
tutorials online. If you don't configure it properly, you might end up storing
|
||||
passwords in clear, so a breach of your server would reveal all the stored
|
||||
passwords!
|
||||
[OpenLDAP](https://www.openldap.org) is a monster of a service that implements
|
||||
all of LDAP and all of its extensions, plus some of its own. That said, if you
|
||||
need all that flexibility, it might be what you need! Note that installation
|
||||
can be a bit painful (figuring out how to use `slapd`) and people have mixed
|
||||
experiences following tutorials online. If you don't configure it properly, you
|
||||
might end up storing passwords in clear, so a breach of your server would
|
||||
reveal all the stored passwords!
|
||||
|
||||
OpenLDAP doesn't come with a UI: if you want a web interface, you'll have to
|
||||
install one (not that many that look nice) and configure it.
|
||||
install one (not that many look nice) and configure it.
|
||||
|
||||
LLDAP is much simpler to setup, has a much smaller image (10x smaller, 20x if
|
||||
you add PhpLdapAdmin), and comes packed with its own purpose-built wed UI.
|
||||
you add PhpLdapAdmin), and comes packed with its own purpose-built web UI.
|
||||
However, it's not as flexible as OpenLDAP.
|
||||
|
||||
### vs FreeIPA
|
||||
|
||||
FreeIPA is the one-stop shop for identity management: LDAP, Kerberos, NTP, DNS,
|
||||
Samba, you name it, it has it. In addition to user management, it also does
|
||||
security policies, single sign-on, certificate management, linux account
|
||||
management and so on.
|
||||
[FreeIPA](http://www.freeipa.org) is the one-stop shop for identity management:
|
||||
LDAP, Kerberos, NTP, DNS, Samba, you name it, it has it. In addition to user
|
||||
management, it also does security policies, single sign-on, certificate
|
||||
management, linux account management and so on.
|
||||
|
||||
If you need all of that, go for it! Keep in mind that a more complex system is
|
||||
more complex to maintain, though.
|
||||
|
||||
LLDAP is much lighter to run (<100 MB RAM including the DB), easier to
|
||||
LLDAP is much lighter to run (<10 MB RAM including the DB), easier to
|
||||
configure (no messing around with DNS or security policies) and simpler to
|
||||
use. It also comes conveniently packed in a docker container.
|
||||
|
||||
### vs Kanidm
|
||||
|
||||
[Kanidm](https://kanidm.com) is an up-and-coming Rust identity management
|
||||
platform, covering all your bases: OAuth, Linux accounts, SSH keys, Radius,
|
||||
WebAuthn. It comes with a (read-only) LDAPS server.
|
||||
|
||||
It's fairly easy to install and does much more; but their LDAP server is
|
||||
read-only, and by having more moving parts it is inherently more complex. If
|
||||
you don't need to modify the users through LDAP and you're planning on
|
||||
installing something like [KeyCloak](https://www.keycloak.org) to provide
|
||||
modern identity protocols, check out Kanidm.
|
||||
|
||||
## I can't log in!
|
||||
|
||||
If you just set up the server, can get to the login page but the password you
|
||||
set isn't working, try the following:
|
||||
|
||||
- (For docker): Make sure that the `/data` folder is persistent, either to a
|
||||
docker volume or mounted from the host filesystem.
|
||||
- Check if there is a `lldap_config.toml` file (either in `/data` for docker
|
||||
or in the current directory). If there isn't, copy
|
||||
`lldap_config.docker_template.toml` there, and fill in the various values
|
||||
(passwords, secrets, ...).
|
||||
- Check if there is a `users.db` file (either in `/data` for docker or where
|
||||
you specified the DB URL, which defaults to the current directory). If
|
||||
there isn't, check that the user running the command (user with ID 10001
|
||||
for docker) has the rights to write to the `/data` folder. If in doubt, you
|
||||
can `chmod 777 /data` (or whatever the folder) to make it world-writeable.
|
||||
- Make sure you restart the server.
|
||||
- If it's still not working, join the
|
||||
[Discord server](https://discord.gg/h5PEdRMNyP) to ask for help.
|
||||
- (For docker): Make sure that the `/data` folder is persistent, either to a
|
||||
docker volume or mounted from the host filesystem.
|
||||
- Check if there is a `lldap_config.toml` file (either in `/data` for docker
|
||||
or in the current directory). If there isn't, copy
|
||||
`lldap_config.docker_template.toml` there, and fill in the various values
|
||||
(passwords, secrets, ...).
|
||||
- Check if there is a `users.db` file (either in `/data` for docker or where
|
||||
you specified the DB URL, which defaults to the current directory). If
|
||||
there isn't, check that the user running the command (user with ID 10001
|
||||
for docker) has the rights to write to the `/data` folder. If in doubt, you
|
||||
can `chmod 777 /data` (or whatever the folder) to make it world-writeable.
|
||||
- Make sure you restart the server.
|
||||
- If it's still not working, join the
|
||||
[Discord server](https://discord.gg/h5PEdRMNyP) to ask for help.
|
||||
|
||||
## Contributions
|
||||
|
||||
|
||||
@@ -1,25 +1,33 @@
|
||||
[package]
|
||||
name = "lldap_app"
|
||||
version = "0.4.0"
|
||||
authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
|
||||
description = "Frontend for LLDAP"
|
||||
edition = "2021"
|
||||
homepage = "https://github.com/lldap/lldap"
|
||||
license = "GPL-3.0-only"
|
||||
name = "lldap_app"
|
||||
repository = "https://github.com/lldap/lldap"
|
||||
version = "0.5.0"
|
||||
include = ["src/**/*", "queries/**/*", "Cargo.toml", "../schema.graphql"]
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
base64 = "0.13"
|
||||
gloo-console = "0.2.3"
|
||||
gloo-file = "0.2.3"
|
||||
gloo-net = "*"
|
||||
graphql_client = "0.10"
|
||||
http = "0.2"
|
||||
jwt = "0.13"
|
||||
rand = "0.8"
|
||||
serde = "1"
|
||||
serde_json = "1"
|
||||
url-escape = "0.1.1"
|
||||
validator = "=0.14"
|
||||
validator_derive = "*"
|
||||
wasm-bindgen = "0.2"
|
||||
yew = "0.18"
|
||||
yewtil = "*"
|
||||
yew-router = "0.15"
|
||||
yew_form = "0.1.8"
|
||||
yew_form_derive = "*"
|
||||
wasm-bindgen-futures = "*"
|
||||
yew = "0.19.3"
|
||||
yew-router = "0.16"
|
||||
|
||||
# Needed because of https://github.com/tkaitchuck/aHash/issues/95
|
||||
indexmap = "=1.6.2"
|
||||
@@ -29,6 +37,7 @@ version = "0.3"
|
||||
features = [
|
||||
"Document",
|
||||
"Element",
|
||||
"FileReader",
|
||||
"HtmlDocument",
|
||||
"HtmlInputElement",
|
||||
"HtmlOptionElement",
|
||||
@@ -47,5 +56,18 @@ features = [
|
||||
path = "../auth"
|
||||
features = [ "opaque_client" ]
|
||||
|
||||
[dependencies.image]
|
||||
features = ["jpeg"]
|
||||
default-features = false
|
||||
version = "0.24"
|
||||
|
||||
[dependencies.yew_form]
|
||||
git = "https://github.com/jfbilodeau/yew_form"
|
||||
rev = "4b9fabffb63393ec7626a4477fd36de12a07fac9"
|
||||
|
||||
[dependencies.yew_form_derive]
|
||||
git = "https://github.com/jfbilodeau/yew_form"
|
||||
rev = "4b9fabffb63393ec7626a4477fd36de12a07fac9"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
20
app/build.sh
@@ -6,22 +6,12 @@ then
|
||||
>&2 echo '`wasm-pack` not found. Try running `cargo install wasm-pack`'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
wasm-pack build --target web
|
||||
|
||||
ROLLUP_BIN=$(which rollup 2>/dev/null)
|
||||
if [ -f ../node_modules/rollup/dist/bin/rollup ]
|
||||
if ! which gzip > /dev/null 2>&1
|
||||
then
|
||||
ROLLUP_BIN=../node_modules/rollup/dist/bin/rollup
|
||||
elif [ -f node_modules/rollup/dist/bin/rollup ]
|
||||
then
|
||||
ROLLUP_BIN=node_modules/rollup/dist/bin/rollup
|
||||
fi
|
||||
|
||||
if [ -z "$ROLLUP_BIN" ]
|
||||
then
|
||||
>&2 echo '`rollup` not found. Try running `npm install rollup`'
|
||||
>&2 echo '`gzip` not found.'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
$ROLLUP_BIN ./main.js --format iife --file ./pkg/bundle.js --globals bootstrap:bootstrap
|
||||
wasm-pack build --target web --release
|
||||
|
||||
gzip -9 -k -f pkg/lldap_app_bg.wasm
|
||||
|
||||
@@ -4,17 +4,21 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>LLDAP Administration</title>
|
||||
<script src="/pkg/bundle.js" defer></script>
|
||||
<script src="/static/main.js" type="module" defer></script>
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/css/bootstrap.min.css"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap-dark-5@1.1.3/dist/css/bootstrap-nightshade.min.css"
|
||||
rel="preload stylesheet"
|
||||
integrity="sha384-+0n0xVW2eSR5OomGNYDnhzAbDsOXxcvSN1TPprVMTNDbiYZCxYbOOl7+AMvyTG2x"
|
||||
integrity="sha384-CvItGYrXmque42UjYhp+bjRR8tgQz78Nlwk42gYsNzBc6y0DuXNtdUaRzr1cl2uK"
|
||||
crossorigin="anonymous"
|
||||
as="style" />
|
||||
<script
|
||||
src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.1/dist/js/bootstrap.bundle.min.js"
|
||||
integrity="sha384-/bQdsTh/da6pkI1MST/rWKFNjaCP5gBSY4sEBT38Q/9RBh9AH40zEOg7Hlq2THRZ"
|
||||
crossorigin="anonymous"></script>
|
||||
<script
|
||||
src="https://cdn.jsdelivr.net/npm/bootstrap-dark-5@1.1.3/dist/js/darkmode.min.js"
|
||||
integrity="sha384-A4SLs39X/aUfwRclRaXvNeXNBTLZdnZdHhhteqbYFS2jZTRD79tKeFeBn7SGXNpi"
|
||||
crossorigin="anonymous"></script>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.5.0/font/bootstrap-icons.css"
|
||||
@@ -30,10 +34,32 @@
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="/static/style.css" />
|
||||
<script>
|
||||
function inDarkMode(){
|
||||
return darkmode.inDarkMode;
|
||||
}
|
||||
</script>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>
|
||||
<!-- This will be displayed if the user doesn't have JavaScript enabled. -->
|
||||
LLDAP requires JavaScript, please switch to a compatible browser or
|
||||
enable it.
|
||||
</noscript>
|
||||
|
||||
<script>
|
||||
/* Detect if the user has WASM support. */
|
||||
if (typeof WebAssembly === 'undefined') {
|
||||
const pWASMMsg = document.createElement("p")
|
||||
pWASMMsg.innerHTML = `
|
||||
LLDAP requires WASM and JIT for JavaScript, please switch to a
|
||||
compatible browser or enable it.
|
||||
`
|
||||
document.body.appendChild(pWASMMsg)
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -4,15 +4,18 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>LLDAP Administration</title>
|
||||
<script src="/pkg/bundle.js" defer></script>
|
||||
<script src="/static/main.js" type="module" defer></script>
|
||||
<link
|
||||
href="/static/bootstrap.min.css"
|
||||
href="/static/bootstrap-nightshade.min.css"
|
||||
rel="preload stylesheet"
|
||||
integrity="sha384-+0n0xVW2eSR5OomGNYDnhzAbDsOXxcvSN1TPprVMTNDbiYZCxYbOOl7+AMvyTG2x"
|
||||
integrity="sha384-CvItGYrXmque42UjYhp+bjRR8tgQz78Nlwk42gYsNzBc6y0DuXNtdUaRzr1cl2uK"
|
||||
as="style" />
|
||||
<script
|
||||
src="/static/bootstrap.bundle.min.js"
|
||||
integrity="sha384-/bQdsTh/da6pkI1MST/rWKFNjaCP5gBSY4sEBT38Q/9RBh9AH40zEOg7Hlq2THRZ"></script>
|
||||
<script
|
||||
src="/static/darkmode.min.js"
|
||||
integrity="sha384-A4SLs39X/aUfwRclRaXvNeXNBTLZdnZdHhhteqbYFS2jZTRD79tKeFeBn7SGXNpi"></script>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="/static/bootstrap-icons.css"
|
||||
@@ -28,10 +31,32 @@
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="/static/style.css" />
|
||||
<script>
|
||||
function inDarkMode(){
|
||||
return darkmode.inDarkMode;
|
||||
}
|
||||
</script>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>
|
||||
<!-- This will be displayed if the user doesn't have JavaScript enabled. -->
|
||||
LLDAP requires JavaScript, please switch to a compatible browser or
|
||||
enable it.
|
||||
</noscript>
|
||||
|
||||
<script>
|
||||
/* Detect if the user has WASM support. */
|
||||
if (typeof WebAssembly === 'undefined') {
|
||||
const pWASMMsg = document.createElement("p")
|
||||
pWASMMsg.innerHTML = `
|
||||
LLDAP requires WASM and JIT for JavaScript, please switch to a
|
||||
compatible browser or enable it.
|
||||
`
|
||||
document.body.appendChild(pWASMMsg)
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import init, { run_app } from './pkg/lldap_app.js';
|
||||
async function main() {
|
||||
await init('/pkg/lldap_app_bg.wasm');
|
||||
run_app();
|
||||
}
|
||||
main()
|
||||
@@ -2,6 +2,8 @@ query GetGroupDetails($id: Int!) {
|
||||
group(groupId: $id) {
|
||||
id
|
||||
displayName
|
||||
creationDate
|
||||
uuid
|
||||
users {
|
||||
id
|
||||
displayName
|
||||
|
||||
@@ -2,5 +2,6 @@ query GetGroupList {
|
||||
groups {
|
||||
id
|
||||
displayName
|
||||
creationDate
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ query GetUserDetails($id: String!) {
|
||||
displayName
|
||||
firstName
|
||||
lastName
|
||||
avatar
|
||||
creationDate
|
||||
uuid
|
||||
groups {
|
||||
id
|
||||
displayName
|
||||
|
||||
@@ -52,23 +52,25 @@ pub struct Props {
|
||||
}
|
||||
|
||||
impl CommonComponent<AddGroupMemberComponent> for AddGroupMemberComponent {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::UserListResponse(response) => {
|
||||
self.user_list = Some(response?.users);
|
||||
self.common.cancel_task();
|
||||
}
|
||||
Msg::SubmitAddMember => return self.submit_add_member(),
|
||||
Msg::SubmitAddMember => return self.submit_add_member(ctx),
|
||||
Msg::AddMemberResponse(response) => {
|
||||
response?;
|
||||
self.common.cancel_task();
|
||||
let user = self
|
||||
.selected_user
|
||||
.as_ref()
|
||||
.expect("Could not get selected user")
|
||||
.clone();
|
||||
// Remove the user from the dropdown.
|
||||
self.common.on_user_added_to_group.emit(user);
|
||||
ctx.props().on_user_added_to_group.emit(user);
|
||||
}
|
||||
Msg::SelectionChanged(option_props) => {
|
||||
let was_some = self.selected_user.is_some();
|
||||
@@ -88,23 +90,25 @@ impl CommonComponent<AddGroupMemberComponent> for AddGroupMemberComponent {
|
||||
}
|
||||
|
||||
impl AddGroupMemberComponent {
|
||||
fn get_user_list(&mut self) {
|
||||
fn get_user_list(&mut self, ctx: &Context<Self>) {
|
||||
self.common.call_graphql::<ListUserNames, _>(
|
||||
ctx,
|
||||
list_user_names::Variables { filters: None },
|
||||
Msg::UserListResponse,
|
||||
"Error trying to fetch user list",
|
||||
);
|
||||
}
|
||||
|
||||
fn submit_add_member(&mut self) -> Result<bool> {
|
||||
fn submit_add_member(&mut self, ctx: &Context<Self>) -> Result<bool> {
|
||||
let user_id = match self.selected_user.clone() {
|
||||
None => return Ok(false),
|
||||
Some(user) => user.id,
|
||||
};
|
||||
self.common.call_graphql::<AddUserToGroup, _>(
|
||||
ctx,
|
||||
add_user_to_group::Variables {
|
||||
user: user_id,
|
||||
group: self.common.group_id,
|
||||
group: ctx.props().group_id,
|
||||
},
|
||||
Msg::AddMemberResponse,
|
||||
"Error trying to initiate adding the user to a group",
|
||||
@@ -112,8 +116,8 @@ impl AddGroupMemberComponent {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn get_selectable_user_list(&self, user_list: &[User]) -> Vec<User> {
|
||||
let user_groups = self.common.users.iter().collect::<HashSet<_>>();
|
||||
fn get_selectable_user_list(&self, ctx: &Context<Self>, user_list: &[User]) -> Vec<User> {
|
||||
let user_groups = ctx.props().users.iter().collect::<HashSet<_>>();
|
||||
user_list
|
||||
.iter()
|
||||
.filter(|u| !user_groups.contains(u))
|
||||
@@ -126,41 +130,39 @@ impl Component for AddGroupMemberComponent {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut res = Self {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
user_list: None,
|
||||
selected_user: None,
|
||||
};
|
||||
res.get_user_list();
|
||||
res.get_user_list(ctx);
|
||||
res
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update_and_report_error(
|
||||
self,
|
||||
ctx,
|
||||
msg,
|
||||
self.common.on_error.clone(),
|
||||
ctx.props().on_error.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
if let Some(user_list) = &self.user_list {
|
||||
let to_add_user_list = self.get_selectable_user_list(user_list);
|
||||
let to_add_user_list = self.get_selectable_user_list(ctx, user_list);
|
||||
#[allow(unused_braces)]
|
||||
let make_select_option = |user: User| {
|
||||
html_nested! {
|
||||
<SelectOption value=user.id.clone() text=user.display_name.clone() key=user.id />
|
||||
<SelectOption value={user.id.clone()} text={user.display_name.clone()} key={user.id} />
|
||||
}
|
||||
};
|
||||
html! {
|
||||
<div class="row">
|
||||
<div class="col-sm-3">
|
||||
<Select on_selection_change=self.common.callback(Msg::SelectionChanged)>
|
||||
<Select on_selection_change={link.callback(Msg::SelectionChanged)}>
|
||||
{
|
||||
to_add_user_list
|
||||
.into_iter()
|
||||
@@ -169,12 +171,13 @@ impl Component for AddGroupMemberComponent {
|
||||
}
|
||||
</Select>
|
||||
</div>
|
||||
<div class="col-sm-1">
|
||||
<div class="col-3">
|
||||
<button
|
||||
class="btn btn-success"
|
||||
disabled=self.selected_user.is_none() || self.common.is_task_running()
|
||||
onclick=self.common.callback(|_| Msg::SubmitAddMember)>
|
||||
{"Add"}
|
||||
class="btn btn-secondary"
|
||||
disabled={self.selected_user.is_none() || self.common.is_task_running()}
|
||||
onclick={link.callback(|_| Msg::SubmitAddMember)}>
|
||||
<i class="bi-person-plus me-2"></i>
|
||||
{"Add to group"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -64,16 +64,18 @@ pub struct Props {
|
||||
}
|
||||
|
||||
impl CommonComponent<AddUserToGroupComponent> for AddUserToGroupComponent {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::GroupListResponse(response) => {
|
||||
self.group_list = Some(response?.groups.into_iter().map(Into::into).collect());
|
||||
self.common.cancel_task();
|
||||
}
|
||||
Msg::SubmitAddGroup => return self.submit_add_group(),
|
||||
Msg::SubmitAddGroup => return self.submit_add_group(ctx),
|
||||
Msg::AddGroupResponse(response) => {
|
||||
response?;
|
||||
self.common.cancel_task();
|
||||
// Adding the user to the group succeeded, we're not in the process of adding a
|
||||
// group anymore.
|
||||
let group = self
|
||||
@@ -82,7 +84,7 @@ impl CommonComponent<AddUserToGroupComponent> for AddUserToGroupComponent {
|
||||
.expect("Could not get selected group")
|
||||
.clone();
|
||||
// Remove the group from the dropdown.
|
||||
self.common.on_user_added_to_group.emit(group);
|
||||
ctx.props().on_user_added_to_group.emit(group);
|
||||
}
|
||||
Msg::SelectionChanged(option_props) => {
|
||||
let was_some = self.selected_group.is_some();
|
||||
@@ -102,22 +104,24 @@ impl CommonComponent<AddUserToGroupComponent> for AddUserToGroupComponent {
|
||||
}
|
||||
|
||||
impl AddUserToGroupComponent {
|
||||
fn get_group_list(&mut self) {
|
||||
fn get_group_list(&mut self, ctx: &Context<Self>) {
|
||||
self.common.call_graphql::<GetGroupList, _>(
|
||||
ctx,
|
||||
get_group_list::Variables,
|
||||
Msg::GroupListResponse,
|
||||
"Error trying to fetch group list",
|
||||
);
|
||||
}
|
||||
|
||||
fn submit_add_group(&mut self) -> Result<bool> {
|
||||
fn submit_add_group(&mut self, ctx: &Context<Self>) -> Result<bool> {
|
||||
let group_id = match &self.selected_group {
|
||||
None => return Ok(false),
|
||||
Some(group) => group.id,
|
||||
};
|
||||
self.common.call_graphql::<AddUserToGroup, _>(
|
||||
ctx,
|
||||
add_user_to_group::Variables {
|
||||
user: self.common.username.clone(),
|
||||
user: ctx.props().username.clone(),
|
||||
group: group_id,
|
||||
},
|
||||
Msg::AddGroupResponse,
|
||||
@@ -126,8 +130,8 @@ impl AddUserToGroupComponent {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn get_selectable_group_list(&self, group_list: &[Group]) -> Vec<Group> {
|
||||
let user_groups = self.common.groups.iter().collect::<HashSet<_>>();
|
||||
fn get_selectable_group_list(&self, props: &Props, group_list: &[Group]) -> Vec<Group> {
|
||||
let user_groups = props.groups.iter().collect::<HashSet<_>>();
|
||||
group_list
|
||||
.iter()
|
||||
.filter(|g| !user_groups.contains(g))
|
||||
@@ -139,41 +143,39 @@ impl AddUserToGroupComponent {
|
||||
impl Component for AddUserToGroupComponent {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut res = Self {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
group_list: None,
|
||||
selected_group: None,
|
||||
};
|
||||
res.get_group_list();
|
||||
res.get_group_list(ctx);
|
||||
res
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update_and_report_error(
|
||||
self,
|
||||
ctx,
|
||||
msg,
|
||||
self.common.on_error.clone(),
|
||||
ctx.props().on_error.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
if let Some(group_list) = &self.group_list {
|
||||
let to_add_group_list = self.get_selectable_group_list(group_list);
|
||||
let to_add_group_list = self.get_selectable_group_list(ctx.props(), group_list);
|
||||
#[allow(unused_braces)]
|
||||
let make_select_option = |group: Group| {
|
||||
html_nested! {
|
||||
<SelectOption value=group.id.to_string() text=group.display_name key=group.id />
|
||||
<SelectOption value={group.id.to_string()} text={group.display_name} key={group.id} />
|
||||
}
|
||||
};
|
||||
html! {
|
||||
<div class="row">
|
||||
<div class="col-sm-3">
|
||||
<Select on_selection_change=self.common.callback(Msg::SelectionChanged)>
|
||||
<Select on_selection_change={link.callback(Msg::SelectionChanged)}>
|
||||
{
|
||||
to_add_group_list
|
||||
.into_iter()
|
||||
@@ -182,12 +184,13 @@ impl Component for AddUserToGroupComponent {
|
||||
}
|
||||
</Select>
|
||||
</div>
|
||||
<div class="col-sm-1">
|
||||
<div class="col-sm-3">
|
||||
<button
|
||||
class="btn btn-success"
|
||||
disabled=self.selected_group.is_none() || self.common.is_task_running()
|
||||
onclick=self.common.callback(|_| Msg::SubmitAddGroup)>
|
||||
{"Add"}
|
||||
class="btn btn-secondary"
|
||||
disabled={self.selected_group.is_none() || self.common.is_task_running()}
|
||||
onclick={link.callback(|_| Msg::SubmitAddGroup)}>
|
||||
<i class="bi-person-plus me-2"></i>
|
||||
{"Add to group"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,161 +9,208 @@ use crate::{
|
||||
logout::LogoutButton,
|
||||
reset_password_step1::ResetPasswordStep1Form,
|
||||
reset_password_step2::ResetPasswordStep2Form,
|
||||
router::{AppRoute, Link, NavButton},
|
||||
router::{AppRoute, Link, Redirect},
|
||||
user_details::UserDetails,
|
||||
user_table::UserTable,
|
||||
},
|
||||
infra::cookies::get_cookie,
|
||||
};
|
||||
use yew::prelude::*;
|
||||
use yew::services::ConsoleService;
|
||||
use yew_router::{
|
||||
agent::{RouteAgentDispatcher, RouteRequest},
|
||||
route::Route,
|
||||
router::Router,
|
||||
service::RouteService,
|
||||
infra::{api::HostService, cookies::get_cookie},
|
||||
};
|
||||
|
||||
use gloo_console::error;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use yew::{
|
||||
function_component,
|
||||
html::Scope,
|
||||
prelude::{html, Component, Html},
|
||||
Context,
|
||||
};
|
||||
use yew_router::{
|
||||
prelude::{History, Location},
|
||||
scope_ext::RouterScopeExt,
|
||||
BrowserRouter, Switch,
|
||||
};
|
||||
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_namespace = darkmode)]
|
||||
fn toggleDarkMode(doSave: bool);
|
||||
|
||||
#[wasm_bindgen]
|
||||
fn inDarkMode() -> bool;
|
||||
}
|
||||
|
||||
#[function_component(DarkModeToggle)]
|
||||
pub fn dark_mode_toggle() -> Html {
|
||||
html! {
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" onclick={|_| toggleDarkMode(true)} type="checkbox" id="darkModeToggle" checked={inDarkMode()}/>
|
||||
<label class="form-check-label" for="darkModeToggle">{"Dark mode"}</label>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[function_component(AppContainer)]
|
||||
pub fn app_container() -> Html {
|
||||
html! {
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
}
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
link: ComponentLink<Self>,
|
||||
user_info: Option<(String, bool)>,
|
||||
redirect_to: Option<AppRoute>,
|
||||
route_dispatcher: RouteAgentDispatcher,
|
||||
password_reset_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
Login((String, bool)),
|
||||
Logout,
|
||||
PasswordResetProbeFinished(anyhow::Result<bool>),
|
||||
}
|
||||
|
||||
impl Component for App {
|
||||
type Message = Msg;
|
||||
type Properties = ();
|
||||
|
||||
fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
let mut app = Self {
|
||||
link,
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let app = Self {
|
||||
user_info: get_cookie("user_id")
|
||||
.unwrap_or_else(|e| {
|
||||
ConsoleService::error(&e.to_string());
|
||||
error!(&e.to_string());
|
||||
None
|
||||
})
|
||||
.and_then(|u| {
|
||||
get_cookie("is_admin")
|
||||
.map(|so| so.map(|s| (u, s == "true")))
|
||||
.unwrap_or_else(|e| {
|
||||
ConsoleService::error(&e.to_string());
|
||||
error!(&e.to_string());
|
||||
None
|
||||
})
|
||||
}),
|
||||
redirect_to: Self::get_redirect_route(),
|
||||
route_dispatcher: RouteAgentDispatcher::new(),
|
||||
redirect_to: Self::get_redirect_route(ctx),
|
||||
password_reset_enabled: None,
|
||||
};
|
||||
app.apply_initial_redirections();
|
||||
ctx.link().send_future(async move {
|
||||
Msg::PasswordResetProbeFinished(HostService::probe_password_reset().await)
|
||||
});
|
||||
app.apply_initial_redirections(ctx);
|
||||
app
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
let history = ctx.link().history().unwrap();
|
||||
match msg {
|
||||
Msg::Login((user_name, is_admin)) => {
|
||||
self.user_info = Some((user_name.clone(), is_admin));
|
||||
self.route_dispatcher
|
||||
.send(RouteRequest::ChangeRoute(Route::from(
|
||||
self.redirect_to.take().unwrap_or_else(|| {
|
||||
if is_admin {
|
||||
AppRoute::ListUsers
|
||||
} else {
|
||||
AppRoute::UserDetails(user_name.clone())
|
||||
}
|
||||
}),
|
||||
)));
|
||||
history.push(self.redirect_to.take().unwrap_or_else(|| {
|
||||
if is_admin {
|
||||
AppRoute::ListUsers
|
||||
} else {
|
||||
AppRoute::UserDetails {
|
||||
user_id: user_name.clone(),
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
Msg::Logout => {
|
||||
self.user_info = None;
|
||||
self.redirect_to = None;
|
||||
history.push(AppRoute::Login);
|
||||
}
|
||||
Msg::PasswordResetProbeFinished(Ok(enabled)) => {
|
||||
self.password_reset_enabled = Some(enabled);
|
||||
}
|
||||
Msg::PasswordResetProbeFinished(Err(err)) => {
|
||||
self.password_reset_enabled = Some(false);
|
||||
error!(&format!(
|
||||
"Could not probe for password reset support: {err:#}"
|
||||
));
|
||||
}
|
||||
}
|
||||
if self.user_info.is_none() {
|
||||
self.route_dispatcher
|
||||
.send(RouteRequest::ReplaceRoute(Route::from(AppRoute::Login)));
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||
false
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
let link = self.link.clone();
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link().clone();
|
||||
let is_admin = self.is_admin();
|
||||
let password_reset_enabled = self.password_reset_enabled;
|
||||
html! {
|
||||
<div class="container shadow-sm py-3">
|
||||
{self.view_banner()}
|
||||
<div>
|
||||
{self.view_banner(ctx)}
|
||||
<div class="container py-3 bg-kug">
|
||||
<div class="row justify-content-center" style="padding-bottom: 80px;">
|
||||
<div class="shadow-sm py-3" style="max-width: 1000px">
|
||||
<Router<AppRoute>
|
||||
render = Router::render(move |s| Self::dispatch_route(s, &link, is_admin))
|
||||
<main class="py-3" style="max-width: 1000px">
|
||||
<Switch<AppRoute>
|
||||
render={Switch::render(move |routes| Self::dispatch_route(routes, &link, is_admin, password_reset_enabled))}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{self.view_footer()}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn get_redirect_route() -> Option<AppRoute> {
|
||||
let route_service = RouteService::<()>::new();
|
||||
let current_route = route_service.get_path();
|
||||
if current_route.is_empty()
|
||||
|| current_route == "/"
|
||||
|| current_route.contains("login")
|
||||
|| current_route.contains("reset-password")
|
||||
{
|
||||
None
|
||||
} else {
|
||||
use yew_router::Switch;
|
||||
AppRoute::from_route_part::<()>(current_route, None).0
|
||||
}
|
||||
// Get the page to land on after logging in, defaulting to the index.
|
||||
fn get_redirect_route(ctx: &Context<Self>) -> Option<AppRoute> {
|
||||
let route = ctx.link().history().unwrap().location().route::<AppRoute>();
|
||||
route.filter(|route| {
|
||||
!matches!(
|
||||
route,
|
||||
AppRoute::Index
|
||||
| AppRoute::Login
|
||||
| AppRoute::StartResetPassword
|
||||
| AppRoute::FinishResetPassword { token: _ }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_initial_redirections(&mut self) {
|
||||
let route_service = RouteService::<()>::new();
|
||||
let current_route = route_service.get_path();
|
||||
if current_route.contains("reset-password") {
|
||||
return;
|
||||
}
|
||||
match &self.user_info {
|
||||
None => {
|
||||
self.route_dispatcher
|
||||
.send(RouteRequest::ReplaceRoute(Route::from(AppRoute::Login)));
|
||||
fn apply_initial_redirections(&self, ctx: &Context<Self>) {
|
||||
let history = ctx.link().history().unwrap();
|
||||
let route = history.location().route::<AppRoute>();
|
||||
let redirection = match (route, &self.user_info, &self.redirect_to) {
|
||||
(
|
||||
Some(AppRoute::StartResetPassword | AppRoute::FinishResetPassword { token: _ }),
|
||||
_,
|
||||
_,
|
||||
) => {
|
||||
if self.password_reset_enabled == Some(false) {
|
||||
Some(AppRoute::Login)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
Some((user_name, is_admin)) => match &self.redirect_to {
|
||||
Some(url) => {
|
||||
self.route_dispatcher
|
||||
.send(RouteRequest::ReplaceRoute(Route::from(url.clone())));
|
||||
(None, _, _) | (_, None, _) => Some(AppRoute::Login),
|
||||
// User is logged in, a URL was given, don't redirect.
|
||||
(_, Some(_), Some(_)) => None,
|
||||
(_, Some((user_name, is_admin)), None) => {
|
||||
if *is_admin {
|
||||
Some(AppRoute::ListUsers)
|
||||
} else {
|
||||
Some(AppRoute::UserDetails {
|
||||
user_id: user_name.clone(),
|
||||
})
|
||||
}
|
||||
None => {
|
||||
if *is_admin {
|
||||
self.route_dispatcher
|
||||
.send(RouteRequest::ReplaceRoute(Route::from(AppRoute::ListUsers)));
|
||||
} else {
|
||||
self.route_dispatcher
|
||||
.send(RouteRequest::ReplaceRoute(Route::from(
|
||||
AppRoute::UserDetails(user_name.clone()),
|
||||
)));
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
};
|
||||
if let Some(redirect_to) = redirection {
|
||||
history.push(redirect_to);
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_route(switch: AppRoute, link: &ComponentLink<Self>, is_admin: bool) -> Html {
|
||||
fn dispatch_route(
|
||||
switch: &AppRoute,
|
||||
link: &Scope<Self>,
|
||||
is_admin: bool,
|
||||
password_reset_enabled: Option<bool>,
|
||||
) -> Html {
|
||||
match switch {
|
||||
AppRoute::Login => html! {
|
||||
<LoginForm on_logged_in=link.callback(Msg::Login)/>
|
||||
<LoginForm on_logged_in={link.callback(Msg::Login)} password_reset_enabled={password_reset_enabled.unwrap_or(false)}/>
|
||||
},
|
||||
AppRoute::CreateUser => html! {
|
||||
<CreateUserForm/>
|
||||
@@ -171,7 +218,10 @@ impl App {
|
||||
AppRoute::Index | AppRoute::ListUsers => html! {
|
||||
<div>
|
||||
<UserTable />
|
||||
<NavButton classes="btn btn-primary" route=AppRoute::CreateUser>{"Create a user"}</NavButton>
|
||||
<Link classes="btn btn-primary" to={AppRoute::CreateUser}>
|
||||
<i class="bi-person-plus me-2"></i>
|
||||
{"Create a user"}
|
||||
</Link>
|
||||
</div>
|
||||
},
|
||||
AppRoute::CreateGroup => html! {
|
||||
@@ -180,34 +230,46 @@ impl App {
|
||||
AppRoute::ListGroups => html! {
|
||||
<div>
|
||||
<GroupTable />
|
||||
<NavButton classes="btn btn-primary" route=AppRoute::CreateGroup>{"Create a group"}</NavButton>
|
||||
<Link classes="btn btn-primary" to={AppRoute::CreateGroup}>
|
||||
<i class="bi-plus-circle me-2"></i>
|
||||
{"Create a group"}
|
||||
</Link>
|
||||
</div>
|
||||
},
|
||||
AppRoute::GroupDetails(group_id) => html! {
|
||||
<GroupDetails group_id=group_id />
|
||||
AppRoute::GroupDetails { group_id } => html! {
|
||||
<GroupDetails group_id={*group_id} />
|
||||
},
|
||||
AppRoute::UserDetails(username) => html! {
|
||||
<UserDetails username=username is_admin=is_admin />
|
||||
AppRoute::UserDetails { user_id } => html! {
|
||||
<UserDetails username={user_id.clone()} is_admin={is_admin} />
|
||||
},
|
||||
AppRoute::ChangePassword(username) => html! {
|
||||
<ChangePasswordForm username=username is_admin=is_admin />
|
||||
AppRoute::ChangePassword { user_id } => html! {
|
||||
<ChangePasswordForm username={user_id.clone()} is_admin={is_admin} />
|
||||
},
|
||||
AppRoute::StartResetPassword => html! {
|
||||
<ResetPasswordStep1Form />
|
||||
AppRoute::StartResetPassword => match password_reset_enabled {
|
||||
Some(true) => html! { <ResetPasswordStep1Form /> },
|
||||
Some(false) => {
|
||||
html! { <Redirect to={AppRoute::Login}/> }
|
||||
}
|
||||
|
||||
None => html! {},
|
||||
},
|
||||
AppRoute::FinishResetPassword(token) => html! {
|
||||
<ResetPasswordStep2Form token=token />
|
||||
AppRoute::FinishResetPassword { token } => match password_reset_enabled {
|
||||
Some(true) => html! { <ResetPasswordStep2Form token={token.clone()} /> },
|
||||
Some(false) => {
|
||||
html! { <Redirect to={AppRoute::Login}/> }
|
||||
}
|
||||
None => html! {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn view_banner(&self) -> Html {
|
||||
fn view_banner(&self, ctx: &Context<Self>) -> Html {
|
||||
html! {
|
||||
<header class="p-3 mb-4 border-bottom shadow-sm">
|
||||
<header class="p-2 mb-3 border-bottom">
|
||||
<div class="container">
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-center justify-content-lg-start">
|
||||
<a href="/" class="d-flex align-items-center mb-2 mb-lg-0 me-md-5 text-dark text-decoration-none">
|
||||
<h1>{"LLDAP"}</h1>
|
||||
<a href="/" class="d-flex align-items-center mt-2 mb-lg-0 me-md-5 text-decoration-none">
|
||||
<h2>{"LLDAP"}</h2>
|
||||
</a>
|
||||
|
||||
<ul class="nav col-12 col-lg-auto me-lg-auto mb-2 justify-content-center mb-md-0">
|
||||
@@ -215,66 +277,80 @@ impl App {
|
||||
<>
|
||||
<li>
|
||||
<Link
|
||||
classes="nav-link px-2 link-dark h4"
|
||||
route=AppRoute::ListUsers>
|
||||
classes="nav-link px-2 h6"
|
||||
to={AppRoute::ListUsers}>
|
||||
<i class="bi-people me-2"></i>
|
||||
{"Users"}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
classes="nav-link px-2 link-dark h4"
|
||||
route=AppRoute::ListGroups>
|
||||
classes="nav-link px-2 h6"
|
||||
to={AppRoute::ListGroups}>
|
||||
<i class="bi-collection me-2"></i>
|
||||
{"Groups"}
|
||||
</Link>
|
||||
</li>
|
||||
</>
|
||||
} } else { html!{} } }
|
||||
</ul>
|
||||
|
||||
<div class="dropdown text-end">
|
||||
<a href="#"
|
||||
class="d-block link-dark text-decoration-none dropdown-toggle"
|
||||
id="dropdownUser"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false">
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
fill="currentColor"
|
||||
class="bi bi-person-circle"
|
||||
viewBox="0 0 16 16">
|
||||
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
|
||||
<path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1z"/>
|
||||
</svg>
|
||||
</a>
|
||||
{if let Some((user_id, _)) = &self.user_info { html! {
|
||||
<ul
|
||||
class="dropdown-menu text-small dropdown-menu-lg-end"
|
||||
aria-labelledby="dropdownUser1"
|
||||
style="">
|
||||
<li>
|
||||
<Link
|
||||
classes="dropdown-item"
|
||||
route=AppRoute::UserDetails(user_id.clone())>
|
||||
{"Profile"}
|
||||
</Link>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider" /></li>
|
||||
<li>
|
||||
<LogoutButton on_logged_out=self.link.callback(|_| Msg::Logout) />
|
||||
</li>
|
||||
</ul>
|
||||
} } else { html!{} } }
|
||||
</div>
|
||||
{ self.view_user_menu(ctx) }
|
||||
<DarkModeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
}
|
||||
}
|
||||
|
||||
fn view_user_menu(&self, ctx: &Context<Self>) -> Html {
|
||||
if let Some((user_id, _)) = &self.user_info {
|
||||
let link = ctx.link();
|
||||
html! {
|
||||
<div class="dropdown text-end">
|
||||
<a href="#"
|
||||
class="d-block nav-link text-decoration-none dropdown-toggle"
|
||||
id="dropdownUser"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false">
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
fill="currentColor"
|
||||
class="bi bi-person-circle"
|
||||
viewBox="0 0 16 16">
|
||||
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
|
||||
<path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1z"/>
|
||||
</svg>
|
||||
<span class="ms-2">
|
||||
{user_id}
|
||||
</span>
|
||||
</a>
|
||||
<ul
|
||||
class="dropdown-menu text-small dropdown-menu-lg-end"
|
||||
aria-labelledby="dropdownUser1"
|
||||
style="">
|
||||
<li>
|
||||
<Link
|
||||
classes="dropdown-item"
|
||||
to={AppRoute::UserDetails{ user_id: user_id.clone() }}>
|
||||
{"View details"}
|
||||
</Link>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider" /></li>
|
||||
<li>
|
||||
<LogoutButton on_logged_out={link.callback(|_| Msg::Logout)} />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
|
||||
fn view_footer(&self) -> Html {
|
||||
html! {
|
||||
<footer class="text-center text-muted fixed-bottom bg-light">
|
||||
<footer class="text-center fixed-bottom text-muted bg-light py-2">
|
||||
<div>
|
||||
<span>{format!("LLDAP version {}", env!("CARGO_PKG_VERSION"))}</span>
|
||||
</div>
|
||||
|
||||
@@ -1,34 +1,27 @@
|
||||
use crate::{
|
||||
components::router::{AppRoute, NavButton},
|
||||
components::router::{AppRoute, Link},
|
||||
infra::{
|
||||
api::HostService,
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
},
|
||||
};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use gloo_console::error;
|
||||
use lldap_auth::*;
|
||||
use validator_derive::Validate;
|
||||
use yew::{prelude::*, services::ConsoleService};
|
||||
use yew::prelude::*;
|
||||
use yew_form::Form;
|
||||
use yew_form_derive::Model;
|
||||
use yew_router::{
|
||||
agent::{RouteAgentDispatcher, RouteRequest},
|
||||
route::Route,
|
||||
};
|
||||
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
||||
|
||||
#[derive(PartialEq, Eq)]
|
||||
#[derive(PartialEq, Eq, Default)]
|
||||
enum OpaqueData {
|
||||
#[default]
|
||||
None,
|
||||
Login(opaque::client::login::ClientLogin),
|
||||
Registration(opaque::client::registration::ClientRegistration),
|
||||
}
|
||||
|
||||
impl Default for OpaqueData {
|
||||
fn default() -> Self {
|
||||
OpaqueData::None
|
||||
}
|
||||
}
|
||||
|
||||
impl OpaqueData {
|
||||
fn take(&mut self) -> Self {
|
||||
std::mem::take(self)
|
||||
@@ -36,7 +29,7 @@ impl OpaqueData {
|
||||
}
|
||||
|
||||
/// The fields of the form, with the constraints.
|
||||
#[derive(Model, Validate, PartialEq, Clone, Default)]
|
||||
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
|
||||
pub struct FormModel {
|
||||
#[validate(custom(
|
||||
function = "empty_or_long",
|
||||
@@ -61,10 +54,9 @@ pub struct ChangePasswordForm {
|
||||
common: CommonComponentParts<Self>,
|
||||
form: Form<FormModel>,
|
||||
opaque_data: OpaqueData,
|
||||
route_dispatcher: RouteAgentDispatcher,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Properties)]
|
||||
#[derive(Clone, PartialEq, Eq, Properties)]
|
||||
pub struct Props {
|
||||
pub username: String,
|
||||
pub is_admin: bool,
|
||||
@@ -80,15 +72,20 @@ pub enum Msg {
|
||||
}
|
||||
|
||||
impl CommonComponent<ChangePasswordForm> for ChangePasswordForm {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
use anyhow::Context;
|
||||
match msg {
|
||||
Msg::FormUpdate => Ok(true),
|
||||
Msg::Submit => {
|
||||
if !self.form.validate() {
|
||||
bail!("Check the form for errors");
|
||||
}
|
||||
if self.common.is_admin {
|
||||
self.handle_msg(Msg::SubmitNewPassword)
|
||||
if ctx.props().is_admin {
|
||||
self.handle_msg(ctx, Msg::SubmitNewPassword)
|
||||
} else {
|
||||
let old_password = self.form.model().old_password;
|
||||
if old_password.is_empty() {
|
||||
@@ -100,14 +97,14 @@ impl CommonComponent<ChangePasswordForm> for ChangePasswordForm {
|
||||
.context("Could not initialize login")?;
|
||||
self.opaque_data = OpaqueData::Login(login_start_request.state);
|
||||
let req = login::ClientLoginStartRequest {
|
||||
username: self.common.username.clone(),
|
||||
username: ctx.props().username.clone(),
|
||||
login_start_request: login_start_request.message,
|
||||
};
|
||||
self.common.call_backend(
|
||||
HostService::login_start,
|
||||
req,
|
||||
ctx,
|
||||
HostService::login_start(req),
|
||||
Msg::AuthenticationStartResponse,
|
||||
)?;
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
@@ -119,34 +116,33 @@ impl CommonComponent<ChangePasswordForm> for ChangePasswordForm {
|
||||
|e| {
|
||||
// Common error, we want to print a full error to the console but only a
|
||||
// simple one to the user.
|
||||
ConsoleService::error(&format!(
|
||||
"Invalid username or password: {}",
|
||||
e
|
||||
));
|
||||
error!(&format!("Invalid username or password: {}", e));
|
||||
anyhow!("Invalid username or password")
|
||||
},
|
||||
)?;
|
||||
}
|
||||
_ => panic!("Unexpected data in opaque_data field"),
|
||||
};
|
||||
self.handle_msg(Msg::SubmitNewPassword)
|
||||
self.handle_msg(ctx, Msg::SubmitNewPassword)
|
||||
}
|
||||
Msg::SubmitNewPassword => {
|
||||
let mut rng = rand::rngs::OsRng;
|
||||
let new_password = self.form.model().password;
|
||||
let registration_start_request =
|
||||
opaque::client::registration::start_registration(&new_password, &mut rng)
|
||||
.context("Could not initiate password change")?;
|
||||
let registration_start_request = opaque::client::registration::start_registration(
|
||||
new_password.as_bytes(),
|
||||
&mut rng,
|
||||
)
|
||||
.context("Could not initiate password change")?;
|
||||
let req = registration::ClientRegistrationStartRequest {
|
||||
username: self.common.username.clone(),
|
||||
username: ctx.props().username.clone(),
|
||||
registration_start_request: registration_start_request.message,
|
||||
};
|
||||
self.opaque_data = OpaqueData::Registration(registration_start_request.state);
|
||||
self.common.call_backend(
|
||||
HostService::register_start,
|
||||
req,
|
||||
ctx,
|
||||
HostService::register_start(req),
|
||||
Msg::RegistrationStartResponse,
|
||||
)?;
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
Msg::RegistrationStartResponse(res) => {
|
||||
@@ -166,22 +162,20 @@ impl CommonComponent<ChangePasswordForm> for ChangePasswordForm {
|
||||
registration_upload: registration_finish.message,
|
||||
};
|
||||
self.common.call_backend(
|
||||
HostService::register_finish,
|
||||
req,
|
||||
ctx,
|
||||
HostService::register_finish(req),
|
||||
Msg::RegistrationFinishResponse,
|
||||
)
|
||||
);
|
||||
}
|
||||
_ => panic!("Unexpected data in opaque_data field"),
|
||||
}?;
|
||||
};
|
||||
Ok(false)
|
||||
}
|
||||
Msg::RegistrationFinishResponse(response) => {
|
||||
self.common.cancel_task();
|
||||
if response.is_ok() {
|
||||
self.route_dispatcher
|
||||
.send(RouteRequest::ChangeRoute(Route::from(
|
||||
AppRoute::UserDetails(self.common.username.clone()),
|
||||
)));
|
||||
ctx.link().history().unwrap().push(AppRoute::UserDetails {
|
||||
user_id: ctx.props().username.clone(),
|
||||
});
|
||||
}
|
||||
response?;
|
||||
Ok(true)
|
||||
@@ -198,28 +192,38 @@ impl Component for ChangePasswordForm {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
ChangePasswordForm {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
form: yew_form::Form::<FormModel>::new(FormModel::default()),
|
||||
opaque_data: OpaqueData::None,
|
||||
route_dispatcher: RouteAgentDispatcher::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
let is_admin = self.common.is_admin;
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let is_admin = ctx.props().is_admin;
|
||||
let link = ctx.link();
|
||||
type Field = yew_form::Field<FormModel>;
|
||||
html! {
|
||||
<>
|
||||
<div class="mb-2 mt-2">
|
||||
<h5 class="fw-bold">
|
||||
{"Change password"}
|
||||
</h5>
|
||||
</div>
|
||||
{
|
||||
if let Some(e) = &self.common.error {
|
||||
html! {
|
||||
<div class="alert alert-danger mt-3 mb-3">
|
||||
{e.to_string() }
|
||||
</div>
|
||||
}
|
||||
} else { html! {} }
|
||||
}
|
||||
<form
|
||||
class="form">
|
||||
{if !is_admin { html! {
|
||||
@@ -230,82 +234,81 @@ impl Component for ChangePasswordForm {
|
||||
</label>
|
||||
<div class="col-sm-10">
|
||||
<Field
|
||||
form=&self.form
|
||||
form={&self.form}
|
||||
field_name="old_password"
|
||||
input_type="password"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="current-password"
|
||||
oninput=self.common.callback(|_| Msg::FormUpdate) />
|
||||
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("old_password")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}} else { html! {} }}
|
||||
<div class="form-group row">
|
||||
<div class="form-group row mb-3">
|
||||
<label for="new_password"
|
||||
class="form-label col-sm-2 col-form-label">
|
||||
{"New password*:"}
|
||||
{"New Password"}
|
||||
<span class="text-danger">{"*"}</span>
|
||||
{":"}
|
||||
</label>
|
||||
<div class="col-sm-10">
|
||||
<Field
|
||||
form=&self.form
|
||||
form={&self.form}
|
||||
field_name="password"
|
||||
input_type="password"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="new-password"
|
||||
oninput=self.common.callback(|_| Msg::FormUpdate) />
|
||||
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("password")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<div class="form-group row mb-3">
|
||||
<label for="confirm_password"
|
||||
class="form-label col-sm-2 col-form-label">
|
||||
{"Confirm password*:"}
|
||||
{"Confirm Password"}
|
||||
<span class="text-danger">{"*"}</span>
|
||||
{":"}
|
||||
</label>
|
||||
<div class="col-sm-10">
|
||||
<Field
|
||||
form=&self.form
|
||||
form={&self.form}
|
||||
field_name="confirm_password"
|
||||
input_type="password"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="new-password"
|
||||
oninput=self.common.callback(|_| Msg::FormUpdate) />
|
||||
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("confirm_password")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<div class="form-group row justify-content-center">
|
||||
<button
|
||||
class="btn btn-primary col-sm-1 col-form-label"
|
||||
class="btn btn-primary col-auto col-form-label"
|
||||
type="submit"
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
|
||||
{"Submit"}
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}>
|
||||
<i class="bi-save me-2"></i>
|
||||
{"Save changes"}
|
||||
</button>
|
||||
<Link
|
||||
classes="btn btn-secondary ms-2 col-auto col-form-label"
|
||||
to={AppRoute::UserDetails{user_id: ctx.props().username.clone()}}>
|
||||
<i class="bi-arrow-return-left me-2"></i>
|
||||
{"Back"}
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
{ if let Some(e) = &self.common.error {
|
||||
html! {
|
||||
<div class="alert alert-danger">
|
||||
{e.to_string() }
|
||||
</div>
|
||||
}
|
||||
} else { html! {} }
|
||||
}
|
||||
<div>
|
||||
<NavButton
|
||||
classes="btn btn-primary"
|
||||
route=AppRoute::UserDetails(self.common.username.clone())>
|
||||
{"Back"}
|
||||
</NavButton>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,15 +3,12 @@ use crate::{
|
||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||
};
|
||||
use anyhow::{bail, Result};
|
||||
use gloo_console::log;
|
||||
use graphql_client::GraphQLQuery;
|
||||
use validator_derive::Validate;
|
||||
use yew::prelude::*;
|
||||
use yew::services::ConsoleService;
|
||||
use yew_form_derive::Model;
|
||||
use yew_router::{
|
||||
agent::{RouteAgentDispatcher, RouteRequest},
|
||||
route::Route,
|
||||
};
|
||||
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
@@ -24,11 +21,10 @@ pub struct CreateGroup;
|
||||
|
||||
pub struct CreateGroupForm {
|
||||
common: CommonComponentParts<Self>,
|
||||
route_dispatcher: RouteAgentDispatcher,
|
||||
form: yew_form::Form<CreateGroupModel>,
|
||||
}
|
||||
|
||||
#[derive(Model, Validate, PartialEq, Clone, Default)]
|
||||
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
|
||||
pub struct CreateGroupModel {
|
||||
#[validate(length(min = 1, message = "Groupname is required"))]
|
||||
groupname: String,
|
||||
@@ -41,7 +37,11 @@ pub enum Msg {
|
||||
}
|
||||
|
||||
impl CommonComponent<CreateGroupForm> for CreateGroupForm {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::Update => Ok(true),
|
||||
Msg::SubmitForm => {
|
||||
@@ -53,6 +53,7 @@ impl CommonComponent<CreateGroupForm> for CreateGroupForm {
|
||||
name: model.groupname,
|
||||
};
|
||||
self.common.call_graphql::<CreateGroup, _>(
|
||||
ctx,
|
||||
req,
|
||||
Msg::CreateGroupResponse,
|
||||
"Error trying to create group",
|
||||
@@ -60,12 +61,11 @@ impl CommonComponent<CreateGroupForm> for CreateGroupForm {
|
||||
Ok(true)
|
||||
}
|
||||
Msg::CreateGroupResponse(response) => {
|
||||
ConsoleService::log(&format!(
|
||||
log!(&format!(
|
||||
"Created group '{}'",
|
||||
&response?.create_group.display_name
|
||||
));
|
||||
self.route_dispatcher
|
||||
.send(RouteRequest::ChangeRoute(Route::from(AppRoute::ListGroups)));
|
||||
ctx.link().history().unwrap().push(AppRoute::ListGroups);
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
@@ -80,44 +80,42 @@ impl Component for CreateGroupForm {
|
||||
type Message = Msg;
|
||||
type Properties = ();
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
Self {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
route_dispatcher: RouteAgentDispatcher::new(),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
form: yew_form::Form::<CreateGroupModel>::new(CreateGroupModel::default()),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
type Field = yew_form::Field<CreateGroupModel>;
|
||||
html! {
|
||||
<div class="row justify-content-center">
|
||||
<form class="form shadow-sm py-3" style="max-width: 636px">
|
||||
<form class="form py-3" style="max-width: 636px">
|
||||
<div class="row mb-3">
|
||||
<h5 class="fw-bold">{"Create a group"}</h5>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="groupname"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Group name*:"}
|
||||
{"Group name"}
|
||||
<span class="text-danger">{"*"}</span>
|
||||
{":"}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
form=&self.form
|
||||
form={&self.form}
|
||||
field_name="groupname"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="groupname"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("groupname")}
|
||||
</div>
|
||||
@@ -127,8 +125,9 @@ impl Component for CreateGroupForm {
|
||||
<button
|
||||
class="btn btn-primary col-auto col-form-label"
|
||||
type="submit"
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})>
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})}>
|
||||
<i class="bi-save me-2"></i>
|
||||
{"Submit"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -5,17 +5,14 @@ use crate::{
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
},
|
||||
};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use anyhow::{bail, Result};
|
||||
use gloo_console::log;
|
||||
use graphql_client::GraphQLQuery;
|
||||
use lldap_auth::{opaque, registration};
|
||||
use validator_derive::Validate;
|
||||
use yew::prelude::*;
|
||||
use yew::services::ConsoleService;
|
||||
use yew_form_derive::Model;
|
||||
use yew_router::{
|
||||
agent::{RouteAgentDispatcher, RouteRequest},
|
||||
route::Route,
|
||||
};
|
||||
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
@@ -28,17 +25,15 @@ pub struct CreateUser;
|
||||
|
||||
pub struct CreateUserForm {
|
||||
common: CommonComponentParts<Self>,
|
||||
route_dispatcher: RouteAgentDispatcher,
|
||||
form: yew_form::Form<CreateUserModel>,
|
||||
}
|
||||
|
||||
#[derive(Model, Validate, PartialEq, Clone, Default)]
|
||||
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
|
||||
pub struct CreateUserModel {
|
||||
#[validate(length(min = 1, message = "Username is required"))]
|
||||
username: String,
|
||||
#[validate(email(message = "A valid email is required"))]
|
||||
email: String,
|
||||
#[validate(length(min = 1, message = "Display name is required"))]
|
||||
display_name: String,
|
||||
first_name: String,
|
||||
last_name: String,
|
||||
@@ -74,7 +69,11 @@ pub enum Msg {
|
||||
}
|
||||
|
||||
impl CommonComponent<CreateUserForm> for CreateUserForm {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::Update => Ok(true),
|
||||
Msg::SubmitForm => {
|
||||
@@ -90,9 +89,11 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
|
||||
displayName: to_option(model.display_name),
|
||||
firstName: to_option(model.first_name),
|
||||
lastName: to_option(model.last_name),
|
||||
avatar: None,
|
||||
},
|
||||
};
|
||||
self.common.call_graphql::<CreateUser, _>(
|
||||
ctx,
|
||||
req,
|
||||
Msg::CreateUserResponse,
|
||||
"Error trying to create user",
|
||||
@@ -102,7 +103,7 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
|
||||
Msg::CreateUserResponse(r) => {
|
||||
match r {
|
||||
Err(e) => return Err(e),
|
||||
Ok(r) => ConsoleService::log(&format!(
|
||||
Ok(r) => log!(&format!(
|
||||
"Created user '{}' at '{}'",
|
||||
&r.create_user.id, &r.create_user.creation_date
|
||||
)),
|
||||
@@ -116,18 +117,20 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
|
||||
let opaque::client::registration::ClientRegistrationStartResult {
|
||||
state,
|
||||
message,
|
||||
} = opaque::client::registration::start_registration(&password, &mut rng)?;
|
||||
} = opaque::client::registration::start_registration(
|
||||
password.as_bytes(),
|
||||
&mut rng,
|
||||
)?;
|
||||
let req = registration::ClientRegistrationStartRequest {
|
||||
username: user_id,
|
||||
registration_start_request: message,
|
||||
};
|
||||
self.common
|
||||
.call_backend(HostService::register_start, req, move |r| {
|
||||
.call_backend(ctx, HostService::register_start(req), move |r| {
|
||||
Msg::RegistrationStartResponse((state, r))
|
||||
})
|
||||
.context("Error trying to create user")?;
|
||||
});
|
||||
} else {
|
||||
self.update(Msg::SuccessfulCreation);
|
||||
self.update(ctx, Msg::SuccessfulCreation);
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
@@ -143,22 +146,19 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
|
||||
server_data: response.server_data,
|
||||
registration_upload: registration_upload.message,
|
||||
};
|
||||
self.common
|
||||
.call_backend(
|
||||
HostService::register_finish,
|
||||
req,
|
||||
Msg::RegistrationFinishResponse,
|
||||
)
|
||||
.context("Error trying to register user")?;
|
||||
self.common.call_backend(
|
||||
ctx,
|
||||
HostService::register_finish(req),
|
||||
Msg::RegistrationFinishResponse,
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
Msg::RegistrationFinishResponse(response) => {
|
||||
response?;
|
||||
self.handle_msg(Msg::SuccessfulCreation)
|
||||
self.handle_msg(ctx, Msg::SuccessfulCreation)
|
||||
}
|
||||
Msg::SuccessfulCreation => {
|
||||
self.route_dispatcher
|
||||
.send(RouteRequest::ChangeRoute(Route::from(AppRoute::ListUsers)));
|
||||
ctx.link().history().unwrap().push(AppRoute::ListUsers);
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
@@ -173,44 +173,42 @@ impl Component for CreateUserForm {
|
||||
type Message = Msg;
|
||||
type Properties = ();
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
Self {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
route_dispatcher: RouteAgentDispatcher::new(),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
form: yew_form::Form::<CreateUserModel>::new(CreateUserModel::default()),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
type Field = yew_form::Field<CreateUserModel>;
|
||||
html! {
|
||||
<div class="row justify-content-center">
|
||||
<form class="form shadow-sm py-3" style="max-width: 636px">
|
||||
<form class="form py-3" style="max-width: 636px">
|
||||
<div class="row mb-3">
|
||||
<h5 class="fw-bold">{"Create a user"}</h5>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="username"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"User name*:"}
|
||||
{"User name"}
|
||||
<span class="text-danger">{"*"}</span>
|
||||
{":"}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
form=&self.form
|
||||
form={&self.form}
|
||||
field_name="username"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="username"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("username")}
|
||||
</div>
|
||||
@@ -219,75 +217,77 @@ impl Component for CreateUserForm {
|
||||
<div class="form-group row mb-3">
|
||||
<label for="email"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Email*:"}
|
||||
{"Email"}
|
||||
<span class="text-danger">{"*"}</span>
|
||||
{":"}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
form=&self.form
|
||||
form={&self.form}
|
||||
input_type="email"
|
||||
field_name="email"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="email"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("email")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="display-name"
|
||||
<label for="display_name"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Display name*:"}
|
||||
{"Display name:"}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
form=&self.form
|
||||
form={&self.form}
|
||||
autocomplete="name"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
field_name="display_name"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("display_name")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="first-name"
|
||||
<label for="first_name"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"First name:"}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
form=&self.form
|
||||
form={&self.form}
|
||||
autocomplete="given-name"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
field_name="first_name"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("first_name")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="last-name"
|
||||
<label for="last_name"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Last name:"}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
form=&self.form
|
||||
form={&self.form}
|
||||
autocomplete="family-name"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
field_name="last_name"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("last_name")}
|
||||
</div>
|
||||
@@ -300,14 +300,14 @@ impl Component for CreateUserForm {
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
form=&self.form
|
||||
form={&self.form}
|
||||
input_type="password"
|
||||
field_name="password"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="new-password"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("password")}
|
||||
</div>
|
||||
@@ -320,14 +320,14 @@ impl Component for CreateUserForm {
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
form=&self.form
|
||||
form={&self.form}
|
||||
input_type="password"
|
||||
field_name="confirm_password"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="new-password"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("confirm_password")}
|
||||
</div>
|
||||
@@ -336,14 +336,16 @@ impl Component for CreateUserForm {
|
||||
<div class="form-group row justify-content-center">
|
||||
<button
|
||||
class="btn btn-primary col-auto col-form-label mt-4"
|
||||
disabled=self.common.is_task_running()
|
||||
disabled={self.common.is_task_running()}
|
||||
type="submit"
|
||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})>
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})}>
|
||||
<i class="bi-save me-2"></i>
|
||||
{"Submit"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{ if let Some(e) = &self.common.error {
|
||||
{
|
||||
if let Some(e) = &self.common.error {
|
||||
html! {
|
||||
<div class="alert alert-danger">
|
||||
{e.to_string() }
|
||||
|
||||
@@ -39,16 +39,21 @@ pub enum Msg {
|
||||
}
|
||||
|
||||
impl CommonComponent<DeleteGroup> for DeleteGroup {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::ClickedDeleteGroup => {
|
||||
self.modal.as_ref().expect("modal not initialized").show();
|
||||
}
|
||||
Msg::ConfirmDeleteGroup => {
|
||||
self.update(Msg::DismissModal);
|
||||
self.update(ctx, Msg::DismissModal);
|
||||
self.common.call_graphql::<DeleteGroupQuery, _>(
|
||||
ctx,
|
||||
delete_group_query::Variables {
|
||||
group_id: self.common.group.id,
|
||||
group_id: ctx.props().group.id,
|
||||
},
|
||||
Msg::DeleteGroupResponse,
|
||||
"Error trying to delete group",
|
||||
@@ -58,12 +63,8 @@ impl CommonComponent<DeleteGroup> for DeleteGroup {
|
||||
self.modal.as_ref().expect("modal not initialized").hide();
|
||||
}
|
||||
Msg::DeleteGroupResponse(response) => {
|
||||
self.common.cancel_task();
|
||||
response?;
|
||||
self.common
|
||||
.props
|
||||
.on_group_deleted
|
||||
.emit(self.common.group.id);
|
||||
ctx.props().on_group_deleted.emit(ctx.props().group.id);
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
@@ -78,15 +79,15 @@ impl Component for DeleteGroup {
|
||||
type Message = Msg;
|
||||
type Properties = DeleteGroupProps;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
Self {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
node_ref: NodeRef::default(),
|
||||
modal: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn rendered(&mut self, first_render: bool) {
|
||||
fn rendered(&mut self, _: &Context<Self>, first_render: bool) {
|
||||
if first_render {
|
||||
self.modal = Some(Modal::new(
|
||||
self.node_ref
|
||||
@@ -96,43 +97,42 @@ impl Component for DeleteGroup {
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update_and_report_error(
|
||||
self,
|
||||
ctx,
|
||||
msg,
|
||||
self.common.on_error.clone(),
|
||||
ctx.props().on_error.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
html! {
|
||||
<>
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|_| Msg::ClickedDeleteGroup)>
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|_| Msg::ClickedDeleteGroup)}>
|
||||
<i class="bi-x-circle-fill" aria-label="Delete group" />
|
||||
</button>
|
||||
{self.show_modal()}
|
||||
{self.show_modal(ctx)}
|
||||
</>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DeleteGroup {
|
||||
fn show_modal(&self) -> Html {
|
||||
fn show_modal(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
html! {
|
||||
<div
|
||||
class="modal fade"
|
||||
id="deleteGroupModal".to_string() + &self.common.group.id.to_string()
|
||||
id={"deleteGroupModal".to_string() + &ctx.props().group.id.to_string()}
|
||||
tabindex="-1"
|
||||
aria-labelledby="deleteGroupModalLabel"
|
||||
aria-hidden="true"
|
||||
ref=self.node_ref.clone()>
|
||||
ref={self.node_ref.clone()}>
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
@@ -141,25 +141,29 @@ impl DeleteGroup {
|
||||
type="button"
|
||||
class="btn-close"
|
||||
aria-label="Close"
|
||||
onclick=self.common.callback(|_| Msg::DismissModal) />
|
||||
onclick={link.callback(|_| Msg::DismissModal)} />
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<span>
|
||||
{"Are you sure you want to delete group "}
|
||||
<b>{&self.common.group.display_name}</b>{"?"}
|
||||
<b>{&ctx.props().group.display_name}</b>{"?"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
onclick=self.common.callback(|_| Msg::DismissModal)>
|
||||
onclick={link.callback(|_| Msg::DismissModal)}>
|
||||
<i class="bi-x-circle me-2"></i>
|
||||
{"Cancel"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick=self.common.callback(|_| Msg::ConfirmDeleteGroup)
|
||||
class="btn btn-danger">{"Yes, I'm sure"}</button>
|
||||
onclick={link.callback(|_| Msg::ConfirmDeleteGroup)}
|
||||
class="btn btn-danger">
|
||||
<i class="bi-check-circle me-2"></i>
|
||||
{"Yes, I'm sure"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -36,16 +36,21 @@ pub enum Msg {
|
||||
}
|
||||
|
||||
impl CommonComponent<DeleteUser> for DeleteUser {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::ClickedDeleteUser => {
|
||||
self.modal.as_ref().expect("modal not initialized").show();
|
||||
}
|
||||
Msg::ConfirmDeleteUser => {
|
||||
self.update(Msg::DismissModal);
|
||||
self.update(ctx, Msg::DismissModal);
|
||||
self.common.call_graphql::<DeleteUserQuery, _>(
|
||||
ctx,
|
||||
delete_user_query::Variables {
|
||||
user: self.common.username.clone(),
|
||||
user: ctx.props().username.clone(),
|
||||
},
|
||||
Msg::DeleteUserResponse,
|
||||
"Error trying to delete user",
|
||||
@@ -55,12 +60,10 @@ impl CommonComponent<DeleteUser> for DeleteUser {
|
||||
self.modal.as_ref().expect("modal not initialized").hide();
|
||||
}
|
||||
Msg::DeleteUserResponse(response) => {
|
||||
self.common.cancel_task();
|
||||
response?;
|
||||
self.common
|
||||
.props
|
||||
ctx.props()
|
||||
.on_user_deleted
|
||||
.emit(self.common.username.clone());
|
||||
.emit(ctx.props().username.clone());
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
@@ -75,15 +78,15 @@ impl Component for DeleteUser {
|
||||
type Message = Msg;
|
||||
type Properties = DeleteUserProps;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
Self {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
node_ref: NodeRef::default(),
|
||||
modal: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn rendered(&mut self, first_render: bool) {
|
||||
fn rendered(&mut self, _: &Context<Self>, first_render: bool) {
|
||||
if first_render {
|
||||
self.modal = Some(Modal::new(
|
||||
self.node_ref
|
||||
@@ -93,44 +96,43 @@ impl Component for DeleteUser {
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update_and_report_error(
|
||||
self,
|
||||
ctx,
|
||||
msg,
|
||||
self.common.on_error.clone(),
|
||||
ctx.props().on_error.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
html! {
|
||||
<>
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|_| Msg::ClickedDeleteUser)>
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|_| Msg::ClickedDeleteUser)}>
|
||||
<i class="bi-x-circle-fill" aria-label="Delete user" />
|
||||
</button>
|
||||
{self.show_modal()}
|
||||
{self.show_modal(ctx)}
|
||||
</>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DeleteUser {
|
||||
fn show_modal(&self) -> Html {
|
||||
fn show_modal(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
html! {
|
||||
<div
|
||||
class="modal fade"
|
||||
id="deleteUserModal".to_string() + &self.common.username
|
||||
id={"deleteUserModal".to_string() + &ctx.props().username}
|
||||
tabindex="-1"
|
||||
//role="dialog"
|
||||
aria-labelledby="deleteUserModalLabel"
|
||||
aria-hidden="true"
|
||||
ref=self.node_ref.clone()>
|
||||
ref={self.node_ref.clone()}>
|
||||
<div class="modal-dialog" /*role="document"*/>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
@@ -139,25 +141,29 @@ impl DeleteUser {
|
||||
type="button"
|
||||
class="btn-close"
|
||||
aria-label="Close"
|
||||
onclick=self.common.callback(|_| Msg::DismissModal) />
|
||||
onclick={link.callback(|_| Msg::DismissModal)} />
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<span>
|
||||
{"Are you sure you want to delete user "}
|
||||
<b>{&self.common.username}</b>{"?"}
|
||||
<b>{&ctx.props().username}</b>{"?"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
onclick=self.common.callback(|_| Msg::DismissModal)>
|
||||
{"Cancel"}
|
||||
onclick={link.callback(|_| Msg::DismissModal)}>
|
||||
<i class="bi-x-circle me-2"></i>
|
||||
{"Cancel"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick=self.common.callback(|_| Msg::ConfirmDeleteUser)
|
||||
class="btn btn-danger">{"Yes, I'm sure"}</button>
|
||||
onclick={link.callback(|_| Msg::ConfirmDeleteUser)}
|
||||
class="btn btn-danger">
|
||||
<i class="bi-check-circle me-2"></i>
|
||||
{"Yes, I'm sure"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -40,16 +40,17 @@ pub enum Msg {
|
||||
OnUserRemovedFromGroup((String, i64)),
|
||||
}
|
||||
|
||||
#[derive(yew::Properties, Clone, PartialEq)]
|
||||
#[derive(yew::Properties, Clone, PartialEq, Eq)]
|
||||
pub struct Props {
|
||||
pub group_id: i64,
|
||||
}
|
||||
|
||||
impl GroupDetails {
|
||||
fn get_group_details(&mut self) {
|
||||
fn get_group_details(&mut self, ctx: &Context<Self>) {
|
||||
self.common.call_graphql::<GetGroupDetails, _>(
|
||||
ctx,
|
||||
get_group_details::Variables {
|
||||
id: self.common.group_id,
|
||||
id: ctx.props().group_id,
|
||||
},
|
||||
Msg::GroupDetailsResponse,
|
||||
"Error trying to fetch group details",
|
||||
@@ -68,34 +69,73 @@ impl GroupDetails {
|
||||
}
|
||||
}
|
||||
|
||||
fn view_user_list(&self, g: &Group) -> Html {
|
||||
fn view_details(&self, g: &Group) -> Html {
|
||||
html! {
|
||||
<>
|
||||
<h3>{g.display_name.to_string()}</h3>
|
||||
<div class="py-3">
|
||||
<form class="form">
|
||||
<div class="form-group row mb-3">
|
||||
<label for="displayName"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Group: "}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<span id="groupId" class="form-constrol-static">{g.display_name.to_string()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="creationDate"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Creation date: "}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<span id="creationDate" class="form-constrol-static">{g.creation_date.naive_local().date()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="uuid"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"UUID: "}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<span id="uuid" class="form-constrol-static">{g.uuid.to_string()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
}
|
||||
|
||||
fn view_user_list(&self, ctx: &Context<Self>, g: &Group) -> Html {
|
||||
let link = ctx.link();
|
||||
let make_user_row = |user: &User| {
|
||||
let user_id = user.id.clone();
|
||||
let display_name = user.display_name.clone();
|
||||
html! {
|
||||
<tr>
|
||||
<td>
|
||||
<Link route=AppRoute::UserDetails(user_id.clone())>
|
||||
<Link to={AppRoute::UserDetails{user_id: user_id.clone()}}>
|
||||
{user_id.clone()}
|
||||
</Link>
|
||||
</td>
|
||||
<td>{display_name}</td>
|
||||
<td>
|
||||
<RemoveUserFromGroupComponent
|
||||
username=user_id
|
||||
group_id=g.id
|
||||
on_user_removed_from_group=self.common.callback(Msg::OnUserRemovedFromGroup)
|
||||
on_error=self.common.callback(Msg::OnError)/>
|
||||
username={user_id}
|
||||
group_id={g.id}
|
||||
on_user_removed_from_group={link.callback(Msg::OnUserRemovedFromGroup)}
|
||||
on_error={link.callback(Msg::OnError)}/>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
};
|
||||
html! {
|
||||
<>
|
||||
<h3>{g.display_name.to_string()}</h3>
|
||||
<h5 class="fw-bold">{"Members"}</h5>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr key="headerRow">
|
||||
<th>{"User Id"}</th>
|
||||
@@ -107,7 +147,7 @@ impl GroupDetails {
|
||||
{if g.users.is_empty() {
|
||||
html! {
|
||||
<tr key="EmptyRow">
|
||||
<td>{"No members"}</td>
|
||||
<td>{"There are no users in this group."}</td>
|
||||
<td/>
|
||||
</tr>
|
||||
}
|
||||
@@ -121,7 +161,8 @@ impl GroupDetails {
|
||||
}
|
||||
}
|
||||
|
||||
fn view_add_user_button(&self, g: &Group) -> Html {
|
||||
fn view_add_user_button(&self, ctx: &Context<Self>, g: &Group) -> Html {
|
||||
let link = ctx.link();
|
||||
let users: Vec<_> = g
|
||||
.users
|
||||
.iter()
|
||||
@@ -132,16 +173,16 @@ impl GroupDetails {
|
||||
.collect();
|
||||
html! {
|
||||
<AddGroupMemberComponent
|
||||
group_id=g.id
|
||||
users=users
|
||||
on_error=self.common.callback(Msg::OnError)
|
||||
on_user_added_to_group=self.common.callback(Msg::OnUserAddedToGroup)/>
|
||||
group_id={g.id}
|
||||
users={users}
|
||||
on_error={link.callback(Msg::OnError)}
|
||||
on_user_added_to_group={link.callback(Msg::OnUserAddedToGroup)}/>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CommonComponent<GroupDetails> for GroupDetails {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::GroupDetailsResponse(response) => match response {
|
||||
Ok(group) => self.group = Some(group.group),
|
||||
@@ -177,32 +218,29 @@ impl Component for GroupDetails {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut table = Self {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
group: None,
|
||||
};
|
||||
table.get_group_details();
|
||||
table.get_group_details(ctx);
|
||||
table
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
match (&self.group, &self.common.error) {
|
||||
(None, None) => html! {{"Loading..."}},
|
||||
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
||||
(Some(u), error) => {
|
||||
html! {
|
||||
<div>
|
||||
{self.view_user_list(u)}
|
||||
{self.view_add_user_button(u)}
|
||||
{self.view_details(u)}
|
||||
{self.view_user_list(ctx, u)}
|
||||
{self.view_add_user_button(ctx, u)}
|
||||
{self.view_messages(error)}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ use yew::prelude::*;
|
||||
#[graphql(
|
||||
schema_path = "../schema.graphql",
|
||||
query_path = "queries/get_group_list.graphql",
|
||||
response_derives = "Debug,Clone,PartialEq",
|
||||
response_derives = "Debug,Clone,PartialEq,Eq",
|
||||
custom_scalars_module = "crate::infra::graphql"
|
||||
)]
|
||||
pub struct GetGroupList;
|
||||
@@ -34,7 +34,7 @@ pub enum Msg {
|
||||
}
|
||||
|
||||
impl CommonComponent<GroupTable> for GroupTable {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::ListGroupsResponse(groups) => {
|
||||
self.groups = Some(groups?.groups.into_iter().collect());
|
||||
@@ -58,12 +58,13 @@ impl Component for GroupTable {
|
||||
type Message = Msg;
|
||||
type Properties = ();
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut table = GroupTable {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
groups: None,
|
||||
};
|
||||
table.common.call_graphql::<GetGroupList, _>(
|
||||
ctx,
|
||||
get_group_list::Variables {},
|
||||
Msg::ListGroupsResponse,
|
||||
"Error trying to fetch groups",
|
||||
@@ -71,18 +72,14 @@ impl Component for GroupTable {
|
||||
table
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
html! {
|
||||
<div>
|
||||
{self.view_groups()}
|
||||
{self.view_groups(ctx)}
|
||||
{self.view_errors()}
|
||||
</div>
|
||||
}
|
||||
@@ -90,19 +87,20 @@ impl Component for GroupTable {
|
||||
}
|
||||
|
||||
impl GroupTable {
|
||||
fn view_groups(&self) -> Html {
|
||||
fn view_groups(&self, ctx: &Context<Self>) -> Html {
|
||||
let make_table = |groups: &Vec<Group>| {
|
||||
html! {
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{"Groups"}</th>
|
||||
<th>{"Group name"}</th>
|
||||
<th>{"Creation date"}</th>
|
||||
<th>{"Delete"}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groups.iter().map(|u| self.view_group(u)).collect::<Vec<_>>()}
|
||||
{groups.iter().map(|u| self.view_group(ctx, u)).collect::<Vec<_>>()}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -114,19 +112,23 @@ impl GroupTable {
|
||||
}
|
||||
}
|
||||
|
||||
fn view_group(&self, group: &Group) -> Html {
|
||||
fn view_group(&self, ctx: &Context<Self>, group: &Group) -> Html {
|
||||
let link = ctx.link();
|
||||
html! {
|
||||
<tr key=group.id>
|
||||
<tr key={group.id}>
|
||||
<td>
|
||||
<Link route=AppRoute::GroupDetails(group.id)>
|
||||
<Link to={AppRoute::GroupDetails{group_id: group.id}}>
|
||||
{&group.display_name}
|
||||
</Link>
|
||||
</td>
|
||||
<td>
|
||||
{&group.creation_date.naive_local().date()}
|
||||
</td>
|
||||
<td>
|
||||
<DeleteGroup
|
||||
group=group.clone()
|
||||
on_group_deleted=self.common.callback(Msg::OnGroupDeleted)
|
||||
on_error=self.common.callback(Msg::OnError)/>
|
||||
group={group.clone()}
|
||||
on_group_deleted={link.callback(Msg::OnGroupDeleted)}
|
||||
on_error={link.callback(Msg::OnError)}/>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
use crate::{
|
||||
components::router::{AppRoute, NavButton},
|
||||
components::router::{AppRoute, Link},
|
||||
infra::{
|
||||
api::HostService,
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
},
|
||||
};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use gloo_console::error;
|
||||
use lldap_auth::*;
|
||||
use validator_derive::Validate;
|
||||
use yew::{prelude::*, services::ConsoleService};
|
||||
use yew::prelude::*;
|
||||
use yew_form::Form;
|
||||
use yew_form_derive::Model;
|
||||
|
||||
@@ -19,7 +20,7 @@ pub struct LoginForm {
|
||||
}
|
||||
|
||||
/// The fields of the form, with the constraints.
|
||||
#[derive(Model, Validate, PartialEq, Clone, Default)]
|
||||
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
|
||||
pub struct FormModel {
|
||||
#[validate(length(min = 1, message = "Missing username"))]
|
||||
username: String,
|
||||
@@ -30,6 +31,7 @@ pub struct FormModel {
|
||||
#[derive(Clone, PartialEq, Properties)]
|
||||
pub struct Props {
|
||||
pub on_logged_in: Callback<(String, bool)>,
|
||||
pub password_reset_enabled: bool,
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
@@ -46,7 +48,12 @@ pub enum Msg {
|
||||
}
|
||||
|
||||
impl CommonComponent<LoginForm> for LoginForm {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
use anyhow::Context;
|
||||
match msg {
|
||||
Msg::Update => Ok(true),
|
||||
Msg::Submit => {
|
||||
@@ -63,9 +70,9 @@ impl CommonComponent<LoginForm> for LoginForm {
|
||||
login_start_request: message,
|
||||
};
|
||||
self.common
|
||||
.call_backend(HostService::login_start, req, move |r| {
|
||||
.call_backend(ctx, HostService::login_start(req), move |r| {
|
||||
Msg::AuthenticationStartResponse((state, r))
|
||||
})?;
|
||||
});
|
||||
Ok(true)
|
||||
}
|
||||
Msg::AuthenticationStartResponse((login_start, res)) => {
|
||||
@@ -76,9 +83,8 @@ impl CommonComponent<LoginForm> for LoginForm {
|
||||
Err(e) => {
|
||||
// Common error, we want to print a full error to the console but only a
|
||||
// simple one to the user.
|
||||
ConsoleService::error(&format!("Invalid username or password: {}", e));
|
||||
error!(&format!("Invalid username or password: {}", e));
|
||||
self.common.error = Some(anyhow!("Invalid username or password"));
|
||||
self.common.cancel_task();
|
||||
return Ok(true);
|
||||
}
|
||||
Ok(l) => l,
|
||||
@@ -88,24 +94,22 @@ impl CommonComponent<LoginForm> for LoginForm {
|
||||
credential_finalization: login_finish.message,
|
||||
};
|
||||
self.common.call_backend(
|
||||
HostService::login_finish,
|
||||
req,
|
||||
ctx,
|
||||
HostService::login_finish(req),
|
||||
Msg::AuthenticationFinishResponse,
|
||||
)?;
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
Msg::AuthenticationFinishResponse(user_info) => {
|
||||
self.common.cancel_task();
|
||||
self.common
|
||||
ctx.props()
|
||||
.on_logged_in
|
||||
.emit(user_info.context("Could not log in")?);
|
||||
Ok(true)
|
||||
}
|
||||
Msg::AuthenticationRefreshResponse(user_info) => {
|
||||
self.refreshing = false;
|
||||
self.common.cancel_task();
|
||||
if let Ok(user_info) = user_info {
|
||||
self.common.on_logged_in.emit(user_info);
|
||||
ctx.props().on_logged_in.emit(user_info);
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
@@ -121,32 +125,28 @@ impl Component for LoginForm {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut app = LoginForm {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
form: Form::<FormModel>::new(FormModel::default()),
|
||||
refreshing: true,
|
||||
};
|
||||
if let Err(e) =
|
||||
app.common
|
||||
.call_backend(HostService::refresh, (), Msg::AuthenticationRefreshResponse)
|
||||
{
|
||||
ConsoleService::debug(&format!("Could not refresh auth: {}", e));
|
||||
app.refreshing = false;
|
||||
}
|
||||
app.common.call_backend(
|
||||
ctx,
|
||||
HostService::refresh(),
|
||||
Msg::AuthenticationRefreshResponse,
|
||||
);
|
||||
app
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
type Field = yew_form::Field<FormModel>;
|
||||
let password_reset_enabled = ctx.props().password_reset_enabled;
|
||||
let link = &ctx.link();
|
||||
if self.refreshing {
|
||||
html! {
|
||||
<div>
|
||||
@@ -167,11 +167,11 @@ impl Component for LoginForm {
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
form=&self.form
|
||||
form={&self.form}
|
||||
field_name="username"
|
||||
placeholder="Username"
|
||||
autocomplete="username"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
@@ -183,7 +183,7 @@ impl Component for LoginForm {
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
form=&self.form
|
||||
form={&self.form}
|
||||
field_name="password"
|
||||
input_type="password"
|
||||
placeholder="Password"
|
||||
@@ -193,16 +193,23 @@ impl Component for LoginForm {
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}>
|
||||
<i class="bi-box-arrow-in-right me-2"/>
|
||||
{"Login"}
|
||||
</button>
|
||||
<NavButton
|
||||
classes="btn-link btn"
|
||||
disabled=self.common.is_task_running()
|
||||
route=AppRoute::StartResetPassword>
|
||||
{"Forgot your password?"}
|
||||
</NavButton>
|
||||
{ if password_reset_enabled {
|
||||
html! {
|
||||
<Link
|
||||
classes="btn-link btn"
|
||||
disabled={self.common.is_task_running()}
|
||||
to={AppRoute::StartResetPassword}>
|
||||
{"Forgot your password?"}
|
||||
</Link>
|
||||
}
|
||||
} else {
|
||||
html!{}
|
||||
}}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
{ if let Some(e) = &self.common.error {
|
||||
|
||||
@@ -21,16 +21,20 @@ pub enum Msg {
|
||||
}
|
||||
|
||||
impl CommonComponent<LogoutButton> for LogoutButton {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::LogoutRequested => {
|
||||
self.common
|
||||
.call_backend(HostService::logout, (), Msg::LogoutCompleted)?;
|
||||
.call_backend(ctx, HostService::logout(), Msg::LogoutCompleted);
|
||||
}
|
||||
Msg::LogoutCompleted(res) => {
|
||||
res?;
|
||||
delete_cookie("user_id")?;
|
||||
self.common.on_logged_out.emit(());
|
||||
ctx.props().on_logged_out.emit(());
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
@@ -45,25 +49,22 @@ impl Component for LogoutButton {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
LogoutButton {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
html! {
|
||||
<button
|
||||
class="dropdown-item"
|
||||
onclick=self.common.callback(|_| Msg::LogoutRequested)>
|
||||
onclick={link.callback(|_| Msg::LogoutRequested)}>
|
||||
{"Logout"}
|
||||
</button>
|
||||
}
|
||||
|
||||
@@ -31,15 +31,18 @@ pub enum Msg {
|
||||
}
|
||||
|
||||
impl CommonComponent<RemoveUserFromGroupComponent> for RemoveUserFromGroupComponent {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::SubmitRemoveGroup => self.submit_remove_group(),
|
||||
Msg::SubmitRemoveGroup => self.submit_remove_group(ctx),
|
||||
Msg::RemoveGroupResponse(response) => {
|
||||
response?;
|
||||
self.common.cancel_task();
|
||||
self.common
|
||||
ctx.props()
|
||||
.on_user_removed_from_group
|
||||
.emit((self.common.username.clone(), self.common.group_id));
|
||||
.emit((ctx.props().username.clone(), ctx.props().group_id));
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
@@ -51,11 +54,12 @@ impl CommonComponent<RemoveUserFromGroupComponent> for RemoveUserFromGroupCompon
|
||||
}
|
||||
|
||||
impl RemoveUserFromGroupComponent {
|
||||
fn submit_remove_group(&mut self) {
|
||||
fn submit_remove_group(&mut self, ctx: &Context<Self>) {
|
||||
self.common.call_graphql::<RemoveUserFromGroup, _>(
|
||||
ctx,
|
||||
remove_user_from_group::Variables {
|
||||
user: self.common.username.clone(),
|
||||
group: self.common.group_id,
|
||||
user: ctx.props().username.clone(),
|
||||
group: ctx.props().group_id,
|
||||
},
|
||||
Msg::RemoveGroupResponse,
|
||||
"Error trying to initiate removing the user from a group",
|
||||
@@ -67,30 +71,28 @@ impl Component for RemoveUserFromGroupComponent {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
Self {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update_and_report_error(
|
||||
self,
|
||||
ctx,
|
||||
msg,
|
||||
self.common.on_error.clone(),
|
||||
ctx.props().on_error.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
html! {
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|_| Msg::SubmitRemoveGroup)>
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|_| Msg::SubmitRemoveGroup)}>
|
||||
<i class="bi-x-circle-fill" aria-label="Remove user from group" />
|
||||
</button>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::{
|
||||
components::router::{AppRoute, NavButton},
|
||||
components::router::{AppRoute, Link},
|
||||
infra::{
|
||||
api::HostService,
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
@@ -18,7 +18,7 @@ pub struct ResetPasswordStep1Form {
|
||||
}
|
||||
|
||||
/// The fields of the form, with the constraints.
|
||||
#[derive(Model, Validate, PartialEq, Clone, Default)]
|
||||
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
|
||||
pub struct FormModel {
|
||||
#[validate(length(min = 1, message = "Missing username"))]
|
||||
username: String,
|
||||
@@ -31,7 +31,11 @@ pub enum Msg {
|
||||
}
|
||||
|
||||
impl CommonComponent<ResetPasswordStep1Form> for ResetPasswordStep1Form {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::Update => Ok(true),
|
||||
Msg::Submit => {
|
||||
@@ -40,10 +44,10 @@ impl CommonComponent<ResetPasswordStep1Form> for ResetPasswordStep1Form {
|
||||
}
|
||||
let FormModel { username } = self.form.model();
|
||||
self.common.call_backend(
|
||||
HostService::reset_password_step1,
|
||||
&username,
|
||||
ctx,
|
||||
HostService::reset_password_step1(username),
|
||||
Msg::PasswordResetResponse,
|
||||
)?;
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
Msg::PasswordResetResponse(response) => {
|
||||
@@ -63,25 +67,22 @@ impl Component for ResetPasswordStep1Form {
|
||||
type Message = Msg;
|
||||
type Properties = ();
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
ResetPasswordStep1Form {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
form: Form::<FormModel>::new(FormModel::default()),
|
||||
just_succeeded: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
self.just_succeeded = false;
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
type Field = yew_form::Field<FormModel>;
|
||||
let link = &ctx.link();
|
||||
html! {
|
||||
<form
|
||||
class="form center-block col-sm-4 col-offset-4">
|
||||
@@ -95,11 +96,11 @@ impl Component for ResetPasswordStep1Form {
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
form=&self.form
|
||||
form={&self.form}
|
||||
field_name="username"
|
||||
placeholder="Username"
|
||||
placeholder="Username or email"
|
||||
autocomplete="username"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
</div>
|
||||
{ if self.just_succeeded {
|
||||
html! {
|
||||
@@ -111,23 +112,24 @@ impl Component for ResetPasswordStep1Form {
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}>
|
||||
<i class="bi-check-circle me-2"/>
|
||||
{"Reset password"}
|
||||
</button>
|
||||
<NavButton
|
||||
<Link
|
||||
classes="btn-link btn"
|
||||
disabled=self.common.is_task_running()
|
||||
route=AppRoute::Login>
|
||||
disabled={self.common.is_task_running()}
|
||||
to={AppRoute::Login}>
|
||||
{"Back"}
|
||||
</NavButton>
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
}}
|
||||
<div class="form-group">
|
||||
{ if let Some(e) = &self.common.error {
|
||||
html! {
|
||||
<div class="alert alert-danger">
|
||||
<div class="alert alert-danger mb-2">
|
||||
{e.to_string() }
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use crate::{
|
||||
components::router::AppRoute,
|
||||
components::router::{AppRoute, Link},
|
||||
infra::{
|
||||
api::HostService,
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
},
|
||||
};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use anyhow::{bail, Result};
|
||||
use lldap_auth::{
|
||||
opaque::client::registration as opaque_registration,
|
||||
password_reset::ServerPasswordResetResponse, registration,
|
||||
@@ -14,13 +14,10 @@ use validator_derive::Validate;
|
||||
use yew::prelude::*;
|
||||
use yew_form::Form;
|
||||
use yew_form_derive::Model;
|
||||
use yew_router::{
|
||||
agent::{RouteAgentDispatcher, RouteRequest},
|
||||
route::Route,
|
||||
};
|
||||
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
||||
|
||||
/// The fields of the form, with the constraints.
|
||||
#[derive(Model, Validate, PartialEq, Clone, Default)]
|
||||
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
|
||||
pub struct FormModel {
|
||||
#[validate(length(min = 8, message = "Invalid password. Min length: 8"))]
|
||||
password: String,
|
||||
@@ -33,10 +30,9 @@ pub struct ResetPasswordStep2Form {
|
||||
form: Form<FormModel>,
|
||||
username: Option<String>,
|
||||
opaque_data: Option<opaque_registration::ClientRegistration>,
|
||||
route_dispatcher: RouteAgentDispatcher,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Properties)]
|
||||
#[derive(Clone, PartialEq, Eq, Properties)]
|
||||
pub struct Props {
|
||||
pub token: String,
|
||||
}
|
||||
@@ -50,11 +46,15 @@ pub enum Msg {
|
||||
}
|
||||
|
||||
impl CommonComponent<ResetPasswordStep2Form> for ResetPasswordStep2Form {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
use anyhow::Context;
|
||||
match msg {
|
||||
Msg::ValidateTokenResponse(response) => {
|
||||
self.username = Some(response?.user_id);
|
||||
self.common.cancel_task();
|
||||
Ok(true)
|
||||
}
|
||||
Msg::FormUpdate => Ok(true),
|
||||
@@ -65,7 +65,7 @@ impl CommonComponent<ResetPasswordStep2Form> for ResetPasswordStep2Form {
|
||||
let mut rng = rand::rngs::OsRng;
|
||||
let new_password = self.form.model().password;
|
||||
let registration_start_request =
|
||||
opaque_registration::start_registration(&new_password, &mut rng)
|
||||
opaque_registration::start_registration(new_password.as_bytes(), &mut rng)
|
||||
.context("Could not initiate password change")?;
|
||||
let req = registration::ClientRegistrationStartRequest {
|
||||
username: self.username.clone().unwrap(),
|
||||
@@ -73,10 +73,10 @@ impl CommonComponent<ResetPasswordStep2Form> for ResetPasswordStep2Form {
|
||||
};
|
||||
self.opaque_data = Some(registration_start_request.state);
|
||||
self.common.call_backend(
|
||||
HostService::register_start,
|
||||
req,
|
||||
ctx,
|
||||
HostService::register_start(req),
|
||||
Msg::RegistrationStartResponse,
|
||||
)?;
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
Msg::RegistrationStartResponse(res) => {
|
||||
@@ -94,17 +94,15 @@ impl CommonComponent<ResetPasswordStep2Form> for ResetPasswordStep2Form {
|
||||
registration_upload: registration_finish.message,
|
||||
};
|
||||
self.common.call_backend(
|
||||
HostService::register_finish,
|
||||
req,
|
||||
ctx,
|
||||
HostService::register_finish(req),
|
||||
Msg::RegistrationFinishResponse,
|
||||
)?;
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
Msg::RegistrationFinishResponse(response) => {
|
||||
self.common.cancel_task();
|
||||
if response.is_ok() {
|
||||
self.route_dispatcher
|
||||
.send(RouteRequest::ChangeRoute(Route::from(AppRoute::Login)));
|
||||
ctx.link().history().unwrap().push(AppRoute::Login);
|
||||
}
|
||||
response?;
|
||||
Ok(true)
|
||||
@@ -121,35 +119,28 @@ impl Component for ResetPasswordStep2Form {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut component = ResetPasswordStep2Form {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
form: yew_form::Form::<FormModel>::new(FormModel::default()),
|
||||
opaque_data: None,
|
||||
route_dispatcher: RouteAgentDispatcher::new(),
|
||||
username: None,
|
||||
};
|
||||
let token = component.common.token.clone();
|
||||
component
|
||||
.common
|
||||
.call_backend(
|
||||
HostService::reset_password_step2,
|
||||
&token,
|
||||
Msg::ValidateTokenResponse,
|
||||
)
|
||||
.unwrap();
|
||||
let token = ctx.props().token.clone();
|
||||
component.common.call_backend(
|
||||
ctx,
|
||||
HostService::reset_password_step2(token),
|
||||
Msg::ValidateTokenResponse,
|
||||
);
|
||||
component
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
match (&self.username, &self.common.error) {
|
||||
(None, None) => {
|
||||
return html! {
|
||||
@@ -158,9 +149,17 @@ impl Component for ResetPasswordStep2Form {
|
||||
}
|
||||
(None, Some(e)) => {
|
||||
return html! {
|
||||
<div class="alert alert-danger">
|
||||
{e.to_string() }
|
||||
</div>
|
||||
<>
|
||||
<div class="alert alert-danger">
|
||||
{e.to_string() }
|
||||
</div>
|
||||
<Link
|
||||
classes="btn-link btn"
|
||||
disabled={self.common.is_task_running()}
|
||||
to={AppRoute::Login}>
|
||||
{"Back"}
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
@@ -178,14 +177,14 @@ impl Component for ResetPasswordStep2Form {
|
||||
</label>
|
||||
<div class="col-sm-10">
|
||||
<Field
|
||||
form=&self.form
|
||||
form={&self.form}
|
||||
field_name="password"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="new-password"
|
||||
input_type="password"
|
||||
oninput=self.common.callback(|_| Msg::FormUpdate) />
|
||||
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("password")}
|
||||
</div>
|
||||
@@ -198,14 +197,14 @@ impl Component for ResetPasswordStep2Form {
|
||||
</label>
|
||||
<div class="col-sm-10">
|
||||
<Field
|
||||
form=&self.form
|
||||
form={&self.form}
|
||||
field_name="confirm_password"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="new-password"
|
||||
input_type="password"
|
||||
oninput=self.common.callback(|_| Msg::FormUpdate) />
|
||||
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("confirm_password")}
|
||||
</div>
|
||||
@@ -215,8 +214,8 @@ impl Component for ResetPasswordStep2Form {
|
||||
<button
|
||||
class="btn btn-primary col-sm-1 col-form-label"
|
||||
type="submit"
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}>
|
||||
{"Submit"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,34 +1,30 @@
|
||||
use yew_router::{
|
||||
components::{RouterAnchor, RouterButton},
|
||||
Switch,
|
||||
};
|
||||
use yew_router::Routable;
|
||||
|
||||
#[derive(Switch, Debug, Clone)]
|
||||
#[derive(Routable, Debug, Clone, PartialEq)]
|
||||
pub enum AppRoute {
|
||||
#[to = "/login"]
|
||||
#[at("/login")]
|
||||
Login,
|
||||
#[to = "/reset-password/step1"]
|
||||
#[at("/reset-password/step1")]
|
||||
StartResetPassword,
|
||||
#[to = "/reset-password/step2/{token}"]
|
||||
FinishResetPassword(String),
|
||||
#[to = "/users/create"]
|
||||
#[at("/reset-password/step2/:token")]
|
||||
FinishResetPassword { token: String },
|
||||
#[at("/users/create")]
|
||||
CreateUser,
|
||||
#[to = "/users"]
|
||||
#[at("/users")]
|
||||
ListUsers,
|
||||
#[to = "/user/{user_id}/password"]
|
||||
ChangePassword(String),
|
||||
#[to = "/user/{user_id}"]
|
||||
UserDetails(String),
|
||||
#[to = "/groups/create"]
|
||||
#[at("/user/:user_id/password")]
|
||||
ChangePassword { user_id: String },
|
||||
#[at("/user/:user_id")]
|
||||
UserDetails { user_id: String },
|
||||
#[at("/groups/create")]
|
||||
CreateGroup,
|
||||
#[to = "/groups"]
|
||||
#[at("/groups")]
|
||||
ListGroups,
|
||||
#[to = "/group/{group_id}"]
|
||||
GroupDetails(i64),
|
||||
#[to = "/"]
|
||||
#[at("/group/:group_id")]
|
||||
GroupDetails { group_id: i64 },
|
||||
#[at("/")]
|
||||
Index,
|
||||
}
|
||||
|
||||
pub type Link = RouterAnchor<AppRoute>;
|
||||
|
||||
pub type NavButton = RouterButton<AppRoute>;
|
||||
pub type Link = yew_router::components::Link<AppRoute>;
|
||||
pub type Redirect = yew_router::components::Redirect<AppRoute>;
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
use yew::{html::ChangeData, prelude::*};
|
||||
use yewtil::NeqAssign;
|
||||
use yew::prelude::*;
|
||||
|
||||
pub struct Select {
|
||||
link: ComponentLink<Self>,
|
||||
props: SelectProps,
|
||||
node_ref: NodeRef,
|
||||
}
|
||||
|
||||
@@ -14,100 +11,70 @@ pub struct SelectProps {
|
||||
}
|
||||
|
||||
pub enum SelectMsg {
|
||||
OnSelectChange(ChangeData),
|
||||
OnSelectChange,
|
||||
}
|
||||
|
||||
impl Select {
|
||||
fn get_nth_child_props(&self, nth: i32) -> Option<SelectOptionProps> {
|
||||
fn get_nth_child_props(&self, ctx: &Context<Self>, nth: i32) -> Option<SelectOptionProps> {
|
||||
if nth == -1 {
|
||||
return None;
|
||||
}
|
||||
self.props
|
||||
ctx.props()
|
||||
.children
|
||||
.iter()
|
||||
.nth(nth as usize)
|
||||
.map(|child| child.props)
|
||||
.map(|child| (*child.props).clone())
|
||||
}
|
||||
|
||||
fn send_selection_update(&self) {
|
||||
fn send_selection_update(&self, ctx: &Context<Self>) {
|
||||
let select_node = self.node_ref.cast::<web_sys::HtmlSelectElement>().unwrap();
|
||||
self.props
|
||||
ctx.props()
|
||||
.on_selection_change
|
||||
.emit(self.get_nth_child_props(select_node.selected_index()))
|
||||
.emit(self.get_nth_child_props(ctx, select_node.selected_index()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for Select {
|
||||
type Message = SelectMsg;
|
||||
type Properties = SelectProps;
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
Self {
|
||||
link,
|
||||
props,
|
||||
node_ref: NodeRef::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn rendered(&mut self, _first_render: bool) {
|
||||
self.send_selection_update();
|
||||
fn rendered(&mut self, ctx: &Context<Self>, _first_render: bool) {
|
||||
self.send_selection_update(ctx);
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
let SelectMsg::OnSelectChange(data) = msg;
|
||||
match data {
|
||||
ChangeData::Select(_) => self.send_selection_update(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
fn update(&mut self, ctx: &Context<Self>, _: Self::Message) -> bool {
|
||||
self.send_selection_update(ctx);
|
||||
false
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.props.children.neq_assign(props.children)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
html! {
|
||||
<select
|
||||
ref=self.node_ref.clone()
|
||||
disabled=self.props.children.is_empty()
|
||||
onchange=self.link.callback(SelectMsg::OnSelectChange)>
|
||||
{ self.props.children.clone() }
|
||||
<select class="form-select"
|
||||
ref={self.node_ref.clone()}
|
||||
disabled={ctx.props().children.is_empty()}
|
||||
onchange={ctx.link().callback(|_| SelectMsg::OnSelectChange)}>
|
||||
{ ctx.props().children.clone() }
|
||||
</select>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SelectOption {
|
||||
props: SelectOptionProps,
|
||||
}
|
||||
|
||||
#[derive(yew::Properties, Clone, PartialEq, Debug)]
|
||||
#[derive(yew::Properties, Clone, PartialEq, Eq, Debug)]
|
||||
pub struct SelectOptionProps {
|
||||
pub value: String,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
impl Component for SelectOption {
|
||||
type Message = ();
|
||||
type Properties = SelectOptionProps;
|
||||
|
||||
fn create(props: Self::Properties, _: ComponentLink<Self>) -> Self {
|
||||
Self { props }
|
||||
}
|
||||
|
||||
fn update(&mut self, _: Self::Message) -> ShouldRender {
|
||||
false
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.props.neq_assign(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
html! {
|
||||
<option value=self.props.value.clone()>
|
||||
{&self.props.text}
|
||||
</option>
|
||||
}
|
||||
#[function_component(SelectOption)]
|
||||
pub fn select_option(props: &SelectOptionProps) -> Html {
|
||||
html! {
|
||||
<option value={props.value.clone()}>
|
||||
{&props.text}
|
||||
</option>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::{
|
||||
components::{
|
||||
add_user_to_group::AddUserToGroupComponent,
|
||||
remove_user_from_group::RemoveUserFromGroupComponent,
|
||||
router::{AppRoute, Link, NavButton},
|
||||
router::{AppRoute, Link},
|
||||
user_details_form::UserDetailsForm,
|
||||
},
|
||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||
@@ -40,14 +40,14 @@ pub enum Msg {
|
||||
OnUserRemovedFromGroup((String, i64)),
|
||||
}
|
||||
|
||||
#[derive(yew::Properties, Clone, PartialEq)]
|
||||
#[derive(yew::Properties, Clone, PartialEq, Eq)]
|
||||
pub struct Props {
|
||||
pub username: String,
|
||||
pub is_admin: bool,
|
||||
}
|
||||
|
||||
impl CommonComponent<UserDetails> for UserDetails {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::UserDetailsResponse(response) => match response {
|
||||
Ok(user) => self.user = Some(user.user),
|
||||
@@ -77,10 +77,11 @@ impl CommonComponent<UserDetails> for UserDetails {
|
||||
}
|
||||
|
||||
impl UserDetails {
|
||||
fn get_user_details(&mut self) {
|
||||
fn get_user_details(&mut self, ctx: &Context<Self>) {
|
||||
self.common.call_graphql::<GetUserDetails, _>(
|
||||
ctx,
|
||||
get_user_details::Variables {
|
||||
id: self.common.username.clone(),
|
||||
id: ctx.props().username.clone(),
|
||||
},
|
||||
Msg::UserDetailsResponse,
|
||||
"Error trying to fetch user details",
|
||||
@@ -99,24 +100,25 @@ impl UserDetails {
|
||||
}
|
||||
}
|
||||
|
||||
fn view_group_memberships(&self, u: &User) -> Html {
|
||||
fn view_group_memberships(&self, ctx: &Context<Self>, u: &User) -> Html {
|
||||
let link = &ctx.link();
|
||||
let make_group_row = |group: &Group| {
|
||||
let display_name = group.display_name.clone();
|
||||
html! {
|
||||
<tr key="groupRow_".to_string() + &display_name>
|
||||
{if self.common.is_admin { html! {
|
||||
<tr key={"groupRow_".to_string() + &display_name}>
|
||||
{if ctx.props().is_admin { html! {
|
||||
<>
|
||||
<td>
|
||||
<Link route=AppRoute::GroupDetails(group.id)>
|
||||
<Link to={AppRoute::GroupDetails{group_id: group.id}}>
|
||||
{&group.display_name}
|
||||
</Link>
|
||||
</td>
|
||||
<td>
|
||||
<RemoveUserFromGroupComponent
|
||||
username=u.id.clone()
|
||||
group_id=group.id
|
||||
on_user_removed_from_group=self.common.callback(Msg::OnUserRemovedFromGroup)
|
||||
on_error=self.common.callback(Msg::OnError)/>
|
||||
username={u.id.clone()}
|
||||
group_id={group.id}
|
||||
on_user_removed_from_group={link.callback(Msg::OnUserRemovedFromGroup)}
|
||||
on_error={link.callback(Msg::OnError)}/>
|
||||
</td>
|
||||
</>
|
||||
} } else { html! {
|
||||
@@ -129,18 +131,18 @@ impl UserDetails {
|
||||
<>
|
||||
<h5 class="row m-3 fw-bold">{"Group memberships"}</h5>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr key="headerRow">
|
||||
<th>{"Group"}</th>
|
||||
{ if self.common.is_admin { html!{ <th></th> }} else { html!{} }}
|
||||
{ if ctx.props().is_admin { html!{ <th></th> }} else { html!{} }}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{if u.groups.is_empty() {
|
||||
html! {
|
||||
<tr key="EmptyRow">
|
||||
<td>{"Not member of any group"}</td>
|
||||
<td>{"This user is not a member of any groups."}</td>
|
||||
</tr>
|
||||
}
|
||||
} else {
|
||||
@@ -153,14 +155,15 @@ impl UserDetails {
|
||||
}
|
||||
}
|
||||
|
||||
fn view_add_group_button(&self, u: &User) -> Html {
|
||||
if self.common.is_admin {
|
||||
fn view_add_group_button(&self, ctx: &Context<Self>, u: &User) -> Html {
|
||||
let link = &ctx.link();
|
||||
if ctx.props().is_admin {
|
||||
html! {
|
||||
<AddUserToGroupComponent
|
||||
username=u.id.clone()
|
||||
groups=u.groups.clone()
|
||||
on_error=self.common.callback(Msg::OnError)
|
||||
on_user_added_to_group=self.common.callback(Msg::OnUserAddedToGroup)/>
|
||||
username={u.id.clone()}
|
||||
groups={u.groups.clone()}
|
||||
on_error={link.callback(Msg::OnError)}
|
||||
on_user_added_to_group={link.callback(Msg::OnUserAddedToGroup)}/>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
@@ -172,24 +175,20 @@ impl Component for UserDetails {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut table = Self {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
user: None,
|
||||
};
|
||||
table.get_user_details();
|
||||
table.get_user_details(ctx);
|
||||
table
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
match (&self.user, &self.common.error) {
|
||||
(None, None) => html! {{"Loading..."}},
|
||||
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
||||
@@ -197,18 +196,20 @@ impl Component for UserDetails {
|
||||
html! {
|
||||
<>
|
||||
<h3>{u.id.to_string()}</h3>
|
||||
<UserDetailsForm
|
||||
user=u.clone()
|
||||
on_error=self.common.callback(Msg::OnError)/>
|
||||
<div class="row justify-content-center">
|
||||
<NavButton
|
||||
route=AppRoute::ChangePassword(u.id.clone())
|
||||
classes="btn btn-primary col-auto">
|
||||
{"Change password"}
|
||||
</NavButton>
|
||||
<div class="d-flex flex-row-reverse">
|
||||
<Link
|
||||
to={AppRoute::ChangePassword{user_id: u.id.clone()}}
|
||||
classes="btn btn-secondary">
|
||||
<i class="bi-key me-2"></i>
|
||||
{"Modify password"}
|
||||
</Link>
|
||||
</div>
|
||||
{self.view_group_memberships(u)}
|
||||
{self.view_add_group_button(u)}
|
||||
<div>
|
||||
<h5 class="row m-3 fw-bold">{"User details"}</h5>
|
||||
</div>
|
||||
<UserDetailsForm user={u.clone()} />
|
||||
{self.view_group_memberships(ctx, u)}
|
||||
{self.view_add_group_button(ctx, u)}
|
||||
{self.view_messages(error)}
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -1,19 +1,52 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use crate::{
|
||||
components::user_details::User,
|
||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||
};
|
||||
use anyhow::{bail, Error, Result};
|
||||
use gloo_file::{
|
||||
callbacks::{read_as_bytes, FileReader},
|
||||
File,
|
||||
};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use validator_derive::Validate;
|
||||
use web_sys::{FileList, HtmlInputElement, InputEvent};
|
||||
use yew::prelude::*;
|
||||
use yew_form_derive::Model;
|
||||
|
||||
#[derive(Default)]
|
||||
struct JsFile {
|
||||
file: Option<File>,
|
||||
contents: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl ToString for JsFile {
|
||||
fn to_string(&self) -> String {
|
||||
self.file
|
||||
.as_ref()
|
||||
.map(File::name)
|
||||
.unwrap_or_else(String::new)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for JsFile {
|
||||
type Err = Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self> {
|
||||
if s.is_empty() {
|
||||
Ok(JsFile::default())
|
||||
} else {
|
||||
bail!("Building file from non-empty string")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The fields of the form, with the editable details and the constraints.
|
||||
#[derive(Model, Validate, PartialEq, Clone)]
|
||||
#[derive(Model, Validate, PartialEq, Eq, Clone)]
|
||||
pub struct UserModel {
|
||||
#[validate(email)]
|
||||
email: String,
|
||||
#[validate(length(min = 1, message = "Display name is required"))]
|
||||
display_name: String,
|
||||
first_name: String,
|
||||
last_name: String,
|
||||
@@ -25,7 +58,7 @@ pub struct UserModel {
|
||||
schema_path = "../schema.graphql",
|
||||
query_path = "queries/update_user.graphql",
|
||||
response_derives = "Debug",
|
||||
variables_derives = "Clone,PartialEq",
|
||||
variables_derives = "Clone,PartialEq,Eq",
|
||||
custom_scalars_module = "crate::infra::graphql"
|
||||
)]
|
||||
pub struct UpdateUser;
|
||||
@@ -34,33 +67,90 @@ pub struct UpdateUser;
|
||||
pub struct UserDetailsForm {
|
||||
common: CommonComponentParts<Self>,
|
||||
form: yew_form::Form<UserModel>,
|
||||
// None means that the avatar hasn't changed.
|
||||
avatar: Option<JsFile>,
|
||||
reader: Option<FileReader>,
|
||||
/// True if we just successfully updated the user, to display a success message.
|
||||
just_updated: bool,
|
||||
user: User,
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
/// A form field changed.
|
||||
Update,
|
||||
/// A new file was selected.
|
||||
FileSelected(File),
|
||||
/// The "Submit" button was clicked.
|
||||
SubmitClicked,
|
||||
/// The "Clear" button for the avatar was clicked.
|
||||
ClearAvatarClicked,
|
||||
/// A picked file finished loading.
|
||||
FileLoaded(String, Result<Vec<u8>>),
|
||||
/// We got the response from the server about our update message.
|
||||
UserUpdated(Result<update_user::ResponseData>),
|
||||
}
|
||||
|
||||
#[derive(yew::Properties, Clone, PartialEq)]
|
||||
#[derive(yew::Properties, Clone, PartialEq, Eq)]
|
||||
pub struct Props {
|
||||
/// The current user details.
|
||||
pub user: User,
|
||||
/// Callback to report errors (e.g. server error).
|
||||
pub on_error: Callback<Error>,
|
||||
}
|
||||
|
||||
impl CommonComponent<UserDetailsForm> for UserDetailsForm {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::Update => Ok(true),
|
||||
Msg::SubmitClicked => self.submit_user_update_form(),
|
||||
Msg::FileSelected(new_avatar) => {
|
||||
if self
|
||||
.avatar
|
||||
.as_ref()
|
||||
.and_then(|f| f.file.as_ref().map(|f| f.name()))
|
||||
!= Some(new_avatar.name())
|
||||
{
|
||||
let file_name = new_avatar.name();
|
||||
let link = ctx.link().clone();
|
||||
self.reader = Some(read_as_bytes(&new_avatar, move |res| {
|
||||
link.send_message(Msg::FileLoaded(
|
||||
file_name,
|
||||
res.map_err(|e| anyhow::anyhow!("{:#}", e)),
|
||||
))
|
||||
}));
|
||||
self.avatar = Some(JsFile {
|
||||
file: Some(new_avatar),
|
||||
contents: None,
|
||||
});
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
Msg::SubmitClicked => self.submit_user_update_form(ctx),
|
||||
Msg::ClearAvatarClicked => {
|
||||
self.avatar = Some(JsFile::default());
|
||||
Ok(true)
|
||||
}
|
||||
Msg::UserUpdated(response) => self.user_update_finished(response),
|
||||
Msg::FileLoaded(file_name, data) => {
|
||||
if let Some(avatar) = &mut self.avatar {
|
||||
if let Some(file) = &avatar.file {
|
||||
if file.name() == file_name {
|
||||
let data = data?;
|
||||
if !is_valid_jpeg(data.as_slice()) {
|
||||
// Clear the selection.
|
||||
self.avatar = None;
|
||||
bail!("Chosen image is not a valid JPEG");
|
||||
} else {
|
||||
avatar.contents = Some(data);
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.reader = None;
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,35 +163,39 @@ impl Component for UserDetailsForm {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let model = UserModel {
|
||||
email: props.user.email.clone(),
|
||||
display_name: props.user.display_name.clone(),
|
||||
first_name: props.user.first_name.clone(),
|
||||
last_name: props.user.last_name.clone(),
|
||||
email: ctx.props().user.email.clone(),
|
||||
display_name: ctx.props().user.display_name.clone(),
|
||||
first_name: ctx.props().user.first_name.clone(),
|
||||
last_name: ctx.props().user.last_name.clone(),
|
||||
};
|
||||
Self {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
form: yew_form::Form::new(model),
|
||||
avatar: None,
|
||||
just_updated: false,
|
||||
reader: None,
|
||||
user: ctx.props().user.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
self.just_updated = false;
|
||||
CommonComponentParts::<Self>::update_and_report_error(
|
||||
self,
|
||||
msg,
|
||||
self.common.on_error.clone(),
|
||||
)
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
type Field = yew_form::Field<UserModel>;
|
||||
let link = &ctx.link();
|
||||
|
||||
let avatar_string = match &self.avatar {
|
||||
Some(avatar) => {
|
||||
let avatar_base64 = to_base64(avatar);
|
||||
avatar_base64.as_deref().unwrap_or("").to_owned()
|
||||
}
|
||||
None => self.user.avatar.as_deref().unwrap_or("").to_owned(),
|
||||
};
|
||||
html! {
|
||||
<div class="py-3">
|
||||
<form class="form">
|
||||
@@ -111,23 +205,43 @@ impl Component for UserDetailsForm {
|
||||
{"User ID: "}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<span id="userId" class="form-constrol-static">{&self.common.user.id}</span>
|
||||
<span id="userId" class="form-control-static"><i>{&self.user.id}</i></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="creationDate"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Creation date: "}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<span id="creationDate" class="form-control-static">{&self.user.creation_date.naive_local().date()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="uuid"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"UUID: "}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<span id="creationDate" class="form-control-static">{&self.user.uuid}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="email"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Email*: "}
|
||||
{"Email"}
|
||||
<span class="text-danger">{"*"}</span>
|
||||
{":"}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
form=&self.form
|
||||
form={&self.form}
|
||||
field_name="email"
|
||||
autocomplete="email"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("email")}
|
||||
</div>
|
||||
@@ -136,17 +250,17 @@ impl Component for UserDetailsForm {
|
||||
<div class="form-group row mb-3">
|
||||
<label for="display_name"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Display Name*: "}
|
||||
{"Display Name: "}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
form=&self.form
|
||||
form={&self.form}
|
||||
field_name="display_name"
|
||||
autocomplete="name"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("display_name")}
|
||||
</div>
|
||||
@@ -160,10 +274,10 @@ impl Component for UserDetailsForm {
|
||||
<div class="col-8">
|
||||
<Field
|
||||
class="form-control"
|
||||
form=&self.form
|
||||
form={&self.form}
|
||||
field_name="first_name"
|
||||
autocomplete="given-name"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("first_name")}
|
||||
</div>
|
||||
@@ -177,36 +291,80 @@ impl Component for UserDetailsForm {
|
||||
<div class="col-8">
|
||||
<Field
|
||||
class="form-control"
|
||||
form=&self.form
|
||||
form={&self.form}
|
||||
field_name="last_name"
|
||||
autocomplete="family-name"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("last_name")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="creationDate"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Creation date: "}
|
||||
<div class="form-group row align-items-center mb-3">
|
||||
<label for="avatar"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Avatar: "}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<span id="creationDate" class="form-constrol-static">{&self.common.user.creation_date.date().naive_local()}</span>
|
||||
<div class="row align-items-center">
|
||||
<div class="col-5">
|
||||
<input
|
||||
class="form-control"
|
||||
id="avatarInput"
|
||||
type="file"
|
||||
accept="image/jpeg"
|
||||
oninput={link.callback(|e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
Self::upload_files(input.files())
|
||||
})} />
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<button
|
||||
class="btn btn-secondary col-auto"
|
||||
id="avatarClear"
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::ClearAvatarClicked})}>
|
||||
{"Clear"}
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
{
|
||||
if !avatar_string.is_empty() {
|
||||
html!{
|
||||
<img
|
||||
id="avatarDisplay"
|
||||
src={format!("data:image/jpeg;base64, {}", avatar_string)}
|
||||
style="max-height:128px;max-width:128px;height:auto;width:auto;"
|
||||
alt="Avatar" />
|
||||
}
|
||||
} else { html! {} }
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row justify-content-center">
|
||||
<div class="form-group row justify-content-center mt-3">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary col-auto col-form-label"
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitClicked})>
|
||||
{"Update"}
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitClicked})}>
|
||||
<i class="bi-save me-2"></i>
|
||||
{"Save changes"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div hidden=!self.just_updated>
|
||||
<span>{"User successfully updated!"}</span>
|
||||
{
|
||||
if let Some(e) = &self.common.error {
|
||||
html! {
|
||||
<div class="alert alert-danger">
|
||||
{e.to_string() }
|
||||
</div>
|
||||
}
|
||||
} else { html! {} }
|
||||
}
|
||||
<div hidden={!self.just_updated}>
|
||||
<div class="alert alert-success mt-4">{"User successfully updated!"}</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -214,17 +372,25 @@ impl Component for UserDetailsForm {
|
||||
}
|
||||
|
||||
impl UserDetailsForm {
|
||||
fn submit_user_update_form(&mut self) -> Result<bool> {
|
||||
fn submit_user_update_form(&mut self, ctx: &Context<Self>) -> Result<bool> {
|
||||
if !self.form.validate() {
|
||||
bail!("Invalid inputs");
|
||||
}
|
||||
let base_user = &self.common.user;
|
||||
if let Some(JsFile {
|
||||
file: Some(_),
|
||||
contents: None,
|
||||
}) = &self.avatar
|
||||
{
|
||||
bail!("Image file hasn't finished loading, try again");
|
||||
}
|
||||
let base_user = &self.user;
|
||||
let mut user_input = update_user::UpdateUserInput {
|
||||
id: self.common.user.id.clone(),
|
||||
id: self.user.id.clone(),
|
||||
email: None,
|
||||
displayName: None,
|
||||
firstName: None,
|
||||
lastName: None,
|
||||
avatar: None,
|
||||
};
|
||||
let default_user_input = user_input.clone();
|
||||
let model = self.form.model();
|
||||
@@ -241,12 +407,16 @@ impl UserDetailsForm {
|
||||
if base_user.last_name != model.last_name {
|
||||
user_input.lastName = Some(model.last_name);
|
||||
}
|
||||
if let Some(avatar) = &self.avatar {
|
||||
user_input.avatar = Some(to_base64(avatar)?);
|
||||
}
|
||||
// Nothing changed.
|
||||
if user_input == default_user_input {
|
||||
return Ok(false);
|
||||
}
|
||||
let req = update_user::Variables { user: user_input };
|
||||
self.common.call_graphql::<UpdateUser, _>(
|
||||
ctx,
|
||||
req,
|
||||
Msg::UserUpdated,
|
||||
"Error trying to update user",
|
||||
@@ -255,23 +425,56 @@ impl UserDetailsForm {
|
||||
}
|
||||
|
||||
fn user_update_finished(&mut self, r: Result<update_user::ResponseData>) -> Result<bool> {
|
||||
self.common.cancel_task();
|
||||
match r {
|
||||
Err(e) => return Err(e),
|
||||
Ok(_) => {
|
||||
let model = self.form.model();
|
||||
self.common.user = User {
|
||||
id: self.common.user.id.clone(),
|
||||
email: model.email,
|
||||
display_name: model.display_name,
|
||||
first_name: model.first_name,
|
||||
last_name: model.last_name,
|
||||
creation_date: self.common.user.creation_date,
|
||||
groups: self.common.user.groups.clone(),
|
||||
};
|
||||
self.just_updated = true;
|
||||
}
|
||||
};
|
||||
r?;
|
||||
let model = self.form.model();
|
||||
self.user.email = model.email;
|
||||
self.user.display_name = model.display_name;
|
||||
self.user.first_name = model.first_name;
|
||||
self.user.last_name = model.last_name;
|
||||
if let Some(avatar) = &self.avatar {
|
||||
self.user.avatar = Some(to_base64(avatar)?);
|
||||
}
|
||||
self.just_updated = true;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn upload_files(files: Option<FileList>) -> Msg {
|
||||
if let Some(files) = files {
|
||||
if files.length() > 0 {
|
||||
Msg::FileSelected(File::from(files.item(0).unwrap()))
|
||||
} else {
|
||||
Msg::Update
|
||||
}
|
||||
} else {
|
||||
Msg::Update
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_valid_jpeg(bytes: &[u8]) -> bool {
|
||||
image::io::Reader::with_format(std::io::Cursor::new(bytes), image::ImageFormat::Jpeg)
|
||||
.decode()
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
fn to_base64(file: &JsFile) -> Result<String> {
|
||||
match file {
|
||||
JsFile {
|
||||
file: None,
|
||||
contents: _,
|
||||
} => Ok(String::new()),
|
||||
JsFile {
|
||||
file: Some(_),
|
||||
contents: None,
|
||||
} => bail!("Image file hasn't finished loading, try again"),
|
||||
JsFile {
|
||||
file: Some(_),
|
||||
contents: Some(data),
|
||||
} => {
|
||||
if !is_valid_jpeg(data.as_slice()) {
|
||||
bail!("Chosen image is not a valid JPEG");
|
||||
}
|
||||
Ok(base64::encode(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ pub enum Msg {
|
||||
}
|
||||
|
||||
impl CommonComponent<UserTable> for UserTable {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::ListUsersResponse(users) => {
|
||||
self.users = Some(users?.users.into_iter().collect());
|
||||
@@ -55,8 +55,9 @@ impl CommonComponent<UserTable> for UserTable {
|
||||
}
|
||||
|
||||
impl UserTable {
|
||||
fn get_users(&mut self, req: Option<RequestFilter>) {
|
||||
fn get_users(&mut self, ctx: &Context<Self>, req: Option<RequestFilter>) {
|
||||
self.common.call_graphql::<ListUsersQuery, _>(
|
||||
ctx,
|
||||
list_users_query::Variables { filters: req },
|
||||
Msg::ListUsersResponse,
|
||||
"Error trying to fetch users",
|
||||
@@ -68,27 +69,23 @@ impl Component for UserTable {
|
||||
type Message = Msg;
|
||||
type Properties = ();
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut table = UserTable {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
users: None,
|
||||
};
|
||||
table.get_users(None);
|
||||
table.get_users(ctx, None);
|
||||
table
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
html! {
|
||||
<div>
|
||||
{self.view_users()}
|
||||
{self.view_users(ctx)}
|
||||
{self.view_errors()}
|
||||
</div>
|
||||
}
|
||||
@@ -96,11 +93,11 @@ impl Component for UserTable {
|
||||
}
|
||||
|
||||
impl UserTable {
|
||||
fn view_users(&self) -> Html {
|
||||
fn view_users(&self, ctx: &Context<Self>) -> Html {
|
||||
let make_table = |users: &Vec<User>| {
|
||||
html! {
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{"User ID"}</th>
|
||||
@@ -113,7 +110,7 @@ impl UserTable {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.iter().map(|u| self.view_user(u)).collect::<Vec<_>>()}
|
||||
{users.iter().map(|u| self.view_user(ctx, u)).collect::<Vec<_>>()}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -125,20 +122,21 @@ impl UserTable {
|
||||
}
|
||||
}
|
||||
|
||||
fn view_user(&self, user: &User) -> Html {
|
||||
fn view_user(&self, ctx: &Context<Self>, user: &User) -> Html {
|
||||
let link = &ctx.link();
|
||||
html! {
|
||||
<tr key=user.id.clone()>
|
||||
<td><Link route=AppRoute::UserDetails(user.id.clone())>{&user.id}</Link></td>
|
||||
<tr key={user.id.clone()}>
|
||||
<td><Link to={AppRoute::UserDetails{user_id: user.id.clone()}}>{&user.id}</Link></td>
|
||||
<td>{&user.email}</td>
|
||||
<td>{&user.display_name}</td>
|
||||
<td>{&user.first_name}</td>
|
||||
<td>{&user.last_name}</td>
|
||||
<td>{&user.creation_date.date().naive_local()}</td>
|
||||
<td>{&user.creation_date.naive_local().date()}</td>
|
||||
<td>
|
||||
<DeleteUser
|
||||
username=user.id.clone()
|
||||
on_user_deleted=self.common.callback(Msg::OnUserDeleted)
|
||||
on_error=self.common.callback(Msg::OnError)/>
|
||||
username={user.id.clone()}
|
||||
on_user_deleted={link.callback(Msg::OnUserDeleted)}
|
||||
on_error={link.callback(Msg::OnError)}/>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
@@ -1,136 +1,84 @@
|
||||
use super::cookies::set_cookie;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use gloo_net::http::{Method, Request};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use lldap_auth::{login, registration, JWTClaims};
|
||||
|
||||
use yew::callback::Callback;
|
||||
use yew::format::Json;
|
||||
use yew::services::fetch::{Credentials, FetchOptions, FetchService, FetchTask, Request, Response};
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use web_sys::RequestCredentials;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct HostService {}
|
||||
|
||||
fn get_default_options() -> FetchOptions {
|
||||
FetchOptions {
|
||||
credentials: Some(Credentials::SameOrigin),
|
||||
..FetchOptions::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn get_claims_from_jwt(jwt: &str) -> Result<JWTClaims> {
|
||||
use jwt::*;
|
||||
let token = Token::<header::Header, JWTClaims, token::Unverified>::parse_unverified(jwt)?;
|
||||
Ok(token.claims().clone())
|
||||
}
|
||||
|
||||
fn create_handler<Resp, CallbackResult, F>(
|
||||
callback: Callback<Result<CallbackResult>>,
|
||||
handler: F,
|
||||
) -> Callback<Response<Result<Resp>>>
|
||||
where
|
||||
F: Fn(http::StatusCode, Resp) -> Result<CallbackResult> + 'static,
|
||||
CallbackResult: 'static,
|
||||
{
|
||||
Callback::once(move |response: Response<Result<Resp>>| {
|
||||
let (meta, maybe_data) = response.into_parts();
|
||||
let message = maybe_data
|
||||
.context("Could not reach server")
|
||||
.and_then(|data| handler(meta.status, data));
|
||||
callback.emit(message)
|
||||
})
|
||||
}
|
||||
const NO_BODY: Option<()> = None;
|
||||
|
||||
struct RequestBody<T>(T);
|
||||
|
||||
impl<'a, R> From<&'a R> for RequestBody<Json<&'a R>>
|
||||
where
|
||||
R: serde::ser::Serialize,
|
||||
{
|
||||
fn from(request: &'a R) -> Self {
|
||||
Self(Json(request))
|
||||
async fn call_server(
|
||||
url: &str,
|
||||
body: Option<impl Serialize>,
|
||||
error_message: &'static str,
|
||||
) -> Result<String> {
|
||||
let mut request = Request::new(url)
|
||||
.header("Content-Type", "application/json")
|
||||
.credentials(RequestCredentials::SameOrigin);
|
||||
if let Some(b) = body {
|
||||
request = request
|
||||
.body(serde_json::to_string(&b)?)
|
||||
.method(Method::POST);
|
||||
}
|
||||
let response = request.send().await?;
|
||||
if response.ok() {
|
||||
Ok(response.text().await?)
|
||||
} else {
|
||||
Err(anyhow!(
|
||||
"{}[{} {}]: {}",
|
||||
error_message,
|
||||
response.status(),
|
||||
response.status_text(),
|
||||
response.text().await?
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<yew::format::Nothing> for RequestBody<yew::format::Nothing> {
|
||||
fn from(request: yew::format::Nothing) -> Self {
|
||||
Self(request)
|
||||
}
|
||||
async fn call_server_json_with_error_message<CallbackResult, Body: Serialize>(
|
||||
url: &str,
|
||||
request: Option<Body>,
|
||||
error_message: &'static str,
|
||||
) -> Result<CallbackResult>
|
||||
where
|
||||
CallbackResult: DeserializeOwned + 'static,
|
||||
{
|
||||
let data = call_server(url, request, error_message).await?;
|
||||
serde_json::from_str(&data).context("Could not parse response")
|
||||
}
|
||||
|
||||
fn call_server<Req, CallbackResult, F, RB>(
|
||||
async fn call_server_empty_response_with_error_message<Body: Serialize>(
|
||||
url: &str,
|
||||
request: RB,
|
||||
callback: Callback<Result<CallbackResult>>,
|
||||
request: Option<Body>,
|
||||
error_message: &'static str,
|
||||
parse_response: F,
|
||||
) -> Result<FetchTask>
|
||||
where
|
||||
F: Fn(String) -> Result<CallbackResult> + 'static,
|
||||
CallbackResult: 'static,
|
||||
RB: Into<RequestBody<Req>>,
|
||||
Req: Into<yew::format::Text>,
|
||||
{
|
||||
let request = {
|
||||
// If the request type is empty (if the size is 0), it's a get.
|
||||
if std::mem::size_of::<RB>() == 0 {
|
||||
Request::get(url)
|
||||
} else {
|
||||
Request::post(url)
|
||||
}
|
||||
}
|
||||
.header("Content-Type", "application/json")
|
||||
.body(request.into().0)?;
|
||||
let handler = create_handler(callback, move |status: http::StatusCode, data: String| {
|
||||
if status.is_success() {
|
||||
parse_response(data)
|
||||
} else {
|
||||
Err(anyhow!("{}[{}]: {}", error_message, status, data))
|
||||
}
|
||||
});
|
||||
FetchService::fetch_with_options(request, get_default_options(), handler)
|
||||
) -> Result<()> {
|
||||
call_server(url, request, error_message).await.map(|_| ())
|
||||
}
|
||||
|
||||
fn call_server_json_with_error_message<CallbackResult, RB, Req>(
|
||||
url: &str,
|
||||
request: RB,
|
||||
callback: Callback<Result<CallbackResult>>,
|
||||
error_message: &'static str,
|
||||
) -> Result<FetchTask>
|
||||
where
|
||||
CallbackResult: serde::de::DeserializeOwned + 'static,
|
||||
RB: Into<RequestBody<Req>>,
|
||||
Req: Into<yew::format::Text>,
|
||||
{
|
||||
call_server(url, request, callback, error_message, |data: String| {
|
||||
serde_json::from_str(&data).context("Could not parse response")
|
||||
})
|
||||
}
|
||||
|
||||
fn call_server_empty_response_with_error_message<RB, Req>(
|
||||
url: &str,
|
||||
request: RB,
|
||||
callback: Callback<Result<()>>,
|
||||
error_message: &'static str,
|
||||
) -> Result<FetchTask>
|
||||
where
|
||||
RB: Into<RequestBody<Req>>,
|
||||
Req: Into<yew::format::Text>,
|
||||
{
|
||||
call_server(
|
||||
url,
|
||||
request,
|
||||
callback,
|
||||
error_message,
|
||||
|_data: String| Ok(()),
|
||||
)
|
||||
fn set_cookies_from_jwt(response: login::ServerLoginResponse) -> Result<(String, bool)> {
|
||||
let jwt_claims = get_claims_from_jwt(response.token.as_str()).context("Could not parse JWT")?;
|
||||
let is_admin = jwt_claims.groups.contains("lldap_admin");
|
||||
set_cookie("user_id", &jwt_claims.user, &jwt_claims.exp)
|
||||
.map(|_| set_cookie("is_admin", &is_admin.to_string(), &jwt_claims.exp))
|
||||
.map(|_| (jwt_claims.user.clone(), is_admin))
|
||||
.context("Error setting cookie")
|
||||
}
|
||||
|
||||
impl HostService {
|
||||
pub fn graphql_query<QueryType>(
|
||||
pub async fn graphql_query<QueryType>(
|
||||
variables: QueryType::Variables,
|
||||
callback: Callback<Result<QueryType::ResponseData>>,
|
||||
error_message: &'static str,
|
||||
) -> Result<FetchTask>
|
||||
) -> Result<QueryType::ResponseData>
|
||||
where
|
||||
QueryType: GraphQLQuery + 'static,
|
||||
{
|
||||
@@ -147,143 +95,103 @@ impl HostService {
|
||||
)
|
||||
})
|
||||
};
|
||||
let parse_graphql_response = move |data: String| {
|
||||
serde_json::from_str(&data)
|
||||
.context("Could not parse response")
|
||||
.and_then(unwrap_graphql_response)
|
||||
};
|
||||
let request_body = QueryType::build_query(variables);
|
||||
call_server(
|
||||
call_server_json_with_error_message::<graphql_client::Response<_>, _>(
|
||||
"/api/graphql",
|
||||
&request_body,
|
||||
callback,
|
||||
Some(request_body),
|
||||
error_message,
|
||||
parse_graphql_response,
|
||||
)
|
||||
.await
|
||||
.and_then(unwrap_graphql_response)
|
||||
}
|
||||
|
||||
pub fn login_start(
|
||||
pub async fn login_start(
|
||||
request: login::ClientLoginStartRequest,
|
||||
callback: Callback<Result<Box<login::ServerLoginStartResponse>>>,
|
||||
) -> Result<FetchTask> {
|
||||
) -> Result<Box<login::ServerLoginStartResponse>> {
|
||||
call_server_json_with_error_message(
|
||||
"/auth/opaque/login/start",
|
||||
&request,
|
||||
callback,
|
||||
Some(request),
|
||||
"Could not start authentication: ",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn login_finish(
|
||||
request: login::ClientLoginFinishRequest,
|
||||
callback: Callback<Result<(String, bool)>>,
|
||||
) -> Result<FetchTask> {
|
||||
let set_cookies = |jwt_claims: JWTClaims| {
|
||||
let is_admin = jwt_claims.groups.contains("lldap_admin");
|
||||
set_cookie("user_id", &jwt_claims.user, &jwt_claims.exp)
|
||||
.map(|_| set_cookie("is_admin", &is_admin.to_string(), &jwt_claims.exp))
|
||||
.map(|_| (jwt_claims.user.clone(), is_admin))
|
||||
.context("Error clearing cookie")
|
||||
};
|
||||
let parse_token = move |data: String| {
|
||||
serde_json::from_str::<login::ServerLoginResponse>(&data)
|
||||
.context("Could not parse response")
|
||||
.and_then(|r| {
|
||||
get_claims_from_jwt(r.token.as_str())
|
||||
.context("Could not parse response")
|
||||
.and_then(set_cookies)
|
||||
})
|
||||
};
|
||||
call_server(
|
||||
pub async fn login_finish(request: login::ClientLoginFinishRequest) -> Result<(String, bool)> {
|
||||
call_server_json_with_error_message::<login::ServerLoginResponse, _>(
|
||||
"/auth/opaque/login/finish",
|
||||
&request,
|
||||
callback,
|
||||
Some(request),
|
||||
"Could not finish authentication",
|
||||
parse_token,
|
||||
)
|
||||
.await
|
||||
.and_then(set_cookies_from_jwt)
|
||||
}
|
||||
|
||||
pub fn register_start(
|
||||
pub async fn register_start(
|
||||
request: registration::ClientRegistrationStartRequest,
|
||||
callback: Callback<Result<Box<registration::ServerRegistrationStartResponse>>>,
|
||||
) -> Result<FetchTask> {
|
||||
) -> Result<Box<registration::ServerRegistrationStartResponse>> {
|
||||
call_server_json_with_error_message(
|
||||
"/auth/opaque/register/start",
|
||||
&request,
|
||||
callback,
|
||||
Some(request),
|
||||
"Could not start registration: ",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn register_finish(
|
||||
pub async fn register_finish(
|
||||
request: registration::ClientRegistrationFinishRequest,
|
||||
callback: Callback<Result<()>>,
|
||||
) -> Result<FetchTask> {
|
||||
) -> Result<()> {
|
||||
call_server_empty_response_with_error_message(
|
||||
"/auth/opaque/register/finish",
|
||||
&request,
|
||||
callback,
|
||||
Some(request),
|
||||
"Could not finish registration",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn refresh(_request: (), callback: Callback<Result<(String, bool)>>) -> Result<FetchTask> {
|
||||
let set_cookies = |jwt_claims: JWTClaims| {
|
||||
let is_admin = jwt_claims.groups.contains("lldap_admin");
|
||||
set_cookie("user_id", &jwt_claims.user, &jwt_claims.exp)
|
||||
.map(|_| set_cookie("is_admin", &is_admin.to_string(), &jwt_claims.exp))
|
||||
.map(|_| (jwt_claims.user.clone(), is_admin))
|
||||
.context("Error clearing cookie")
|
||||
};
|
||||
let parse_token = move |data: String| {
|
||||
serde_json::from_str::<login::ServerLoginResponse>(&data)
|
||||
.context("Could not parse response")
|
||||
.and_then(|r| {
|
||||
get_claims_from_jwt(r.token.as_str())
|
||||
.context("Could not parse response")
|
||||
.and_then(set_cookies)
|
||||
})
|
||||
};
|
||||
call_server(
|
||||
pub async fn refresh() -> Result<(String, bool)> {
|
||||
call_server_json_with_error_message::<login::ServerLoginResponse, _>(
|
||||
"/auth/refresh",
|
||||
yew::format::Nothing,
|
||||
callback,
|
||||
NO_BODY,
|
||||
"Could not start authentication: ",
|
||||
parse_token,
|
||||
)
|
||||
.await
|
||||
.and_then(set_cookies_from_jwt)
|
||||
}
|
||||
|
||||
// The `_request` parameter is to make it the same shape as the other functions.
|
||||
pub fn logout(_request: (), callback: Callback<Result<()>>) -> Result<FetchTask> {
|
||||
call_server_empty_response_with_error_message(
|
||||
"/auth/logout",
|
||||
yew::format::Nothing,
|
||||
callback,
|
||||
"Could not logout",
|
||||
)
|
||||
pub async fn logout() -> Result<()> {
|
||||
call_server_empty_response_with_error_message("/auth/logout", NO_BODY, "Could not logout")
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn reset_password_step1(
|
||||
username: &str,
|
||||
callback: Callback<Result<()>>,
|
||||
) -> Result<FetchTask> {
|
||||
pub async fn reset_password_step1(username: String) -> Result<()> {
|
||||
call_server_empty_response_with_error_message(
|
||||
&format!("/auth/reset/step1/{}", username),
|
||||
yew::format::Nothing,
|
||||
callback,
|
||||
&format!("/auth/reset/step1/{}", url_escape::encode_query(&username)),
|
||||
NO_BODY,
|
||||
"Could not initiate password reset",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn reset_password_step2(
|
||||
token: &str,
|
||||
callback: Callback<Result<lldap_auth::password_reset::ServerPasswordResetResponse>>,
|
||||
) -> Result<FetchTask> {
|
||||
pub async fn reset_password_step2(
|
||||
token: String,
|
||||
) -> Result<lldap_auth::password_reset::ServerPasswordResetResponse> {
|
||||
call_server_json_with_error_message(
|
||||
&format!("/auth/reset/step2/{}", token),
|
||||
yew::format::Nothing,
|
||||
callback,
|
||||
NO_BODY,
|
||||
"Could not validate token",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn probe_password_reset() -> Result<bool> {
|
||||
Ok(
|
||||
gloo_net::http::Request::get("/auth/reset/step1/lldap_unlikely_very_long_user_name")
|
||||
.header("Content-Type", "application/json")
|
||||
.send()
|
||||
.await?
|
||||
.status()
|
||||
!= http::StatusCode::NOT_FOUND,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,21 +21,28 @@
|
||||
//! [`CommonComponentParts::update`]. This will in turn call [`CommonComponent::handle_msg`] and
|
||||
//! take care of error and task handling.
|
||||
|
||||
use std::{
|
||||
future::Future,
|
||||
marker::PhantomData,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use crate::infra::api::HostService;
|
||||
use anyhow::{Error, Result};
|
||||
use gloo_console::error;
|
||||
use graphql_client::GraphQLQuery;
|
||||
use yew::{
|
||||
prelude::*,
|
||||
services::{fetch::FetchTask, ConsoleService},
|
||||
};
|
||||
use yewtil::NeqAssign;
|
||||
use yew::prelude::*;
|
||||
|
||||
/// Trait required for common components.
|
||||
pub trait CommonComponent<C: Component + CommonComponent<C>>: Component {
|
||||
/// Handle the incoming message. If an error is returned here, any running task will be
|
||||
/// cancelled, the error will be written to the [`CommonComponentParts::error`] and the
|
||||
/// component will be refreshed.
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool>;
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool>;
|
||||
/// Get a mutable reference to the inner component parts, necessary for the CRTP.
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<C>;
|
||||
}
|
||||
@@ -43,41 +50,33 @@ pub trait CommonComponent<C: Component + CommonComponent<C>>: Component {
|
||||
/// Structure that contains the common parts needed by most components.
|
||||
/// The fields of [`props`] are directly accessible through a `Deref` implementation.
|
||||
pub struct CommonComponentParts<C: CommonComponent<C>> {
|
||||
link: ComponentLink<C>,
|
||||
pub props: <C as Component>::Properties,
|
||||
pub error: Option<Error>,
|
||||
task: Option<FetchTask>,
|
||||
is_task_running: Arc<Mutex<bool>>,
|
||||
_phantom: PhantomData<C>,
|
||||
}
|
||||
|
||||
impl<C: CommonComponent<C>> CommonComponentParts<C> {
|
||||
pub fn create() -> Self {
|
||||
CommonComponentParts {
|
||||
error: None,
|
||||
is_task_running: Arc::new(Mutex::new(false)),
|
||||
_phantom: PhantomData::<C>,
|
||||
}
|
||||
}
|
||||
/// Whether there is a currently running task in the background.
|
||||
pub fn is_task_running(&self) -> bool {
|
||||
self.task.is_some()
|
||||
}
|
||||
|
||||
/// Cancel any background task.
|
||||
pub fn cancel_task(&mut self) {
|
||||
self.task = None;
|
||||
}
|
||||
|
||||
pub fn create(props: <C as Component>::Properties, link: ComponentLink<C>) -> Self {
|
||||
Self {
|
||||
link,
|
||||
props,
|
||||
error: None,
|
||||
task: None,
|
||||
}
|
||||
*self.is_task_running.lock().unwrap()
|
||||
}
|
||||
|
||||
/// This should be called from the [`yew::prelude::Component::update`]: it will in turn call
|
||||
/// [`CommonComponent::handle_msg`] and handle any resulting error.
|
||||
pub fn update(com: &mut C, msg: <C as Component>::Message) -> ShouldRender {
|
||||
pub fn update(com: &mut C, ctx: &Context<C>, msg: <C as Component>::Message) -> bool {
|
||||
com.mut_common().error = None;
|
||||
match com.handle_msg(msg) {
|
||||
match com.handle_msg(ctx, msg) {
|
||||
Err(e) => {
|
||||
ConsoleService::error(&e.to_string());
|
||||
error!(&e.to_string());
|
||||
com.mut_common().error = Some(e);
|
||||
com.mut_common().cancel_task();
|
||||
assert!(!*com.mut_common().is_task_running.lock().unwrap());
|
||||
true
|
||||
}
|
||||
Ok(b) => b,
|
||||
@@ -87,10 +86,11 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
|
||||
/// Same as above, but the resulting error is instead passed to the reporting function.
|
||||
pub fn update_and_report_error(
|
||||
com: &mut C,
|
||||
ctx: &Context<C>,
|
||||
msg: <C as Component>::Message,
|
||||
report_fn: Callback<Error>,
|
||||
) -> ShouldRender {
|
||||
let should_render = Self::update(com, msg);
|
||||
) -> bool {
|
||||
let should_render = Self::update(com, ctx, msg);
|
||||
com.mut_common()
|
||||
.error
|
||||
.take()
|
||||
@@ -101,38 +101,24 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
|
||||
.unwrap_or(should_render)
|
||||
}
|
||||
|
||||
/// This can be called from [`yew::prelude::Component::update`]: it will check if the
|
||||
/// properties have changed and return whether the component should update.
|
||||
pub fn change(&mut self, props: <C as Component>::Properties) -> ShouldRender
|
||||
where
|
||||
<C as yew::Component>::Properties: std::cmp::PartialEq,
|
||||
{
|
||||
self.props.neq_assign(props)
|
||||
}
|
||||
|
||||
/// Create a callback from the link.
|
||||
pub fn callback<F, IN, M>(&self, function: F) -> Callback<IN>
|
||||
where
|
||||
M: Into<C::Message>,
|
||||
F: Fn(IN) -> M + 'static,
|
||||
{
|
||||
self.link.callback(function)
|
||||
}
|
||||
|
||||
/// Call `method` from the backend with the given `request`, and pass the `callback` for the
|
||||
/// result. Returns whether _starting the call_ failed.
|
||||
pub fn call_backend<M, Req, Cb, Resp>(
|
||||
&mut self,
|
||||
method: M,
|
||||
req: Req,
|
||||
callback: Cb,
|
||||
) -> Result<()>
|
||||
/// result.
|
||||
pub fn call_backend<Fut, Cb, Resp>(&mut self, ctx: &Context<C>, fut: Fut, callback: Cb)
|
||||
where
|
||||
M: Fn(Req, Callback<Resp>) -> Result<FetchTask>,
|
||||
Fut: Future<Output = Resp> + 'static,
|
||||
Cb: FnOnce(Resp) -> <C as Component>::Message + 'static,
|
||||
{
|
||||
self.task = Some(method(req, self.link.callback_once(callback))?);
|
||||
Ok(())
|
||||
{
|
||||
let mut running = self.is_task_running.lock().unwrap();
|
||||
assert!(!*running);
|
||||
*running = true;
|
||||
}
|
||||
let is_task_running = self.is_task_running.clone();
|
||||
ctx.link().send_future(async move {
|
||||
let res = fut.await;
|
||||
*is_task_running.lock().unwrap() = false;
|
||||
callback(res)
|
||||
});
|
||||
}
|
||||
|
||||
/// Call the backend with a GraphQL query.
|
||||
@@ -140,6 +126,7 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
|
||||
/// `EnumCallback` should usually be left as `_`.
|
||||
pub fn call_graphql<QueryType, EnumCallback>(
|
||||
&mut self,
|
||||
ctx: &Context<C>,
|
||||
variables: QueryType::Variables,
|
||||
enum_callback: EnumCallback,
|
||||
error_message: &'static str,
|
||||
@@ -147,29 +134,10 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
|
||||
QueryType: GraphQLQuery + 'static,
|
||||
EnumCallback: Fn(Result<QueryType::ResponseData>) -> <C as Component>::Message + 'static,
|
||||
{
|
||||
self.task = HostService::graphql_query::<QueryType>(
|
||||
variables,
|
||||
self.link.callback(enum_callback),
|
||||
error_message,
|
||||
)
|
||||
.map_err::<(), _>(|e| {
|
||||
ConsoleService::log(&e.to_string());
|
||||
self.error = Some(e);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: Component + CommonComponent<C>> std::ops::Deref for CommonComponentParts<C> {
|
||||
type Target = <C as Component>::Properties;
|
||||
|
||||
fn deref(&self) -> &<Self as std::ops::Deref>::Target {
|
||||
&self.props
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: Component + CommonComponent<C>> std::ops::DerefMut for CommonComponentParts<C> {
|
||||
fn deref_mut(&mut self) -> &mut <Self as std::ops::Deref>::Target {
|
||||
&mut self.props
|
||||
self.call_backend(
|
||||
ctx,
|
||||
HostService::graphql_query::<QueryType>(variables, error_message),
|
||||
enum_callback,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,11 @@ pub fn get_cookie(cookie_name: &str) -> Result<Option<String>> {
|
||||
|
||||
pub fn delete_cookie(cookie_name: &str) -> Result<()> {
|
||||
if get_cookie(cookie_name)?.is_some() {
|
||||
set_cookie(cookie_name, "", &Utc.ymd(1970, 1, 1).and_hms(0, 0, 0))
|
||||
set_cookie(
|
||||
cookie_name,
|
||||
"",
|
||||
&Utc.with_ymd_and_hms(1970, 1, 1, 0, 0, 0).unwrap(),
|
||||
)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen(module = "bootstrap")]
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
#[wasm_bindgen]
|
||||
#[wasm_bindgen(js_namespace = bootstrap)]
|
||||
pub type Modal;
|
||||
|
||||
#[wasm_bindgen(constructor)]
|
||||
#[wasm_bindgen(constructor, js_namespace = bootstrap)]
|
||||
pub fn new(e: web_sys::Element) -> Modal;
|
||||
|
||||
#[wasm_bindgen(method)]
|
||||
#[wasm_bindgen(method, js_namespace = bootstrap)]
|
||||
pub fn show(this: &Modal);
|
||||
|
||||
#[wasm_bindgen(method)]
|
||||
#[wasm_bindgen(method, js_namespace = bootstrap)]
|
||||
pub fn hide(this: &Modal);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#![recursion_limit = "256"]
|
||||
#![forbid(non_ascii_idents)]
|
||||
#![allow(clippy::nonstandard_macro_braces)]
|
||||
#![allow(clippy::uninlined_format_args)]
|
||||
#![allow(clippy::let_unit_value)]
|
||||
|
||||
pub mod components;
|
||||
pub mod infra;
|
||||
|
||||
@@ -8,7 +10,7 @@ use wasm_bindgen::prelude::{wasm_bindgen, JsValue};
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn run_app() -> Result<(), JsValue> {
|
||||
yew::start_app::<components::app::App>();
|
||||
yew::start_app::<components::app::AppContainer>();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/css/bootstrap.min.css
|
||||
https://cdn.jsdelivr.net/npm/bootstrap-dark-5@1.1.3/dist/css/bootstrap-nightshade.min.css
|
||||
https://cdn.jsdelivr.net/npm/bootstrap-dark-5@1.1.3/dist/js/darkmode.min.js
|
||||
https://cdn.jsdelivr.net/npm/bootstrap@5.1.1/dist/js/bootstrap.bundle.min.js
|
||||
https://cdn.jsdelivr.net/npm/bootstrap-icons@1.5.0/font/bootstrap-icons.css
|
||||
https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css
|
||||
10
app/static/main.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import init, { run_app } from '/pkg/lldap_app.js';
|
||||
async function main() {
|
||||
if(navigator.userAgent.indexOf('AppleWebKit') != -1) {
|
||||
await init('/pkg/lldap_app_bg.wasm');
|
||||
} else {
|
||||
await init('/pkg/lldap_app_bg.wasm.gz');
|
||||
}
|
||||
run_app();
|
||||
}
|
||||
main()
|
||||
@@ -1,4 +1,4 @@
|
||||
header h1 {
|
||||
header h2 {
|
||||
font-family: 'Bebas Neue', cursive;
|
||||
}
|
||||
|
||||
@@ -10,3 +10,23 @@ header h1 {
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
html.dark .bg-light {
|
||||
background-color: rgba(59,59,59,1) !important;
|
||||
}
|
||||
|
||||
html.dark a {
|
||||
color: #e1e1e1
|
||||
}
|
||||
|
||||
a {
|
||||
color: #212529
|
||||
}
|
||||
|
||||
html.dark .nav-link {
|
||||
color: #e1e1e1
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: #212529
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
[package]
|
||||
name = "lldap_auth"
|
||||
version = "0.3.0-alpha.1"
|
||||
authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
|
||||
description = "Authentication protocol for LLDAP"
|
||||
edition = "2021"
|
||||
homepage = "https://github.com/lldap/lldap"
|
||||
license = "GPL-3.0-only"
|
||||
name = "lldap_auth"
|
||||
repository = "https://github.com/lldap/lldap"
|
||||
version = "0.4.0"
|
||||
|
||||
[features]
|
||||
default = ["opaque_server", "opaque_client"]
|
||||
@@ -30,7 +34,7 @@ features = [ "serde" ]
|
||||
# For WASM targets, use the JS getrandom.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies.getrandom]
|
||||
version = "0.2"
|
||||
features = ["js"]
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies.getrandom]
|
||||
version = "0.2"
|
||||
features = ["js"]
|
||||
|
||||
@@ -77,10 +77,10 @@ pub mod client {
|
||||
pub use opaque_ke::ClientRegistrationFinishParameters;
|
||||
/// Initiate the registration negotiation.
|
||||
pub fn start_registration<R: RngCore + CryptoRng>(
|
||||
password: &str,
|
||||
password: &[u8],
|
||||
rng: &mut R,
|
||||
) -> AuthenticationResult<ClientRegistrationStartResult> {
|
||||
Ok(ClientRegistration::start(rng, password.as_bytes())?)
|
||||
Ok(ClientRegistration::start(rng, password)?)
|
||||
}
|
||||
|
||||
/// Finalize the registration negotiation.
|
||||
|
||||
@@ -1,20 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
for SECRET in LLDAP_JWT_SECRET LLDAP_LDAP_USER_PASS; do
|
||||
FILE_VAR="${SECRET}_FILE"
|
||||
SECRET_FILE="${!FILE_VAR:-}"
|
||||
if [[ -n "$SECRET_FILE" ]]; then
|
||||
if [[ -f "$SECRET_FILE" ]]; then
|
||||
declare "$SECRET=$(cat $SECRET_FILE)"
|
||||
export "$SECRET"
|
||||
echo "[entrypoint] Set $SECRET from $SECRET_FILE"
|
||||
else
|
||||
echo "[entrypoint] Could not read contents of $SECRET_FILE (specified in $FILE_VAR)" >&2
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
CONFIG_FILE=/data/lldap_config.toml
|
||||
|
||||
if [[ ( ! -w "/data" ) ]] || [[ ( ! -d "/data" ) ]]; then
|
||||
@@ -35,4 +21,13 @@ if [[ ! -r "$CONFIG_FILE" ]]; then
|
||||
exit 1;
|
||||
fi
|
||||
|
||||
exec /app/lldap "$@"
|
||||
echo "> Setup permissions.."
|
||||
find /app \! -user "$UID" -exec chown "$UID:$GID" '{}' +
|
||||
find /data \! -user "$UID" -exec chown "$UID:$GID" '{}' +
|
||||
|
||||
|
||||
echo "> Starting lldap.."
|
||||
echo ""
|
||||
exec gosu "$UID:$GID" /app/lldap "$@"
|
||||
|
||||
exec "$@"
|
||||
|
||||
@@ -26,9 +26,9 @@ Frontend:
|
||||
|
||||
Data storage:
|
||||
* The data (users, groups, memberships, active JWTs, ...) is stored in SQL.
|
||||
* Currently only SQLite is supported (see
|
||||
https://github.com/launchbadge/sqlx/issues/1225 for what blocks us from
|
||||
supporting more SQL backends).
|
||||
* The main SQL DBs are supported: SQLite by default, MySQL, MariaDB, PostgreSQL
|
||||
(see [DB Migration](/database_migration.md) for how to migrate off of
|
||||
SQLite).
|
||||
|
||||
### Code organization
|
||||
|
||||
|
||||
BIN
docs/cookie.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
110
docs/database_migration.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# Migration
|
||||
|
||||
Existing servers can migrate from one database backend to another. This page includes guidance for migrating from SQLite - similar concepts apply when migrating from databases of other types.
|
||||
|
||||
NOTE: [pgloader](https://github.com/dimitri/pgloader) is a tool that can easily migrate to PostgreSQL from other databases. Consider it if your target database is PostgreSQL
|
||||
|
||||
The process is as follows:
|
||||
|
||||
1. Create empty schema on target database
|
||||
2. Stop/pause LLDAP and dump existing values
|
||||
3. Sanitize for target DB (not always required)
|
||||
4. Insert data into target
|
||||
5. Change LLDAP config to new target and restart
|
||||
|
||||
The steps below assume you already have PostgreSQL or MySQL set up with an empty database for LLDAP to use.
|
||||
|
||||
## Create schema on target
|
||||
|
||||
LLDAP has a command that will connect to a target database and initialize the
|
||||
schema. If running with docker, run the following command to use your active
|
||||
instance (this has the benefit of ensuring your container has access):
|
||||
|
||||
```sh
|
||||
docker exec -it <LLDAP container name> /app/lldap create_schema -d <Target database url>
|
||||
```
|
||||
|
||||
If it succeeds, you can proceed to the next step.
|
||||
|
||||
## Create a dump of existing data
|
||||
|
||||
We want to dump (almost) all existing values to some file - the exception being the `metadata` table (and sometimes
|
||||
the `sqlite_sequence` table, when it exists). Be sure to stop/pause LLDAP during this step, as some
|
||||
databases (SQLite in this example) will give an error if LLDAP is in the middle of a write. The dump should consist just INSERT
|
||||
statements. There are various ways to do this, but a simple enough way is filtering a
|
||||
whole database dump. This repo contains [a script](/scripts/sqlite_dump_commands.sh) to generate SQLite commands for creating an appropriate dump:
|
||||
|
||||
```sh
|
||||
./sqlite_dump_commands.sh | sqlite3 /path/to/lldap/config/users.db > /path/to/dump.sql
|
||||
```
|
||||
|
||||
## Sanitize data
|
||||
|
||||
Some databases might use different formats for some data - for example, PostgreSQL uses
|
||||
a different syntax for hex strings than SQLite. We also want to make sure inserts are done in
|
||||
a transaction in case one of the statements fail.
|
||||
|
||||
### To PostgreSQL
|
||||
|
||||
PostgreSQL uses a different hex string format. The command below should switch SQLite
|
||||
format to PostgreSQL format, and wrap it all in a transaction:
|
||||
|
||||
```sh
|
||||
sed -i -r -e "s/X'([[:xdigit:]]+'[^'])/'\\\x\\1/g" \
|
||||
-e ":a; s/(INSERT INTO user_attribute_schema\(.*\) VALUES\(.*),1([^']*\);)$/\1,true\2/; s/(INSERT INTO user_attribute_schema\(.*\) VALUES\(.*),0([^']*\);)$/\1,false\2/; ta" \
|
||||
-e '1s/^/BEGIN;\n/' \
|
||||
-e '$aCOMMIT;' /path/to/dump.sql
|
||||
```
|
||||
|
||||
### To MySQL
|
||||
|
||||
MySQL mostly cooperates, but it gets some errors if you don't escape the `groups` table. It also uses
|
||||
backticks to escape table name instead of quotes. Run the
|
||||
following command to wrap all table names in backticks for good measure, and wrap the inserts in
|
||||
a transaction:
|
||||
|
||||
```sh
|
||||
sed -i -r -e 's/^INSERT INTO "?([a-zA-Z0-9_]+)"?/INSERT INTO `\1`/' \
|
||||
-e '1s/^/START TRANSACTION;\n/' \
|
||||
-e '$aCOMMIT;' \
|
||||
-e '1 i\SET FOREIGN_KEY_CHECKS = 0;' /path/to/dump.sql
|
||||
```
|
||||
|
||||
### To MariaDB
|
||||
|
||||
While MariaDB is supposed to be identical to MySQL, it doesn't support timezone offsets on DATETIME
|
||||
strings. Use the following command to remove those and perform the additional MySQL sanitization:
|
||||
|
||||
```sh
|
||||
sed -i -r -e "s/([^']'[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{9})\+00:00'([^'])/\1'\2/g" \
|
||||
-e 's/^INSERT INTO "?([a-zA-Z0-9_]+)"?/INSERT INTO `\1`/' \
|
||||
-e '1s/^/START TRANSACTION;\n/' \
|
||||
-e '$aCOMMIT;' \
|
||||
-e '1 i\SET FOREIGN_KEY_CHECKS = 0;' /path/to/dump.sql
|
||||
```
|
||||
|
||||
## Insert data
|
||||
|
||||
Insert the data generated from the previous step into the target database. If you encounter errors,
|
||||
you may need to manually tweak your dump, or make changed in LLDAP and recreate the dump.
|
||||
|
||||
### PostgreSQL
|
||||
|
||||
`psql -d <database> -U <username> -W < /path/to/dump.sql`
|
||||
|
||||
or
|
||||
|
||||
`psql -d <database> -U <username> -W -f /path/to/dump.sql`
|
||||
|
||||
### MySQL
|
||||
|
||||
`mysql -u <username> -p <database> < /path/to/dump.sql`
|
||||
|
||||
|
||||
## Switch to new database
|
||||
|
||||
Modify your `database_url` in `lldap_config.toml` (or `LLDAP_DATABASE_URL` in the env)
|
||||
to point to your new database (the same value used when generating schema). Restart
|
||||
LLDAP and check the logs to ensure there were no errors.
|
||||
|
||||
#### More details/examples can be seen in the CI process [here](https://raw.githubusercontent.com/nitnelave/lldap/main/.github/workflows/docker-build-static.yml), look for the job `lldap-database-migration-test`
|
||||
58
docs/migration_guides/v0.5.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Migration from 0.4 to 0.5
|
||||
|
||||
Welcome! If you're here, it's probably that the migration from 0.4.x to 0.5
|
||||
didn't go smoothly for you. Don't worry, we can fix that.
|
||||
|
||||
## Multiple users with the same email
|
||||
|
||||
This is the most common case. You can see in the LLDAP logs that there are
|
||||
several users with the same email, and they are listed.
|
||||
|
||||
This is not allowed anymore in v0.5, to prevent a user from setting their email
|
||||
to someone else's email and gaining access to systems that identify by email.
|
||||
|
||||
The problem is that you currently have several users with the same email, so the
|
||||
constraint cannot be enforced.
|
||||
|
||||
### Step 1: Take a note of the users with duplicate emails
|
||||
|
||||
In the LLDAP logs when you tried to start v0.5+, you'll see some warnings with
|
||||
the list of users with the same emails. Take note of them.
|
||||
|
||||
### Step 2: Downgrade to v0.4.3
|
||||
|
||||
If using docker, switch to the `lldap/lldap:v0.4.3` image. Alternatively, grab
|
||||
the binaries at https://github.com/lldap/lldap/releases/tag/v0.4.3.
|
||||
|
||||
This downgrade is safe and supported.
|
||||
|
||||
### Step 3: Remove duplicate emails
|
||||
|
||||
Restart LLDAP with the v0.4.3 version, and using your notes from step 1, change
|
||||
the email of users with duplicate emails to make sure that each email is unique.
|
||||
|
||||
### Step 4: Upgrade again
|
||||
|
||||
You can now revert to the initial version.
|
||||
|
||||
## Multiple users/groups with the same UUID
|
||||
|
||||
This should be extremely rare. In this case, you'll need to find which users
|
||||
have the same UUID, revert to v0.4.3 to be able to apply the changes, and delete
|
||||
one of the duplicates.
|
||||
|
||||
## FAQ
|
||||
|
||||
### What if I want several users to be controlled by the same email?
|
||||
|
||||
You can use plus codes to set "the same" email to several users, while ensuring
|
||||
that they can't identify as each other. For instance:
|
||||
|
||||
- Admin: `admin@example.com`
|
||||
- Read-only admin: `admin+readonly@example.com`
|
||||
- Jellyfin admin: `admin+jellyfin@example.com`
|
||||
|
||||
### I'm upgrading to a higher version than v0.5.
|
||||
|
||||
This guide is still relevant: you can use whatever later version in place of
|
||||
v0.5. You'll still need to revert to v0.4.3 to apply the changes.
|
||||
90
docs/scripting.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# Scripting
|
||||
|
||||
Programmatically accessing LLDAP can be done either through the LDAP protocol,
|
||||
or via the GraphQL API.
|
||||
|
||||
## LDAP
|
||||
|
||||
Most _read-only_ queries about users and groups are supported. Anything not
|
||||
supported would be considered a missing feature or a bug.
|
||||
|
||||
Most _modification_ queries are not supported, except for creating users and
|
||||
changing the password (through the extended password operation). Those could be
|
||||
added in the future, on a case-by-case basis.
|
||||
|
||||
Most _meta_-queries about the LDAP server itself are not supported and are out
|
||||
of scope. That includes anything that touches the schema, for instance. LLDAP
|
||||
still supports basic RootDSE queries.
|
||||
|
||||
Anonymous bind is not supported.
|
||||
|
||||
## GraphQL
|
||||
|
||||
The best way to interact with LLDAP programmatically is via the GraphQL
|
||||
interface. You can use any language that has a GraphQL library (most of them
|
||||
do), and use the [GraphQL Schema](../schema.graphql) to guide your queries.
|
||||
|
||||
### Getting a token
|
||||
|
||||
You'll need a JWT (authentication token) to issue GraphQL queries. Your view of
|
||||
the system will be limited by the rights of your user. In particular, regular
|
||||
users can only see themselves and the groups they belong to (but not other
|
||||
members of these groups, for instance).
|
||||
|
||||
#### Manually
|
||||
|
||||
Log in to the web front-end of LLDAP. Then open the developer tools (F12), find
|
||||
the "Storage > Cookies", and you'll find the "token" cookie with your JWT.
|
||||
|
||||

|
||||
|
||||
#### Automatically
|
||||
|
||||
The easiest way is to send a json POST request to `/auth/simple/login` with
|
||||
`{"username": "john", "password": "1234"}` in the body.
|
||||
Then you'll receive a JSON response with:
|
||||
|
||||
```
|
||||
{
|
||||
"token": "eYbat...",
|
||||
"refreshToken": "3bCka...",
|
||||
}
|
||||
```
|
||||
|
||||
### Using the token
|
||||
|
||||
You can use the token directly, either as a cookie, or as a bearer auth token
|
||||
(add an "Authorization" header with contents `"Bearer <token>"`).
|
||||
|
||||
The JWT is valid for 1 day (unless you log out explicitly).
|
||||
You can use the refresh token to query `/auth/refresh` and get another JWT. The
|
||||
refresh token is valid for 30 days.
|
||||
|
||||
### Testing your GraphQL queries
|
||||
|
||||
You can go to `/api/graphql/playground` to test your queries and explore the
|
||||
data in the playground. You'll need to provide the JWT in the headers:
|
||||
|
||||
```
|
||||
{ "Authorization": "Bearer abcdef123..." }
|
||||
```
|
||||
|
||||
Then you can enter your query, for instance:
|
||||
|
||||
```graphql
|
||||
{
|
||||
user(userId:"admin") {
|
||||
displayName
|
||||
}
|
||||
groups {
|
||||
id
|
||||
displayName
|
||||
users {
|
||||
id
|
||||
email
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The schema is on the right, along with some basic docs.
|
||||
26
example_configs/airsonic-advanced.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Configuration for Airsonic Advanced
|
||||
|
||||
Replace `dc=example,dc=com` with your LLDAP configured domain.
|
||||
|
||||
### LDAP URL
|
||||
```
|
||||
ldap://lldap:3890/ou=people,dc=example,dc=com
|
||||
```
|
||||
### LDAP search filter
|
||||
```
|
||||
(&(uid={0})(memberof=cn=airsonic,ou=groups,dc=example,dc=com))
|
||||
```
|
||||
|
||||
### LDAP manager DN
|
||||
```
|
||||
cn=admin,ou=people,dc=example,dc=com
|
||||
```
|
||||
|
||||
### Password
|
||||
```
|
||||
admin-password
|
||||
```
|
||||
|
||||
Make sure the box `Automatically create users in Airsonic` is checked.
|
||||
|
||||
Restart airsonic-advanced
|
||||
@@ -30,11 +30,11 @@ authentication_backend:
|
||||
additional_users_dn: ou=people
|
||||
# To allow sign in both with username and email, one can use a filter like
|
||||
# (&(|({username_attribute}={input})({mail_attribute}={input}))(objectClass=person))
|
||||
users_filter: (&({username_attribute}={input})(objectClass=person))
|
||||
users_filter: "(&({username_attribute}={input})(objectClass=person))"
|
||||
# Set this to ou=groups, because all groups are stored in this ou
|
||||
additional_groups_dn: ou=groups
|
||||
# Only this filter is supported right now
|
||||
groups_filter: (member={dn})
|
||||
groups_filter: "(member={dn})"
|
||||
# The attribute holding the name of the group.
|
||||
group_name_attribute: cn
|
||||
# Email attribute
|
||||
|
||||
105
example_configs/authentik.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# Name
|
||||
```
|
||||
lldap
|
||||
```
|
||||
|
||||
# Slug
|
||||
```
|
||||
lldap
|
||||
```
|
||||
- [x] Enabled
|
||||
- [x] Sync Users
|
||||
- [x] User password writeback
|
||||
- [x] Sync groups
|
||||
|
||||
# Connection settings
|
||||
|
||||
## Server URI
|
||||
```
|
||||
ldap://lldap:3890
|
||||
```
|
||||
|
||||
- [ ] Enable StartTLS
|
||||
|
||||
## TLS Verification Certificate
|
||||
```
|
||||
---------
|
||||
```
|
||||
|
||||
## Bind CN
|
||||
```
|
||||
uid=admin,ou=people,dc=example,dc=com
|
||||
```
|
||||
|
||||
## Bind Password
|
||||
```
|
||||
ADMIN_PASSWORD
|
||||
```
|
||||
|
||||
## Base DN
|
||||
```
|
||||
dc=example,dc=com
|
||||
```
|
||||
|
||||
# LDAP Attribute mapping
|
||||
## User Property Mappings
|
||||
- [x] authentik default LDAP Mapping: mail
|
||||
- [x] authentik default LDAP Mapping: Name
|
||||
- [x] authentik default Active Directory Mapping: givenName
|
||||
- [ ] authentik default Active Directory Mapping: sAMAccountName
|
||||
- [x] authentik default Active Directory Mapping: sn
|
||||
- [ ] authentik default Active Directory Mapping: userPrincipalName
|
||||
- [x] authentik default OpenLDAP Mapping: cn
|
||||
- [x] authentik default OpenLDAP Mapping: uid
|
||||
|
||||
## Group Property Mappings
|
||||
- [ ] authentik default LDAP Mapping: mail
|
||||
- [ ] authentik default LDAP Mapping: Name
|
||||
- [ ] authentik default Active Directory Mapping: givenName
|
||||
- [ ] authentik default Active Directory Mapping: sAMAccountName
|
||||
- [ ] authentik default Active Directory Mapping: sn
|
||||
- [ ] authentik default Active Directory Mapping: userPrincipalName
|
||||
- [x] authentik default OpenLDAP Mapping: cn
|
||||
- [ ] authentik default OpenLDAP Mapping: uid
|
||||
|
||||
# Additional settings
|
||||
|
||||
## Group
|
||||
```
|
||||
---------
|
||||
```
|
||||
|
||||
## User path
|
||||
```
|
||||
LDAP/users
|
||||
```
|
||||
|
||||
## Addition User DN
|
||||
```
|
||||
ou=people
|
||||
```
|
||||
|
||||
## Addition Group DN
|
||||
```
|
||||
ou=groups
|
||||
```
|
||||
|
||||
## User object filter
|
||||
```
|
||||
(objectClass=person)
|
||||
```
|
||||
|
||||
## Group object filter
|
||||
```
|
||||
(objectClass=groupOfUniqueNames)
|
||||
```
|
||||
|
||||
## Group membership field
|
||||
```
|
||||
member
|
||||
```
|
||||
|
||||
## Object uniqueness field
|
||||
```
|
||||
uid
|
||||
```
|
||||
57
example_configs/dell_idrac.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Configuration for Dell iDRAC
|
||||
|
||||
## iDRAC 9
|
||||
|
||||
iDRAC 9 can only be connected to LDAPS, so make sure you have that enabled.
|
||||
|
||||
The settings then are as follows:
|
||||
|
||||
### Use Distinguished Name to Search Group Membership
|
||||
```
|
||||
Enabled
|
||||
```
|
||||
|
||||
### LDAP Server Address
|
||||
```
|
||||
Your server address eg. localhost
|
||||
```
|
||||
|
||||
### LDAP Server Port
|
||||
```
|
||||
Your LDAPS port, eg. 6360 or 636
|
||||
```
|
||||
|
||||
### Bind DN
|
||||
```
|
||||
uid=admin,ou=people,dc=example,dc=com
|
||||
```
|
||||
|
||||
### Bind Password
|
||||
```
|
||||
Enabled
|
||||
```
|
||||
|
||||
### Bind Password
|
||||
```
|
||||
Your admin user password
|
||||
```
|
||||
|
||||
### Attribute of User Login
|
||||
```
|
||||
uid
|
||||
```
|
||||
|
||||
### Attribute of Group Membership
|
||||
```
|
||||
member
|
||||
```
|
||||
|
||||
### Search Filter
|
||||
```
|
||||
(&(objectClass=person)(memberof=cn=idrac_users,ou=groups,dc=example,dc=com))
|
||||
```
|
||||
|
||||
For the Group Role Mappings, you define groups by their full `Group DN`, eg.
|
||||
```
|
||||
cn=idrac_users,ou=groups,dc=example,dc=com
|
||||
```
|
||||
32
example_configs/dex_config.yml
Normal file
@@ -0,0 +1,32 @@
|
||||
# lldap configuration:
|
||||
# LLDAP_LDAP_BASE_DN: dc=example,dc=com
|
||||
|
||||
# ##############################
|
||||
# rest of the Dex options
|
||||
# ##############################
|
||||
|
||||
connectors:
|
||||
- type: ldap
|
||||
id: ldap
|
||||
name: LDAP
|
||||
config:
|
||||
host: lldap-host # make sure it does not start with `ldap://`
|
||||
port: 3890 # or 6360 if you have ldaps enabled
|
||||
insecureNoSSL: true # or false if you have ldaps enabled
|
||||
insecureSkipVerify: true # or false if you have ldaps enabled
|
||||
bindDN: uid=admin,ou=people,dc=example,dc=com # replace admin with your admin user
|
||||
bindPW: very-secure-password # replace with your admin password
|
||||
userSearch:
|
||||
baseDN: ou=people,dc=example,dc=com
|
||||
username: uid
|
||||
idAttr: uid
|
||||
emailAttr: mail
|
||||
nameAttr: displayName
|
||||
preferredUsernameAttr: uid
|
||||
groupSearch:
|
||||
baseDN: ou=groups,dc=example,dc=com
|
||||
filter: "(objectClass=groupOfUniqueNames)"
|
||||
userMatchers:
|
||||
- userAttr: DN
|
||||
groupAttr: member
|
||||
nameAttr: cn
|
||||
25
example_configs/dokuwiki.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Configuration for dokuwiki
|
||||
|
||||
LDAP configuration is in ```/dokuwiki/conf/local.protected.php```:
|
||||
|
||||
```
|
||||
<?php
|
||||
$conf['useacl'] = 1; //enable ACL
|
||||
$conf['authtype'] = 'authldap'; //enable this Auth plugin
|
||||
$conf['plugin']['authldap']['server'] = 'ldap://lldap_server:3890'; #IP of your lldap
|
||||
$conf['plugin']['authldap']['usertree'] = 'ou=people,dc=example,dc=com';
|
||||
$conf['plugin']['authldap']['grouptree'] = 'ou=groups, dc=example, dc=com';
|
||||
$conf['plugin']['authldap']['userfilter'] = '(&(uid=%{user})(objectClass=person))';
|
||||
$conf['plugin']['authldap']['groupfilter'] = '(&(member=%{dn})(objectClass=groupOfUniqueNames))';
|
||||
$conf['plugin']['authldap']['attributes'] = array('cn', 'displayname', 'mail', 'givenname', 'objectclass', 'sn', 'uid', 'memberof');
|
||||
$conf['plugin']['authldap']['version'] = 3;
|
||||
$conf['plugin']['authldap']['binddn'] = 'cn=admin,ou=people,dc=example,dc=com';
|
||||
$conf['plugin']['authldap']['bindpw'] = 'ENTER_YOUR_LLDAP_PASSWORD';
|
||||
```
|
||||
|
||||
DokuWiki by default, ships with an LDAP Authentication Plugin called ```authLDAP``` that allows authentication against an LDAP directory.
|
||||
All you need to do is to activate the plugin. This can be done on the DokuWiki Extensions Manager.
|
||||
|
||||
Once the LDAP settings are defined, proceed to define the default authentication method.
|
||||
Navigate to Table of Contents > DokuWiki > Authentication.
|
||||
On the Authentication backend, select ```authldap``` and save the changes.
|
||||
30
example_configs/ejabberd.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Basic LDAP auth for a Ejabberd XMPP server
|
||||
|
||||
[Main documentation here.](https://docs.ejabberd.im/admin/configuration/ldap/)
|
||||
|
||||
For simple user auth add this to main ejabberd.yml:
|
||||
|
||||
```
|
||||
host_config:
|
||||
xmpp.example.org:
|
||||
auth_method: [ldap]
|
||||
ldap_servers:
|
||||
- 127.0.0.1 #IP or hostname of LLDAP server
|
||||
ldap_port: 3890
|
||||
ldap_uids:
|
||||
- uid
|
||||
ldap_rootdn: "uid=lldap_readonly,ou=people,dc=example,dc=org"
|
||||
ldap_password: "secret"
|
||||
ldap_base: "ou=people,dc=example,dc=org"
|
||||
```
|
||||
|
||||
## vCard from LDAP
|
||||
Theoretically possible, [see the documentation.](https://docs.ejabberd.im/admin/configuration/ldap/#vcard-in-ldap)
|
||||
|
||||
TODO
|
||||
|
||||
## Shared roster groups from LDAP
|
||||
|
||||
Theoretically possible, [see the documentation.](https://docs.ejabberd.im/admin/configuration/ldap/#shared-roster-in-ldap)
|
||||
|
||||
TODO
|
||||
22
example_configs/ergo.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Basic LDAP auth for an Ergo IRC server
|
||||
|
||||
[Main documentation here.](https://github.com/ergochat/ergo-ldap)
|
||||
|
||||
For simple user auth prepare a ldap-config.yaml with the following settings
|
||||
|
||||
```
|
||||
host: "127.0.0.1"
|
||||
port: 3890
|
||||
timeout: 30s
|
||||
|
||||
# uncomment for TLS / LDAPS:
|
||||
# use-ssl: true
|
||||
|
||||
bind-dn: "uid=%s,ou=people,dc=example,dc=org"
|
||||
```
|
||||
|
||||
Then add the compiled ergo-ldap program to your Ergo folder and make sure it can be executed by the same user your Ergo IRCd runs as.
|
||||
|
||||
Follow the instructions in the main Ergo config file's accounts section on how to execute an external auth program.
|
||||
|
||||
Make sure SASL auth is enabled and then restart Ergo to enable LDAP linked SASL auth.
|
||||
@@ -1,4 +1,4 @@
|
||||
# Configuration for Gitea
|
||||
# Configuration for Gitea (& Forgejo)
|
||||
In Gitea, go to `Site Administration > Authentication Sources` and click `Add Authentication Source`
|
||||
Select `LDAP (via BindDN)`
|
||||
|
||||
@@ -14,9 +14,36 @@ To log in they can either use their email address or user name. If you only want
|
||||
For more info on the user filter, see: https://docs.gitea.io/en-us/authentication/#ldap-via-binddn
|
||||
* Admin Filter: Use `(memberof=cn=lldap_admin,ou=groups,dc=example,dc=com)` if you want lldap admins to become Gitea admins. Leave empty otherwise.
|
||||
* Username Attribute: `uid`
|
||||
* First Name Attribute: `givenName`
|
||||
* Surname Attribute: `sn`
|
||||
* Email Attribute: `mail`
|
||||
* Avatar Attribute: `jpegPhoto`
|
||||
* Check `Enable User Synchronization`
|
||||
|
||||
Replace every instance of `dc=example,dc=com` with your configured domain.
|
||||
|
||||
After applying the above settings, users should be able to log in with either their user name or email address.
|
||||
After applying the above settings, users should be able to log in with either their user name or email address.
|
||||
|
||||
## Syncronizing LDAP groups with existing teams in organisations
|
||||
|
||||
Groups in LLDAP can be syncronized with teams in organisations. Organisations and teams must be created manually in Gitea.
|
||||
It is possible to syncronize one LDAP group with multiple teams in a Gitea organization.
|
||||
|
||||
Check `Enable LDAP Groups`
|
||||
|
||||
* Group Search Base DN: `ou=groups,dc=example,dc=com`
|
||||
* Group Attribute Containing List Of Users: `member`
|
||||
* User Attribute Listed In Group: `dn`
|
||||
* Map LDAP groups to Organization teams: `{"cn=Groupname1,ou=groups,dc=example,dc=com":{"Organization1": ["Teamname"]},"cn=Groupname2,ou=groups,dc=example,dc=com": {"Organization2": ["Teamname1", "Teamname2"]}}`
|
||||
|
||||
Check `Remove Users from syncronised teams...`
|
||||
|
||||
The `Map LDAP groups to Organization teams` config is JSON formatted and can be extended to as many groups as needed.
|
||||
|
||||
Replace every instance of `dc=example,dc=com` with your configured domain.
|
||||
|
||||
# Configuration for Gitea in `simple auth` mode
|
||||
|
||||
* The configuration method is the same as `BindDN` mode.
|
||||
* `BindDN` and `password` are not required
|
||||
* Gitea will not be able to pre-sync users, user account will be created at login time.
|
||||
|
||||
@@ -20,7 +20,7 @@ ssl_skip_verify = false
|
||||
# client_key = "/path/to/client.key"
|
||||
|
||||
# Search user bind dn
|
||||
bind_dn = "cn=<your grafana user>,ou=people,dc=example,dc=org"
|
||||
bind_dn = "uid=<your grafana user>,ou=people,dc=example,dc=org"
|
||||
# Search user bind password
|
||||
# If the password contains # or ; you have to wrap it with triple quotes. Ex """#password;"""
|
||||
bind_password = "<grafana user password>"
|
||||
@@ -37,13 +37,13 @@ search_base_dns = ["dc=example,dc=org"]
|
||||
[servers.attributes]
|
||||
member_of = "memberOf"
|
||||
email = "mail"
|
||||
name = "givenName"
|
||||
name = "displayName"
|
||||
surname = "sn"
|
||||
username = "uid"
|
||||
|
||||
# If you want to map your ldap groups to grafana's groups, see: https://grafana.com/docs/grafana/latest/auth/ldap/#group-mappings
|
||||
# As a quick example, here is how you would map lldap's admin group to grafana's admin
|
||||
# [[servers.group_mappings]]
|
||||
# group_dn = "cn=lldap_admin,ou=groups,c=example,dc=org"
|
||||
# group_dn = "cn=lldap_admin,ou=groups,dc=example,dc=org"
|
||||
# org_role = "Admin"
|
||||
# grafana_admin = true
|
||||
|
||||
16
example_configs/hedgedoc.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# Configuration for hedgedoc
|
||||
|
||||
[Hedgedoc](https://hedgedoc.org/) is a platform to write and share markdown.
|
||||
|
||||
### Using docker variables
|
||||
|
||||
Any member of the group ```hedgedoc``` can log into hedgedoc.
|
||||
```
|
||||
- CMD_LDAP_URL=ldap://lldap:3890
|
||||
- CMD_LDAP_BINDDN=uid=admin,ou=people,dc=example,dc=com
|
||||
- CMD_LDAP_BINDCREDENTIALS=insert_your_password
|
||||
- CMD_LDAP_SEARCHBASE=ou=people,dc=example,dc=com
|
||||
- CMD_LDAP_SEARCHFILTER=(&(memberOf=cn=hedgedoc,ou=groups,dc=example,dc=com)(uid={{username}}))
|
||||
- CMD_LDAP_USERIDFIELD=uid
|
||||
```
|
||||
Replace `dc=example,dc=com` with your LLDAP configured domain for all occurances
|
||||
24
example_configs/home-assistant.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Home Assistant Configuration
|
||||
|
||||
Home Assistant configures ldap auth via the [Command Line Auth Provider](https://www.home-assistant.io/docs/authentication/providers/#command-line). The wiki mentions a script that can be used for LDAP authentication, but it doesn't work in the container version (it is lacking both `ldapsearch` and `curl` ldap protocol support). Thankfully LLDAP has a graphql API to save the day!
|
||||
|
||||
## Graphql-based Auth Script
|
||||
|
||||
The [auth script](lldap-ha-auth.sh) attempts to authenticate a user against an LLDAP server, using credentials provided via `username` and `password` environment variables. The first argument must be the URL of your LLDAP server, accessible from Home Assistant. You can provide an additional optional argument to confine allowed logins to a single group. The script will output the user's display name as the `name` variable, if not empty.
|
||||
|
||||
1. Copy the [auth script](lldap-ha-auth.sh) to your home assistant instance. In this example, we use `/config/lldap-ha-auth.sh`.
|
||||
- Set the script as executable by running `chmod +x /config/lldap-ha-auth.sh`
|
||||
2. Add the following to your configuration.yaml in Home assistant:
|
||||
```yaml
|
||||
homeassistant:
|
||||
auth_providers:
|
||||
# Ensure you have the homeassistant provider enabled if you want to continue using your existing accounts
|
||||
- type: homeassistant
|
||||
- type: command_line
|
||||
command: /config/lldap-ha-auth.sh
|
||||
# Only allow users in the 'homeassistant_user' group to login.
|
||||
# Change to ["https://lldap.example.com"] to allow all users
|
||||
args: ["https://lldap.example.com", "homeassistant_user"]
|
||||
meta: true
|
||||
```
|
||||
3. Reload your config or restart Home Assistant
|
||||
BIN
example_configs/images/authelia_openid_config.png
Normal file
|
After Width: | Height: | Size: 152 KiB |
BIN
example_configs/images/nextcloud_apps.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
example_configs/images/nextcloud_groups.png
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
example_configs/images/nextcloud_ldap_srv.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
example_configs/images/nextcloud_login_attributes.png
Normal file
|
After Width: | Height: | Size: 115 KiB |
BIN
example_configs/images/nextcloud_loginfilter.png
Normal file
|
After Width: | Height: | Size: 213 KiB |
BIN
example_configs/images/nextcloud_sharing_options.png
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
example_configs/images/nextcloud_sociallogin_checkboxes.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
example_configs/images/rancher_ldap_config.png
Normal file
|
After Width: | Height: | Size: 148 KiB |
@@ -35,6 +35,12 @@ Otherwise, just use:
|
||||
```
|
||||
(uid=*)
|
||||
```
|
||||
### Admin Base DN
|
||||
|
||||
The DN to search for your admins.
|
||||
```
|
||||
ou=people,dc=example,dc=com
|
||||
```
|
||||
|
||||
### Admin Filter
|
||||
|
||||
@@ -43,8 +49,15 @@ that), use:
|
||||
```
|
||||
(memberof=cn=media_admin,ou=groups,dc=example,dc=com)
|
||||
```
|
||||
Bear in mind that admins must also be a member of the users group if you use one.
|
||||
|
||||
Otherwise, you can use LLDAP's admin group:
|
||||
```
|
||||
(memberof=cn=lldap_admin,ou=groups,dc=example,dc=com)
|
||||
```
|
||||
|
||||
## Password change
|
||||
To allow changing Passwords via Jellyfin the following things are required
|
||||
- The bind user needs to have the group lldap_password_manager (changing passwords of members of the group lldap_admin does not work to prevent privilege escalation)
|
||||
- Check `Allow Password Change`
|
||||
- `LDAP Password Attribute` Needs to be set to `userPassword`
|
||||
|
||||
33
example_configs/kanboard.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# configuration for Kanboard
|
||||
|
||||
add these to the Kanboard `config.php`
|
||||
|
||||
Make sure you adapt the constant `LDAP_SERVER` to the correct LDAP endpoint.
|
||||
|
||||
You also might have to change `dc=example,dc=com` to whatever your LLDAP is handling, and maybe change the `kanboard_users` group name used in `LDAP_USER_FILTER` to identify users of a valid group of yours.
|
||||
|
||||
```
|
||||
define('LDAP_AUTH', true);
|
||||
define('LDAP_SERVER', 'ldap://lldap-server.com:3890');
|
||||
define('LDAP_SSL_VERIFY', true);
|
||||
define('LDAP_START_TLS', false);
|
||||
|
||||
define('LDAP_USERNAME_CASE_SENSITIVE', false);
|
||||
define('LDAP_USER_CREATION', true);
|
||||
|
||||
define('LDAP_BIND_TYPE', 'user');
|
||||
define('LDAP_USERNAME', 'uid=%s,ou=people,dc=example,dc=com');
|
||||
define('LDAP_PASSWORD', null);
|
||||
|
||||
define('LDAP_USER_BASE_DN', 'ou=people,dc=example,dc=com');
|
||||
|
||||
define('LDAP_USER_FILTER', '(&(uid=%s)(memberof=cn=kanboard_users,ou=groups,dc=example,dc=com))');
|
||||
define('LDAP_USERNAME_CASE_SENSITIVE', false);
|
||||
|
||||
define('LDAP_USER_ATTRIBUTE_USERNAME', 'uid');
|
||||
define('LDAP_USER_ATTRIBUTE_FULLNAME', 'cn');
|
||||
define('LDAP_USER_ATTRIBUTE_EMAIL', 'mail');
|
||||
define('LDAP_USER_ATTRIBUTE_GROUPS', 'memberof');
|
||||
define('LDAP_USER_ATTRIBUTE_PHOTO', 'jpegPhoto');
|
||||
define('LDAP_USER_ATTRIBUTE_LANGUAGE', '');
|
||||
```
|
||||
@@ -62,3 +62,11 @@ Once the groups are synchronized, go to "Manage > Groups" on the left. Click on
|
||||
|
||||
Assign the role "admin" to the group. Now you can log in as the LLDAP admin to
|
||||
the KeyCloak admin console.
|
||||
|
||||
## Fixing duplicate names or missing First Names for users
|
||||
|
||||
Since Keycloak and LLDAP use different attributes for different parts of a user's name, you may see duplicated or missing names for users in Keycloak. To fix this, update the attribute mappings:
|
||||
|
||||
Go back to "User Federation", edit your LDAP integration and click on the "Mappers" tab.
|
||||
|
||||
Find or create the "first name" mapper (it should have type `user-attribute-ldap-mapper`) and ensure the "LDAP Attribute" setting is set to `givenname`. Keycloak may have defaulted to `cn` which LLDAP uses for the "Display Name" of a user.
|
||||
|
||||
193
example_configs/librenms.md
Normal file
@@ -0,0 +1,193 @@
|
||||
# Configuration for LibreNMS
|
||||
|
||||
You can either configure LibreNMS from the webui or from the command line. This is a list of the variables that you should set.
|
||||
|
||||
## Essential
|
||||
|
||||
## auth_ldap_uid_attribute
|
||||
|
||||
```
|
||||
uid
|
||||
```
|
||||
|
||||
This sets 'uid' as the unique ldap attribute for users.
|
||||
|
||||
## auth_ldap_groupmemberattr
|
||||
|
||||
```
|
||||
member
|
||||
```
|
||||
|
||||
## auth_ldap_groups
|
||||
|
||||
```'
|
||||
{"nms_admin": {"level": 10}}'
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```
|
||||
auth_ldap_groups.nms_admin.level: 10
|
||||
```
|
||||
|
||||
These are both the same.
|
||||
|
||||
This example sets the group nms_admin as Admin (level 10).
|
||||
Set others to match more groups at different levels.
|
||||
|
||||
## auth_ldap_starttls
|
||||
|
||||
```
|
||||
false
|
||||
```
|
||||
|
||||
## auth_ldap_server
|
||||
|
||||
```
|
||||
[lldap server ip]
|
||||
```
|
||||
|
||||
## auth_ldap_port
|
||||
|
||||
```
|
||||
3890
|
||||
```
|
||||
|
||||
## auth_ldap_suffix
|
||||
|
||||
```
|
||||
,ou=people,dc=example,dc=com
|
||||
```
|
||||
|
||||
Not sure if the case of people actually matters.
|
||||
Make sure you keep the initial comma.
|
||||
|
||||
## auth_ldap_groupbase
|
||||
|
||||
```
|
||||
ou=groups,dc=example,dc=com
|
||||
```
|
||||
|
||||
## auth_mechanism
|
||||
|
||||
```
|
||||
ldap
|
||||
```
|
||||
Be careful with this as you will lock yourself out if ldap does not work correctly. Set back to 'mysql' to turn ldap off.
|
||||
|
||||
### auth_ldap_require_groupmembership
|
||||
|
||||
```
|
||||
false
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Use the test script to make sure it works.
|
||||
```
|
||||
./script/auth_test.php -u <user>
|
||||
```
|
||||
Make sure the level is correctly populated. Should look like this:
|
||||
|
||||
```
|
||||
librenms:/opt/librenms# ./scripts/auth_test.php -uadmin
|
||||
Authentication Method: ldap
|
||||
Password:
|
||||
Authenticate user admin:
|
||||
AUTH SUCCESS
|
||||
|
||||
User (admin):
|
||||
username => admin
|
||||
realname => Administrator
|
||||
user_id => admin
|
||||
email => admin@example.com
|
||||
level => 10
|
||||
Groups: cn=nms_admin,ou=groups,dc=example,dc=com
|
||||
```
|
||||
|
||||
## Setting variables
|
||||
|
||||
### Web UI
|
||||
|
||||
You can set all the varibles in the web UI in: Settings -> Authentication -> LDAP Settings
|
||||
|
||||
### Command line
|
||||
|
||||
You can use the lnms command to *get* config options like this:
|
||||
```
|
||||
lnms config:get auth_ldap_uid_attribute
|
||||
```
|
||||
|
||||
You can use the lnms command to *set* config options like this:
|
||||
```
|
||||
lnms config:set auth_ldap_uid_attribute uid
|
||||
```
|
||||
|
||||
Read more [here](https://docs.librenms.org/Support/Configuration/)
|
||||
|
||||
### Pre load configuration for Docker
|
||||
|
||||
You can create a file named: /data/config/ldap.yaml and place your variables in there.
|
||||
|
||||
```
|
||||
librenms:/opt/librenms# cat /data/config/auth.yaml
|
||||
auth_mechanism: ldap
|
||||
|
||||
auth_ldap_server: 172.17.0.1
|
||||
auth_ldap_port: 3890
|
||||
auth_ldap_version: 3
|
||||
auth_ldap_suffix: ,ou=people,dc=example,dc=com
|
||||
auth_ldap_groupbase: ou=groups,dc=example,dc=com
|
||||
|
||||
auth_ldap_prefix: uid=
|
||||
auth_ldap_starttls: False
|
||||
auth_ldap_attr: {"uid": "uid"}
|
||||
auth_ldap_uid_attribute: uid
|
||||
auth_ldap_groups: {"nms_admin": {"level": 10}}
|
||||
auth_ldap_groupmemberattr: member
|
||||
auth_ldap_require_groupmembership: False
|
||||
auth_ldap_debug: False
|
||||
|
||||
auth_ldap_group: cn=groupname,ou=groups,dc=example,dc=com
|
||||
auth_ldap_groupmembertype: username
|
||||
auth_ldap_timeout: 5
|
||||
auth_ldap_emailattr: mail
|
||||
auth_ldap_userdn: True
|
||||
auth_ldap_userlist_filter:
|
||||
auth_ldap_wildcard_ou: False
|
||||
```
|
||||
|
||||
Read more [here](https://github.com/librenms/docker#configuration-management)
|
||||
|
||||
## Issue with current LibreNMS
|
||||
|
||||
The current version (23.7.0 at the time of writing) does not support lldap. A fix has been accepted to LibreNMS so the next version should just work.
|
||||
|
||||
[Link to the commit](https://github.com/librenms/librenms/commit/a71ca98fac1a75753b102be8b3644c4c3ee1a624)
|
||||
|
||||
If you want to apply the fix manually, run git apply with this patch.
|
||||
|
||||
```
|
||||
diff --git a/LibreNMS/Authentication/LdapAuthorizer.php b/LibreNMS/Authentication/LdapAuthorizer.php
|
||||
index 5459759ab..037a7382b 100644
|
||||
--- a/LibreNMS/Authentication/LdapAuthorizer.php
|
||||
+++ b/LibreNMS/Authentication/LdapAuthorizer.php
|
||||
@@ -233,7 +233,7 @@ class LdapAuthorizer extends AuthorizerBase
|
||||
$entries = ldap_get_entries($connection, $search);
|
||||
foreach ($entries as $entry) {
|
||||
$user = $this->ldapToUser($entry);
|
||||
- if ((int) $user['user_id'] !== (int) $user_id) {
|
||||
+ if ($user['user_id'] != $user_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -360,7 +360,7 @@ class LdapAuthorizer extends AuthorizerBase
|
||||
return [
|
||||
'username' => $entry['uid'][0],
|
||||
'realname' => $entry['cn'][0],
|
||||
- 'user_id' => (int) $entry[$uid_attr][0],
|
||||
+ 'user_id' => $entry[$uid_attr][0],
|
||||
'email' => $entry[Config::get('auth_ldap_emailattr', 'mail')][0],
|
||||
'level' => $this->getUserlevel($entry['uid'][0]),
|
||||
];
|
||||
```
|
||||
70
example_configs/lldap-ha-auth.sh
Normal file
@@ -0,0 +1,70 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Usernames should be validated using a regular expression to be of
|
||||
# a known format. Special characters will be escaped anyway, but it is
|
||||
# generally not recommended to allow more than necessary.
|
||||
# This pattern is set by default. In your config file, you can either
|
||||
# overwrite it with a different one or use "unset USERNAME_PATTERN" to
|
||||
# disable validation completely.
|
||||
USERNAME_PATTERN='^[a-z|A-Z|0-9|_|-|.]+$'
|
||||
|
||||
# When the timeout (in seconds) is exceeded (e.g. due to slow networking),
|
||||
# authentication fails.
|
||||
TIMEOUT=3
|
||||
|
||||
# Log messages to stderr.
|
||||
log() {
|
||||
echo "$1" >&2
|
||||
}
|
||||
|
||||
# Get server address
|
||||
if [ -z "$1" ]; then
|
||||
log "Usage: lldap-auth.sh <LLDAP server address> <Optional group to filter>"
|
||||
exit 2
|
||||
fi
|
||||
SERVER_URL="${1%/}"
|
||||
|
||||
# Check username and password are present and not malformed.
|
||||
if [ -z "$username" ] || [ -z "$password" ]; then
|
||||
log "Need username and password environment variables."
|
||||
exit 2
|
||||
elif [ ! -z "$USERNAME_PATTERN" ]; then
|
||||
username_match=$(echo "$username" | sed -r "s/$USERNAME_PATTERN/x/")
|
||||
if [ "$username_match" != "x" ]; then
|
||||
log "Username '$username' has an invalid format."
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
RESPONSE=$(curl -f -s -X POST -m "$TIMEOUT" -H "Content-type: application/json" -d '{"username":"'"$username"'","password":"'"$password"'"}' "$SERVER_URL/auth/simple/login")
|
||||
if [[ $? -ne 0 ]]; then
|
||||
log "Auth failed"
|
||||
exit 1
|
||||
fi
|
||||
TOKEN=$(jq -e -r .token <<< $RESPONSE)
|
||||
if [[ $? -ne 0 ]]; then
|
||||
log "Failed to parse token"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RESPONSE=$(curl -f -s -m "$TIMEOUT" -H "Content-type: application/json" -H "Authorization: Bearer ${TOKEN}" -d '{"variables":{"id":"'"$username"'"},"query":"query($id:String!){user(userId:$id){displayName groups{displayName}}}"}' "$SERVER_URL/api/graphql")
|
||||
if [[ $? -ne 0 ]]; then
|
||||
log "Failed to get user"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
USER_JSON=$(jq -e .data.user <<< $RESPONSE)
|
||||
if [[ $? -ne 0 ]]; then
|
||||
log "Failed to parse user json"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -z "$2" ]] && ! jq -e '.groups|map(.displayName)|index("'"$2"'")' <<< $USER_JSON > /dev/null 2>&1; then
|
||||
log "User is not in group '$2'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DISPLAY_NAME=$(jq -r .displayName <<< $USER_JSON)
|
||||
|
||||
[[ ! -z "$DISPLAY_NAME" ]] && echo "name = $DISPLAY_NAME"
|
||||
|
||||
22
example_configs/lldap.service
Normal file
@@ -0,0 +1,22 @@
|
||||
[Unit]
|
||||
Description=Nitnelave LLDAP
|
||||
Documentation=https://github.com/nitnelave/lldap
|
||||
|
||||
# Only sqlite
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
# The user/group LLDAP is run under. The working directory (see below) should allow write and read access to this user/group.
|
||||
User=root
|
||||
Group=root
|
||||
|
||||
# The location of the compiled binary
|
||||
ExecStart=/opt/nitnelave/lldap \
|
||||
run
|
||||
|
||||
# Only allow writes to the following directory and set it to the working directory (user and password data are stored here).
|
||||
WorkingDirectory=/opt/nitnelave/
|
||||
ReadWriteDirectories=/opt/nitnelave/
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
28
example_configs/mealie.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Mealie
|
||||
|
||||
Configuration is done solely with environmental variables in the mealie-api docker-compose config:
|
||||
|
||||
## Note
|
||||
[LDAP integration in Mealie currently only works with the nightly branch](https://github.com/hay-kot/mealie/issues/2402#issuecomment-1560176528), so `hkotel/mealie:api-nightly` and `hkotel/mealie:frontend-nightly` rather than the current "stable" release of `v1.0.0beta-5`
|
||||
|
||||
## Configuration
|
||||
|
||||
The following config should let you login with either members of the `mealie` group as a user, or as an admin user with members of the `mealie-admin` group.
|
||||
|
||||
Mealie first checks credentials in the `mealie` group to authenticate, then checks for the presence of the user in the `mealie-admin` group and elevates that account to admin status if present, therefore for any account to be an admin account it must belong in both the `mealie` group and the `mealie-admin` group.
|
||||
|
||||
It is recommended to create a `readonly_user` and add them to the `lldap_strict_readonly` group to bind with.
|
||||
|
||||
```yaml
|
||||
- LDAP_AUTH_ENABLED=true
|
||||
- LDAP_SERVER_URL=ldap://lldap:3890
|
||||
- LDAP_TLS_INSECURE=true ## Only required for LDAPS with a self-signed certificate
|
||||
- LDAP_BASE_DN=ou=people,dc=example,dc=com
|
||||
- LDAP_USER_FILTER=(memberof=cn=mealie,ou=groups,dc=example,dc=com)
|
||||
- LDAP_ADMIN_FILTER=(memberof=cn=mealie-admin,ou=groups,dc=example,dc=com)
|
||||
- LDAP_QUERY_BIND=cn=readonly_user,ou=people,dc=example,dc=com
|
||||
- LDAP_QUERY_PASSWORD=READONLY_USER_PASSWORD
|
||||
- LDAP_ID_ATTRIBUTE=uid
|
||||
- LDAP_NAME_ATTRIBUTE=displayName
|
||||
- LDAP_MAIL_ATTRIBUTE=mail
|
||||
```
|
||||
37
example_configs/minio.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# MinIO Configuration
|
||||
|
||||
MinIO is a High-Performance Object Storage released under GNU Affero General Public License v3. 0. It is API compatible with the Amazon S3 cloud storage service. This example assists with basic LDAP configuration and policy attachment.
|
||||
|
||||
## LDAP Config
|
||||
|
||||
### Navigation
|
||||
|
||||
- Login to the WebUI as a consoleAdmin user
|
||||
- Navigate to `Administrator > Identity > LDAP`
|
||||
- Click `Edit Configuration`
|
||||
|
||||
### Configuration Options
|
||||
|
||||
- Server Insecure: Enabled
|
||||
- Server Address: Hostname or IP for your LLDAP host
|
||||
- Lookup Bind DN: `uid=admin,ou=people,dc=example,dc=com`
|
||||
- It is recommended that you create a separate user account (e.g, `bind_user`) instead of `admin` for sharing Bind credentials with other services. The `bind_user` should be a member of the `lldap_strict_readonly` group to limit access to your LDAP configuration in LLDAP.
|
||||
- Lookup Bind Password: The password for the user referenced above
|
||||
- User DN Search Base: `ou=people,dc=example,dc=com`
|
||||
- User DN Search Filter: `(&(uid=%s)(memberOf=cn=minio_admin,ou=groups,dc=example,dc=com))`
|
||||
- This search filter will only allow users that are members of the `minio_admin` group to authenticate. To allow all lldap users, this filter can be used instead `(uid=%s)`
|
||||
- Group Search Base DN: `ou=groups,dc=example,dc=com`
|
||||
- Group Search Filter: `(member=%d)`
|
||||
|
||||
### Enable LDAP
|
||||
|
||||
> Note there appears to be a bug in some versions of MinIO where LDAP is enabled and working, however the configuration UI reports that it is not enabled.
|
||||
|
||||
Now, you can enable LDAP authentication by clicking the `Enable LDAP` button, a restart of the service or container is needed. With this configuration, LLDAP users will be able to log in to MinIO now. However they will not be able to do anything, as we need to attach policies giving permissions to users.
|
||||
|
||||
## Policy Attachment
|
||||
|
||||
Creating MinIO policies is outside of the scope for this document, but it is well documented by MinIO [here](https://min.io/docs/minio/linux/administration/identity-access-management/policy-based-access-control.html). Policies are written in JSON, are extremely flexible, and can be configured to be very granular. In this example we will be using one of the built-in Policies, `consoleAdmin`. We will be applying these policies with the `mc` command line utility.
|
||||
|
||||
- Alias your MinIO instance: `mc alias set myMinIO http://<your-minio-address>:<your-minio-api-port> admin <your-admin-password>`
|
||||
- Attach a policy to your LDAP group: `mc admin policy attach myMinIO consoleAdmin --group='cn=minio_admin,ou=groups,dc=example,dc=com'`
|
||||
126
example_configs/nextcloud.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# Nextcloud LLDAP example config
|
||||
|
||||
## lldap users & groups
|
||||
|
||||
This example is using following users & groups in lldap :
|
||||
|
||||
* A technical user (ex: `ro_admin`), member of `lldap_strict_readonly` or `lldap_password_manager`
|
||||
* A catch-all group called `nextcloud_users`.
|
||||
* Members of `nextcloud_users` group will be authorized to log in Nextcloud.
|
||||
* Some "application" groups, let's say `friends` and `family`: users in Nextcloud will be able to share files and view people in dynamic lists only to members of their own group(s).
|
||||
* Users in `family` and `friends` should also be users in `nextcloud_users` group!
|
||||
|
||||
If you plan on following this tutorial line-by-line, you will now have the following:
|
||||
* 6 groups:
|
||||
1. `nextcloud_users`
|
||||
2. `family`
|
||||
3. `friends`
|
||||
4. `lldap_strict_readonly`
|
||||
5. `lldap_password_manager`
|
||||
6. `ldap_admin`
|
||||
* 1 admin user in any of the following groups:
|
||||
1. `lldap_password_manager`
|
||||
2. `lldap_strict_readonly`
|
||||
* (Atleast) 1 user in the `nextcloud_users` group
|
||||
* (Optional) Any number of users in the `friends` or `family` group.
|
||||
|
||||
## Nextcloud config : the cli way
|
||||
|
||||
TL;DR let's script it. The "user_ldap" application is shipped with default Nextcloud installation (at least using Docker official stable images), you just have to install & enable it :
|
||||
|
||||
```sh
|
||||
occ app:install user_ldap
|
||||
occ app:enable user_ldap
|
||||
occ ldap:create-empty-config
|
||||
|
||||
# EDIT: domain
|
||||
occ ldap:set-config s01 ldapHost "ldap://lldap.example.net."
|
||||
occ ldap:set-config s01 ldapPort 3890
|
||||
# EDIT: admin user
|
||||
occ ldap:set-config s01 ldapAgentName "uid=ro_admin,ou=people,dc=example,dc=com"
|
||||
# EDIT: password
|
||||
occ ldap:set-config s01 ldapAgentPassword "password"
|
||||
# EDIT: Base DN
|
||||
occ ldap:set-config s01 ldapBase "dc=example,dc=com"
|
||||
occ ldap:set-config s01 ldapBaseUsers "dc=example,dc=com"
|
||||
occ ldap:set-config s01 ldapBaseGroups "dc=example,dc=com"
|
||||
occ ldap:set-config s01 ldapConfigurationActive 1
|
||||
occ ldap:set-config s01 ldapLoginFilter "(&(objectclass=person)(uid=%uid))"
|
||||
# EDIT: nextcloud_users group, contains the users who can login to Nextcloud
|
||||
occ ldap:set-config s01 ldapUserFilter "(&(objectclass=person)(memberOf=cn=nextcloud_users,ou=groups,dc=example,dc=com))"
|
||||
occ ldap:set-config s01 ldapUserFilterMode 0
|
||||
occ ldap:set-config s01 ldapUserFilterObjectclass person
|
||||
occ ldap:set-config s01 turnOnPasswordChange 0
|
||||
occ ldap:set-config s01 ldapCacheTTL 600
|
||||
occ ldap:set-config s01 ldapExperiencedAdmin 0
|
||||
occ ldap:set-config s01 ldapGidNumber gidNumber
|
||||
# EDIT: list of application groups
|
||||
occ ldap:set-config s01 ldapGroupFilter "(&(objectclass=groupOfUniqueNames)(|(cn=friends)(cn=family)))"
|
||||
# EDIT: list of application groups
|
||||
occ ldap:set-config s01 ldapGroupFilterGroups "friends;family"
|
||||
occ ldap:set-config s01 ldapGroupFilterMode 0
|
||||
occ ldap:set-config s01 ldapGroupDisplayName cn
|
||||
occ ldap:set-config s01 ldapGroupFilterObjectclass groupOfUniqueNames
|
||||
occ ldap:set-config s01 ldapGroupMemberAssocAttr uniqueMember
|
||||
occ ldap:set-config s01 ldapEmailAttribute "mail"
|
||||
occ ldap:set-config s01 ldapLoginFilterEmail 0
|
||||
occ ldap:set-config s01 ldapLoginFilterUsername 1
|
||||
occ ldap:set-config s01 ldapMatchingRuleInChainState unknown
|
||||
occ ldap:set-config s01 ldapNestedGroups 0
|
||||
occ ldap:set-config s01 ldapPagingSize 500
|
||||
occ ldap:set-config s01 ldapTLS 0
|
||||
occ ldap:set-config s01 ldapUserAvatarRule default
|
||||
occ ldap:set-config s01 ldapUserDisplayName displayname
|
||||
occ ldap:set-config s01 ldapUserFilterMode 1
|
||||
occ ldap:set-config s01 ldapUuidGroupAttribute auto
|
||||
occ ldap:set-config s01 ldapUuidUserAttribute auto
|
||||
```
|
||||
With a bit of of luck, you should be able to log in your nextcloud instance with LLDAP accounts in the `nextcloud_users` group.
|
||||
|
||||
## Nextcloud config : the GUI way
|
||||
|
||||
1. enable LDAP application (installed but not enabled by default)
|
||||
2. setup your ldap server in Settings > Administration > LDAP / AD integration
|
||||
3. setup Group limitations
|
||||
|
||||
### LDAP server config
|
||||
|
||||
Fill the LLDAP domain and port, DN + password of your technical account and base DN (as usual : change `example.com` by your own domain) :
|
||||
|
||||

|
||||
|
||||
### Users tab
|
||||
|
||||
Select `person` as object class and then choose `Edit LDAP Query` : the `only from these groups` option is not functional.
|
||||
We want only users from the `nextcloud_users` group to be allowed to log in Nextcloud :
|
||||
```
|
||||
(&(objectclass=person)(memberOf=cn=nextcloud_users,ou=groups,dc=example,dc=com))
|
||||
```
|
||||
|
||||

|
||||
|
||||
You can check with `Verify settings and count users` that your filter is working properly (here your accounts `admin` and `ro_admin` will not be counted as users).
|
||||
|
||||
### Login attributes
|
||||
Select `Edit LDAP Query` and enter :
|
||||
```
|
||||
(&(objectclass=person)(uid=%uid))
|
||||
```
|
||||
|
||||

|
||||
|
||||
Enter a valid username in lldap and check if your filter is working.
|
||||
|
||||
### Groups
|
||||
|
||||
You can use the menus for this part : select `groupOfUniqueNames` in the first menu and check every group you want members to be allowed to view their group member / share files with.
|
||||
|
||||

|
||||
|
||||
## Sharing restrictions
|
||||
|
||||
Go to Settings > Administration > Sharing and check following boxes :
|
||||
|
||||
* "Allow username autocompletion to users within the same groups"
|
||||
|
||||

|
||||
90
example_configs/nextcloud_oidc_authelia.md
Normal file
@@ -0,0 +1,90 @@
|
||||
## Assumptions
|
||||
|
||||
If you're here, there are some assumptions being made about access and capabilities you have on your system:
|
||||
1. You have Authelia up and running, understand its functionality, and have read through the documentation.
|
||||
2. You have [LLDAP](https://github.com/nitnelave/lldap) up and running.
|
||||
3. You have Nextcloud and LLDAP communicating and without any config errors. See the [example config for Nextcloud](nextcloud.md)
|
||||
|
||||
## Authelia
|
||||
|
||||
Set up Authelia according to its [documentation](https://www.authelia.com/overview/prologue/introduction/), including the [OpenID Connect](https://www.authelia.com/configuration/identity-providers/open-id-connect/) and [Nextcloud instructions](https://www.authelia.com/integration/openid-connect/nextcloud/).
|
||||
|
||||
## LLDAP
|
||||
|
||||
With LLDAP up and running, add a group and note the name you use. For this tutorial, we're using the group `nextcloud_users`. Create a new user and add it to the `nextcloud_users` group.
|
||||
|
||||
#### Optional:
|
||||
Once setup, add an admin or config user and add to the `lldap_strict_readonly` or `lldap_password_manager` group. This will be the config account used for Nextcloud to read your groups and users from the server.
|
||||
|
||||
## Nextcloud
|
||||
**_When we get to the OpenID section, we will be using the same defaults as Authelia's documentation. As a reminder, they are:_**
|
||||
|
||||
* **Application Root URL:** https://nextcloud.example.com
|
||||
* **Authelia Root URL:** https://auth.example.com
|
||||
* [**Client ID:**](https://www.authelia.com/configuration/identity-providers/open-id-connect/#id) nextcloud
|
||||
* [**Client Secret:**](https://www.authelia.com/configuration/identity-providers/open-id-connect/#secret) nextcloud_client_secret
|
||||
|
||||
Login to your Nextcloud instance as an admin. By now, you should have correctly setup Nextcloud and LLDAP to be communicating and working as expected. [See assumptions, above](#assumptions)
|
||||
|
||||
Next, navigate to the `Apps` section.
|
||||
|
||||

|
||||
|
||||
Search for the Nextcloud app [Social Login](https://apps.nextcloud.com/apps/sociallogin). Enable the app.
|
||||
|
||||
Once enabled, navigate to Settings > Administration > Social Login.
|
||||
|
||||
You'll see many different options for various auth methods, including major 3rd party integrations. For the top section, check off these three options:
|
||||
* Allow Users to Connect Social Logins with their Account
|
||||
* Prevent creating an account if the email address exists in another account
|
||||
* Update user profile every login
|
||||
|
||||

|
||||
|
||||
_You can test out the other options such as preventing users without a group, but I haven't tested all the options. These are just the ones I know that worked so far._
|
||||
|
||||
Scroll down and select **Custom OpenID Connect**. Fill out the following options:
|
||||
|
||||
_The first two can be any string you'd like to identify the connection with. The Title is the string that will show up on the button at the login screen and the Internal Name will be used in the Redirect uri. [See point 3 in the section below](#some-notes)._
|
||||
|
||||
| Field | Value |
|
||||
|--|--|
|
||||
| Internal Name | Authelia |
|
||||
|Title | Authelia OpenID |
|
||||
|Authorize URL | https://auth.example.com/api/oidc/authorization |
|
||||
|Token URL | https://auth.example.com/api/oidc/token |
|
||||
|Display Name Claim (Optional) | display_name |
|
||||
|User info URL (Optional) | |
|
||||
|Logout URL (Optional) | |
|
||||
|Client ID: | nextcloud |
|
||||
|Client Secret: | nextcloud_client_secret |
|
||||
|Scope: | openid profile email groups |
|
||||
|Groups Claim (Optional) | |
|
||||
|Button Style | None |
|
||||
|Default Group | nextcloud_users |
|
||||
|
||||
#### Some Notes
|
||||
* The *scope* should be the same as the scope that was setup in [Authelia's OpenID Integration](https://www.authelia.com/integration/openid-connect/nextcloud/#authelia). Here's an example from Authelia:
|
||||
|
||||

|
||||
|
||||
* *_Do not_* use commas in the Nextcloud Social Login app scope! This caused many issues for me.
|
||||
* Be sure you update your Authelia `configuration.yml`. Specifically, the line: `redirect_uris`. The new URL should be
|
||||
`https://nextcloud.example.com/apps/sociallogin/custom_oidc/Authelia`, in some cases the URL also contains the index.php file and has to look like this `https://nextcloud.example.com/index.php/apps/sociallogin/custom_oidc/Authelia`. Check if your nextcloud has index.php in it's URL because if it has this won't work without and if it hasn't the link with index.php won't work.
|
||||
* The final field in the URL (Authelia) needs to be the same value you used in the Social Login "Internal Name" field.
|
||||
* If you've setup LLDAP correctly in nextcloud, the last dropdown for _Default Group_ should show you the `nextcloud_users` group you setup in LLDAP.
|
||||
|
||||
Once you've filled out the fields correctly, scroll to the bottom and hit save and confirm that you don't recieve any errors from Nextcloud.
|
||||
|
||||
#### Config.php
|
||||
Lastly, we need to add the following line to your `config.php` file on your Nextcloud Server.
|
||||
* `'social_login_auto_redirect' => false,`
|
||||
|
||||
If this is set to *false* your login screen will show the standard User/Email and Password input fields with an additional button underneath that should say: `Login with Authelia OpenID` (the name is coming from the Title field in the Social Login options we setup earlier).
|
||||
|
||||
If this is set to *true* then the user flow will _skip_ the login page and automatically bring you to the Authelia Consent Page at `https://auth.example.com/consent?consent_id=alphanuber-uuid-string`
|
||||
|
||||
### Conclusion
|
||||
And that's it! Assuming all the settings that worked for me, work for you, you should be able to login using OpenID Connect via Authelia. If you find any errors, it's a good idea to keep a document of all your settings from Authelia/Nextcloud/LLDAP etc so that you can easily reference and ensure everything lines up.
|
||||
|
||||
If you have any issues, please create a [discussion](https://github.com/nitnelave/lldap/discussions) or join the [Discord](https://discord.gg/h5PEdRMNyP).
|
||||
56
example_configs/nexus.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Configuration for Sonatype Nexus Repository Manager 3
|
||||
In Nexus log in as an administrator, go to `Server Administration and configuration (gear icon)`
|
||||
|
||||
Select `LDAP` under the `Security` section
|
||||
|
||||
Click `Create connection`
|
||||
|
||||
* Host: A name for the connection e.g. lldap
|
||||
* Type: ldap
|
||||
* Host: Your lldap server's ip/hostname
|
||||
* Port: Your lldap server's port (3890 by default)
|
||||
* Base DN: `dc=example,dc=com`
|
||||
* Authentication Method: Simple Authentication
|
||||
* Username or DN: `uid=admin,ou=people,dc=example,dc=com` or preferably create a read only user in lldap with the lldap_strict_readonly group.
|
||||
* Password: The password for the user specified above
|
||||
|
||||
Click `Verify connection` if successful click `Next`
|
||||
|
||||
* Select a template: Generic ldap server
|
||||
* User Relative DN: `ou=people`
|
||||
* User subtree: Leave unchecked
|
||||
* Object class: person
|
||||
* User Filter: Leave empty to allow all users to log in or `(memberOf=uid=nexus_users,ou=groups,dc=example,dc=com)` for a specific group
|
||||
* Username Attribute: `uid`
|
||||
* Real Name Attribute: `cn`
|
||||
* Email Attribute: `mail`
|
||||
* Password Attribute: Leave blank
|
||||
* Check `Enable User Synchronization`
|
||||
|
||||
Test user login credentials with `Verify login`
|
||||
|
||||
## Set up group mapping as roles
|
||||
|
||||
Check `Map LDAP groups as roles`
|
||||
|
||||
* Group Type: `Static Groups`
|
||||
* Group relative DN: `ou=groups`
|
||||
* Group subtree: Leave unchecked
|
||||
* Group object class: `groupOfUniqueNames`
|
||||
* Group ID attribute: `cn`
|
||||
* Group member attribute: `member`
|
||||
* Group member format: `uid=${username},ou=people,dc=example,dc=com`
|
||||
|
||||
Check user mapping with `Verify user mapping`
|
||||
|
||||
## Map specific roles to groups
|
||||
In Nexus log in as an administrator, go to `Server Administration and configuration (gear icon)`
|
||||
Select `Roles` under the `Security` section
|
||||
|
||||
Click `Create Role`
|
||||
|
||||
* Role ID: e.g. nexus_admin (name in nexus)
|
||||
* Role Name: e.g. nexus_admin (group in lldap)
|
||||
* Add privileges/roles as needed e.g. under Roles add nx-admin to the "contained" list
|
||||
|
||||
Click `Save`
|
||||
113
example_configs/opnsense.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# Configuration for OPNsense
|
||||
|
||||
## Create a LDAP Server
|
||||
|
||||
- Login to OPNsense
|
||||
- Navigate to: `System > Access > Servers`
|
||||
- Create a new server by clicking on the `+` icon
|
||||
|
||||
## Server Config
|
||||
|
||||
- Descriptive Name: `A Descriptive Name`
|
||||
- Type: `LDAP`
|
||||
- Hostname or IP address: `Hostname or IP for your LLDAP host`
|
||||
- Port value: `Your LLDAP port`
|
||||
- Default: `3890`
|
||||
- Transport: `TCP - Standard`
|
||||
- Protocol version: `3`
|
||||
|
||||
Make sure the host running LLDAP is accessible to OPNsense and that you mapped the LLDAP port to the LLDAP host.
|
||||
|
||||
## LDAP Config
|
||||
|
||||
### Bind credentials
|
||||
|
||||
#### User DN
|
||||
|
||||
```
|
||||
uid=admin,ou=people,dc=example,dc=com
|
||||
```
|
||||
|
||||
It is recommended that you create a separate user account (e.g, `bind_user`) instead of `admin` for sharing Bind credentials with other services. The `bind_user` should be a member of the `lldap_strict_readonly` group to limit access to your LDAP configuration in LLDAP.
|
||||
|
||||
#### Password
|
||||
|
||||
```
|
||||
xxx
|
||||
```
|
||||
|
||||
Enter the password that you set for the user specified in the User DN field.
|
||||
|
||||
### Search Scope
|
||||
|
||||
```
|
||||
One Level
|
||||
```
|
||||
|
||||
### Base DN
|
||||
|
||||
```
|
||||
dc=example,dc=com
|
||||
```
|
||||
|
||||
This is the same LDAP Base DN that you set via the *LLDAP_LDAP_BASE_DN* environment variable or in `lldap_config.toml`.
|
||||
|
||||
### Authentication containers
|
||||
|
||||
```
|
||||
ou=people,dc=example,dc=com
|
||||
```
|
||||
|
||||
Note: The `Select` box may not work for selecting containers. You can just enter the `Authentication containers` directly into the text field.
|
||||
|
||||
### Extended Query
|
||||
|
||||
```
|
||||
&(objectClass=person)(memberof=cn=lldap_admin,ou=groups,dc=example,dc=com)
|
||||
```
|
||||
|
||||
It is recommended that you create a unique LDAP group (e.g., `lldap_opnsense`) in LLDAP and use that group in this query instead of `lldap_admin`. This will limit OPNsense access to users in the `lldap_opnsense` group and make it easier to synchronize LLDAP groups with OPNsense groups for managing OPNsense access.
|
||||
|
||||
### Initial Template
|
||||
|
||||
```
|
||||
OpenLDAP
|
||||
```
|
||||
|
||||
### User naming attribute
|
||||
|
||||
```
|
||||
uid
|
||||
```
|
||||
|
||||
## Optional Configuration
|
||||
|
||||
The above configuration will connect OPNsense to LLDAP. This optional configuration will synchronize groups between LLDAP and OPNsense and automate user creation when an authorized LLDAP user logs into OPNsense.
|
||||
|
||||
### Remaining Server Configuration
|
||||
|
||||
Enable the following options on the OPNsense configuration page for your LLDAP server (the same page where you entered the prior configuration):
|
||||
|
||||
- Read Properties: `Checked`
|
||||
- Synchronize groups: `Checked`
|
||||
- Automatic user creation: `Checked`
|
||||
|
||||
### Create OPNsense Group
|
||||
|
||||
Go to `System > Access > Groups` and create a new group with the **same** name as the LLDAP group used to authenticate users for OPNsense.
|
||||
|
||||
By default, you would name your OPNsense group `lldap_admin` unless you followed the recommended advice in this guide and created a separate `lldap_opnsense` group for managing OPNsense users.
|
||||
|
||||
If you want your LLDAP users to have full administrator access in OPNsense, then you need to edit the `Assigned Privileges` for the group and add the `GUI - All pages` system privilege.
|
||||
|
||||
### Enable LLDAP as an Authentication Option
|
||||
|
||||
Go to `System > Settings > Administration` page and scroll down to the `Authentication` section. Add your LLDAP server configuration to the `Server` field.
|
||||
|
||||
## Testing LLDAP
|
||||
|
||||
OPNsense includes a built-in feature for testing user authentication at `System > Access > Tester`. Select your LLDAP server configuration in the `Authentication Server` to test logins for your LLDAP users.
|
||||
|
||||
## More Information
|
||||
|
||||
Please read the [OPNsense docs](https://docs.opnsense.org/manual/how-tos/user-ldap.html) for more information on LDAP configuration and managing access to OPNsense.
|
||||
117
example_configs/pfsense.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# Configuration for pfSense
|
||||
|
||||
## Create a LDAP Server
|
||||
|
||||
- Login to pfSense
|
||||
- Navigate to: `System > User Manager > Authentication Servers`
|
||||
- Create a new server by clicking on the `+ Add` button
|
||||
|
||||
## LDAP Server Settings
|
||||
|
||||
- Descriptive Name: `A Descriptive Name`
|
||||
- Type: `LDAP`
|
||||
- Hostname or IP address: `Hostname or IP for your LLDAP host`
|
||||
- Port value: `Your LLDAP port`
|
||||
- Transport: `TCP - Standard`
|
||||
- Protocol version: `3`
|
||||
- Server Timeout: `25`
|
||||
|
||||
(Make sure the host running LLDAP is accessible to pfSense and that you mapped the LLDAP port to the LLDAP host)
|
||||
### Search Scope
|
||||
```
|
||||
Entire Subtree
|
||||
```
|
||||
### Base DN
|
||||
|
||||
```
|
||||
dc=example,dc=com
|
||||
```
|
||||
|
||||
This is the same LDAP Base DN that you set via the *LLDAP_LDAP_BASE_DN* environment variable or in `lldap_config.toml`.
|
||||
### Authentication containers
|
||||
|
||||
```
|
||||
ou=people
|
||||
```
|
||||
|
||||
Note: The `Select a container` box may not work for selecting containers. You can just enter the `Authentication containers` directly into the text field.
|
||||
|
||||
### Extended Query
|
||||
|
||||
Enable extended query: `Checked`
|
||||
|
||||
### Query:
|
||||
|
||||
```
|
||||
&(objectClass=person)(|(memberof=cn=pfsense_admin,ou=groups,dc=example,dc=com)(memberof=cn=pfsense_guest,ou=groups,dc=example,dc=com))
|
||||
```
|
||||
|
||||
This example gives you two groups in LLDAP, one for pfSense admin access (`pfsense_admin`) and one for guest access (`pfsense_guest`). You **must** create these exact same groups in both LLDAP and pfSense, then give them the correct permissions in pfSense.
|
||||
|
||||
### Bind Anonymous
|
||||
`Unchecked`
|
||||
|
||||
### Bind credentials
|
||||
|
||||
#### User DN
|
||||
|
||||
```
|
||||
uid=yourbinduser,ou=people,dc=example,dc=com
|
||||
```
|
||||
|
||||
It is recommended that you create a separate read-only user account (e.g, `readonly`) instead of `admin` for sharing Bind credentials with other services. The `readonly` should be a member of the `lldap_strict_readonly` group to limit access to your LDAP configuration in LLDAP.
|
||||
|
||||
#### Password
|
||||
|
||||
```
|
||||
LLDAPPasswordForBindUser
|
||||
```
|
||||
|
||||
### User naming attribute
|
||||
```
|
||||
uid
|
||||
```
|
||||
### Group naming attribute
|
||||
```
|
||||
cn
|
||||
```
|
||||
### Group member attribute
|
||||
```
|
||||
memberof
|
||||
```
|
||||
### RFC 2307 Groups
|
||||
`Unchecked`
|
||||
|
||||
### Group Object Class
|
||||
`groupOfUniqueNames`
|
||||
|
||||
### Shell Authentication Group DN
|
||||
`cn=pfsense_admin,ou=groups,dc=example,dc=com`
|
||||
|
||||
(This is only if you want to give a group shell access through LDAP. Leave blank and only the pfSense admin user will have shell access.
|
||||
|
||||
### Remaining Server Configuration
|
||||
|
||||
Enable the following options on the pfSense configuration page for your LLDAP server (the same page where you entered the prior configuration):
|
||||
|
||||
- UTF8 Encodes: `Checked`
|
||||
- Username Alterations: `Unchecked`
|
||||
- Allow unauthenticated bind: `Unchecked`
|
||||
|
||||
### Create pfSense Groups
|
||||
|
||||
Go to `System > User Manager > Groups` and create a new group(s) with the **same exact** name as the LLDAP group(s) used to authenticate users for pfSense (`pfsense_admin` and `pfsense_guest` in this example).
|
||||
|
||||
If you want your LLDAP users to have full administrator access in pfSense, then you need to edit the `Assigned Privileges` for the group and add the `WebCfg - All pages` system privilege. If you do not give any permissions to a group, you will be able to log in but only see an empty webUI.
|
||||
|
||||
### Enable LLDAP as an Authentication Option
|
||||
|
||||
Go to `System > User Manager > Settings` page. Add your LLDAP server configuration to the `Authentication Server` field. **The "Save & Test" Button will fail the test results at step 3. No clue why.**
|
||||
|
||||
## Testing LLDAP
|
||||
|
||||
pfSense includes a built-in feature for testing user authentication at `Diagnostics > Authentication`. Select your LLDAP server configuration in the `Authentication Server` to test logins for your LLDAP users. The groups (only the ones you added to pfSense) should show up when tested.
|
||||
|
||||
## More Information
|
||||
|
||||
Please read the [pfSense docs](https://docs.netgate.com/pfsense/en/latest/usermanager/ldap.html) for more information on LDAP configuration and managing access to pfSense.
|
||||