Compare commits
287 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
91d12a7e97 | ||
|
|
e31c7351ea | ||
|
|
cf19fd41b0 | ||
|
|
500a441df7 | ||
|
|
6701027002 | ||
|
|
fab884711f | ||
|
|
1a37e1ee04 | ||
|
|
786f571e86 | ||
|
|
33cd850e65 | ||
|
|
8c3a168c7f | ||
|
|
722fc2de57 | ||
|
|
c6ffaa2abf | ||
|
|
c4a63610c0 | ||
|
|
5bf533272e | ||
|
|
22fcc5303f | ||
|
|
8101ddc85f | ||
|
|
49f4e48aae | ||
|
|
4092b2e5b1 | ||
|
|
b387ceb1c4 | ||
|
|
85d59e79ca | ||
|
|
c5017bbd42 | ||
|
|
c72c1fdf2c | ||
|
|
cbde363fde | ||
|
|
ea82b1a644 | ||
|
|
429952c46f | ||
|
|
0dad470602 | ||
|
|
2f1bf87102 | ||
|
|
1a03346a38 | ||
|
|
23a4763914 | ||
|
|
82f6292927 | ||
|
|
e39e141d6c | ||
|
|
a512b1844a | ||
|
|
5e2eea0d97 | ||
|
|
bafb1dc5cc | ||
|
|
45bbe23b3b | ||
|
|
85ee097a3b | ||
|
|
04afc9d8d9 | ||
|
|
b03a38f267 | ||
|
|
8f446bd932 | ||
|
|
1ae7987b88 | ||
|
|
936a6d696a | ||
|
|
fc7ec97051 | ||
|
|
a67128338d | ||
|
|
e757638506 | ||
|
|
a673a6aa45 | ||
|
|
9b91362730 | ||
|
|
733d363e25 | ||
|
|
da186fab38 | ||
|
|
1f632a8069 | ||
|
|
ff698df280 | ||
|
|
1efab58d0c | ||
|
|
a0b0b455ed | ||
|
|
1d8582f937 | ||
|
|
7e62cc6eda | ||
|
|
55bcced476 | ||
|
|
b7957f598b | ||
|
|
5150d8341f | ||
|
|
e5c80b9f17 | ||
|
|
875c59758b | ||
|
|
b54fe9128d | ||
|
|
ebffc1c086 | ||
|
|
5c1db3cf4a | ||
|
|
e173f34edb | ||
|
|
05c60979d7 | ||
|
|
d6c2805847 | ||
|
|
89ae7c200c | ||
|
|
f689458aa2 | ||
|
|
6b6f11db1b | ||
|
|
f1b86a16ee | ||
|
|
4f89b73fe5 | ||
|
|
c7d68af691 | ||
|
|
4537d1ae2b | ||
|
|
90611aefef | ||
|
|
bd90a3a426 | ||
|
|
e1e1d6cd20 | ||
|
|
16a544b5a0 | ||
|
|
73ac5a65d4 | ||
|
|
5420dcf2b8 | ||
|
|
cb84f7f387 | ||
|
|
c7f45b12ac | ||
|
|
f52197e76f | ||
|
|
3ac38bb96f | ||
|
|
2197fe77a5 | ||
|
|
8d7881171b | ||
|
|
f2570cdd3c | ||
|
|
be452f4649 | ||
|
|
3a6c5fdc65 | ||
|
|
0ccedc6717 | ||
|
|
b6dd1ed512 | ||
|
|
a8e5549b3f | ||
|
|
ae9b3678df | ||
|
|
2221686dc6 | ||
|
|
203bc9a8a2 | ||
|
|
ca19e61f50 | ||
|
|
26cedcb621 | ||
|
|
6228c0f87c | ||
|
|
82df8d4ca1 | ||
|
|
c850fa4273 | ||
|
|
a1fe703bf0 | ||
|
|
d20bd196bc | ||
|
|
747e37592d | ||
|
|
f6c43b691a | ||
|
|
8e8614fe2e | ||
|
|
204232659d | ||
|
|
6c9086cc78 | ||
|
|
110b7c7d5b | ||
|
|
ef0a0ffced | ||
|
|
31cf9b8e2c | ||
|
|
aa83f6cab6 | ||
|
|
b38023c48e | ||
|
|
496fbf72ea | ||
|
|
86c052f98b | ||
|
|
610ada972a | ||
|
|
b664524366 | ||
|
|
182449da03 | ||
|
|
82770a5ff0 | ||
|
|
e11a8460ff | ||
|
|
c761f08995 | ||
|
|
c564de2c92 | ||
|
|
7731b8e593 | ||
|
|
4c05058eb2 | ||
|
|
45c50923b7 | ||
|
|
f730e6a580 | ||
|
|
06a12f5351 | ||
|
|
bf20c448dc | ||
|
|
9f138ec4ac | ||
|
|
ddeb4c3ce3 | ||
|
|
9d623e59c1 | ||
|
|
e44625bc6a | ||
|
|
68013c8919 | ||
|
|
842afac7dd | ||
|
|
2bbfacf755 | ||
|
|
f152a78cb6 | ||
|
|
6191fb226a | ||
|
|
9653d64eb1 | ||
|
|
5b5395103a | ||
|
|
a1e50defbe | ||
|
|
656451435e | ||
|
|
859ed97ca8 | ||
|
|
df889ee2fe | ||
|
|
faee271705 | ||
|
|
ba7848d043 | ||
|
|
45f5bb51d9 | ||
|
|
c0869b4324 | ||
|
|
edf9e538ce | ||
|
|
4a5abfd395 | ||
|
|
9fb4afcf60 | ||
|
|
789c8f367e | ||
|
|
db2b5cbae0 | ||
|
|
a13bfc3575 | ||
|
|
7b5ad47ee2 | ||
|
|
e1503743b5 | ||
|
|
88732556c1 | ||
|
|
35d0cc0fb0 | ||
|
|
6456149e50 | ||
|
|
f1bda21cad | ||
|
|
7b081fce61 | ||
|
|
618e7e3585 | ||
|
|
790fd7c5d1 | ||
|
|
4551e27b55 | ||
|
|
ad1ee52d76 | ||
|
|
9124339b96 | ||
|
|
617a0f53fa | ||
|
|
2a90443ed8 | ||
|
|
1d54ca8040 | ||
|
|
77ced7ea43 | ||
|
|
fa0105fa96 | ||
|
|
18e3892e55 | ||
|
|
350fdcdf9b | ||
|
|
adf088c74b | ||
|
|
c055c4e671 | ||
|
|
98a305e877 | ||
|
|
47ee56873e | ||
|
|
ee863f74fc | ||
|
|
24e3125e34 | ||
|
|
06b6653dff | ||
|
|
62745970c6 | ||
|
|
ea3142da5d | ||
|
|
656edc3763 | ||
|
|
d96b534921 | ||
|
|
9a024cd7fc | ||
|
|
c964428858 | ||
|
|
f98023e67f | ||
|
|
e68d46d4fe | ||
|
|
9a680a7d06 | ||
|
|
7345cc42d0 | ||
|
|
d60f5ab460 | ||
|
|
12dfa60eed | ||
|
|
158e4100ef | ||
|
|
87ebee672f | ||
|
|
ec6e1b0c09 | ||
|
|
640126f39a | ||
|
|
d31ca426f7 | ||
|
|
d4ac9fa703 | ||
|
|
5523d38838 | ||
|
|
587d724c2c | ||
|
|
29f3636064 | ||
|
|
ec69d30b1c | ||
|
|
232a41d053 | ||
|
|
540ac5d241 | ||
|
|
29962881cf | ||
|
|
65dd1d1fd3 | ||
|
|
ba72e622c2 | ||
|
|
5a5baf883f | ||
|
|
6c09af6479 | ||
|
|
ba1a5f6011 | ||
|
|
adc3d656cd | ||
|
|
b9f6b915ac | ||
|
|
43ffeca24d | ||
|
|
31e1ff358b | ||
|
|
026a2f7eb0 | ||
|
|
63f4bf95d2 | ||
|
|
d423c64d57 | ||
|
|
438ac2818a | ||
|
|
9874449d66 | ||
|
|
88ff3e7783 | ||
|
|
107c8ec96e | ||
|
|
5a00b7d8bb | ||
|
|
21e507a9d7 | ||
|
|
1859f5ddf0 | ||
|
|
de15ebba6a | ||
|
|
aa8bbf96f8 |
@@ -1,11 +1,10 @@
|
||||
# Don't track git
|
||||
.git/*
|
||||
.github/*
|
||||
.gitignore
|
||||
|
||||
# Don't track cargo generated files
|
||||
target/*
|
||||
server/target/*
|
||||
app/target/*
|
||||
auth/target/*
|
||||
|
||||
# Don't track the generated JS
|
||||
app/pkg/*
|
||||
@@ -14,9 +13,27 @@ app/pkg/*
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
|
||||
# Don't track docs
|
||||
*.md
|
||||
LICENSE
|
||||
CHANGELOG.md
|
||||
docs/*
|
||||
example_configs/*
|
||||
|
||||
# Output of `npm install rollup`
|
||||
node_modules/*
|
||||
package-lock.json
|
||||
package.json
|
||||
|
||||
# Pre-build binaries
|
||||
*.tar.gz
|
||||
|
||||
# Various config files that shouldn't be tracked
|
||||
.env
|
||||
lldap_config.toml
|
||||
server_key
|
||||
users.db*
|
||||
.gitignore
|
||||
screenshot.png
|
||||
recipe.json
|
||||
cert.pem
|
||||
key.pem
|
||||
|
||||
12
.github/codecov.yml
vendored
Normal file
12
.github/codecov.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
codecov:
|
||||
require_ci_to_pass: yes
|
||||
comment:
|
||||
layout: "diff,flags"
|
||||
require_changes: true
|
||||
require_base: true
|
||||
require_head: true
|
||||
ignore:
|
||||
- "app"
|
||||
- "docs"
|
||||
- "example_configs"
|
||||
- "migration-tool"
|
||||
10
.github/dependabot.yml
vendored
Normal file
10
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# Set update schedule for GitHub Actions
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
# Check for updates to GitHub Actions every weekday
|
||||
interval: "daily"
|
||||
106
.github/workflows/Dockerfile.ci.alpine
vendored
Normal file
106
.github/workflows/Dockerfile.ci.alpine
vendored
Normal file
@@ -0,0 +1,106 @@
|
||||
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/
|
||||
COPY web/index_local.html web/index.html
|
||||
RUN cp target/lldap /lldap/ && \
|
||||
cp target/migration-tool /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 && \
|
||||
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=$CONTAINERUSER:$CONTAINERUSER /lldap /app
|
||||
COPY --from=lldap --chown=$CONTAINERUSER:$CONTAINERUSER /docker-entrypoint.sh /docker-entrypoint.sh
|
||||
VOLUME ["/data"]
|
||||
WORKDIR /app
|
||||
ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"]
|
||||
CMD ["run", "--config-file", "/data/lldap_config.toml"]
|
||||
71
.github/workflows/Dockerfile.ci.debian
vendored
Normal file
71
.github/workflows/Dockerfile.ci.debian
vendored
Normal file
@@ -0,0 +1,71 @@
|
||||
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/
|
||||
COPY web/index_local.html web/index.html
|
||||
RUN cp target/lldap /lldap/ && \
|
||||
cp target/migration-tool /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 && \
|
||||
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"]
|
||||
34
.github/workflows/Dockerfile.dev
vendored
Normal file
34
.github/workflows/Dockerfile.dev
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
FROM rust:1.62-slim-bullseye
|
||||
|
||||
# Set needed env path
|
||||
ENV PATH="/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"
|
||||
|
||||
### Install build deps x86_64
|
||||
RUN apt update && \
|
||||
apt install -y --no-install-recommends curl git wget build-essential make perl pkg-config curl tar jq musl-tools && \
|
||||
curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - && \
|
||||
apt update && \
|
||||
apt install -y --no-install-recommends nodejs && \
|
||||
apt clean && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
npm install -g npm && \
|
||||
npm install -g yarn && \
|
||||
npm install -g pnpm
|
||||
|
||||
### Install build deps aarch64 build
|
||||
RUN dpkg --add-architecture arm64 && \
|
||||
apt update && \
|
||||
apt install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libc6-arm64-cross libc6-dev-arm64-cross && \
|
||||
apt clean && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
rustup target add aarch64-unknown-linux-gnu
|
||||
|
||||
### Add musl-gcc aarch64 and x86_64
|
||||
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 && \
|
||||
rm ./x86_64-linux-musl-cross.tgz && \
|
||||
rm ./aarch64-linux-musl-cross.tgz
|
||||
|
||||
CMD ["bash"]
|
||||
413
.github/workflows/docker-build-static.yml
vendored
Normal file
413
.github/workflows/docker-build-static.yml
vendored
Normal file
@@ -0,0 +1,413 @@
|
||||
name: Docker Static
|
||||
|
||||
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
|
||||
|
||||
# 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
|
||||
# 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
|
||||
## cargo
|
||||
## target
|
||||
|
||||
jobs:
|
||||
build-ui:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: rust:1.62
|
||||
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 ca-certificates
|
||||
- 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
|
||||
- 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: Checkout repository
|
||||
uses: actions/checkout@v3.1.0
|
||||
- name: install rollup nodejs
|
||||
run: npm install -g rollup
|
||||
- name: install wasm-pack with cargo
|
||||
run: cargo install wasm-pack || true
|
||||
env:
|
||||
RUSTFLAGS: ""
|
||||
- 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.62
|
||||
env:
|
||||
CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER: arm-linux-gnueabihf-gcc
|
||||
CARGO_TARGET_ARMV7_UNKNOWN_LINUX_MUSLEABIHF_LINKER: arm-linux-gnueabihf-ld
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: -Ctarget-feature=-crt-static
|
||||
CARGO_HOME: ${GITHUB_WORKSPACE}/.cargo
|
||||
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 tar ca-certificates
|
||||
- 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: Checkout repository
|
||||
uses: actions/checkout@v3.1.0
|
||||
- uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
.cargo/bin
|
||||
.cargo/registry/index
|
||||
.cargo/registry/cache
|
||||
.cargo/git/db
|
||||
target
|
||||
key: lldap-bin-armhf-${{ hashFiles('**/Cargo.lock') }}
|
||||
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:
|
||||
##################################################################################
|
||||
# Github actions currently timeout when downloading musl-gcc #
|
||||
# Using lldap dev image based on rust:1.62-slim-bullseye and musl-gcc bundled #
|
||||
# Only for Job build aarch64 and amd64 #
|
||||
###################################################################################
|
||||
#image: rust:1.62
|
||||
image: nitnelave/rust-dev:latest
|
||||
env:
|
||||
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER: aarch64-linux-musl-gcc
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: -Ctarget-feature=+crt-static
|
||||
CARGO_HOME: ${GITHUB_WORKSPACE}/.cargo
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3.1.0
|
||||
- name: smoke test
|
||||
run: rustc --version
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3.1.0
|
||||
- uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
.cargo/bin
|
||||
.cargo/registry/index
|
||||
.cargo/registry/cache
|
||||
.cargo/git/db
|
||||
target
|
||||
key: lldap-bin-aarch64-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
lldap-bin-aarch64-
|
||||
# - name: fetch musl-gcc
|
||||
# run: |
|
||||
# wget -c https://musl.cc/aarch64-linux-musl-cross.tgz
|
||||
# tar zxf ./x86_64-linux-musl-cross.tgz -C /opt
|
||||
# echo "/opt/aarch64-linux-musl-cross:/opt/aarch64-linux-musl-cross/bin" >> $GITHUB_PATH
|
||||
- name: add musl aarch64 target
|
||||
run: rustup target add aarch64-unknown-linux-musl
|
||||
- name: build lldap aarch4
|
||||
run: cargo build --target=aarch64-unknown-linux-musl --release -p lldap -p migration-tool
|
||||
- name: check path
|
||||
run: ls -al target/aarch64-unknown-linux-musl/release/
|
||||
- name: upload aarch64 lldap artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: aarch64-lldap-bin
|
||||
path: target/aarch64-unknown-linux-musl/release/lldap
|
||||
- name: upload aarch64 migration-tool artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: aarch64-migration-tool-bin
|
||||
path: target/aarch64-unknown-linux-musl/release/migration-tool
|
||||
|
||||
build-amd64:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
# image: rust:1.62
|
||||
image: nitnelave/rust-dev:latest
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: -Ctarget-feature=+crt-static
|
||||
CARGO_HOME: ${GITHUB_WORKSPACE}/.cargo
|
||||
CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER: x86_64-linux-musl-gcc
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3.1.0
|
||||
- uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
.cargo/bin
|
||||
.cargo/registry/index
|
||||
.cargo/registry/cache
|
||||
.cargo/git/db
|
||||
target
|
||||
key: lldap-bin-amd64-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
lldap-bin-amd64-
|
||||
- name: install musl
|
||||
run: apt update && apt install -y musl-tools tar wget
|
||||
# - name: fetch musl-gcc
|
||||
# run: |
|
||||
# wget -c https://musl.cc/x86_64-linux-musl-cross.tgz
|
||||
# tar zxf ./x86_64-linux-musl-cross.tgz -C /opt
|
||||
# echo "/opt/x86_64-linux-musl-cross:/opt/x86_64-linux-musl-cross/bin" >> $GITHUB_PATH
|
||||
- name: add x86_64 target
|
||||
run: rustup target add x86_64-unknown-linux-musl
|
||||
- name: build x86_64 lldap
|
||||
run: cargo build --target=x86_64-unknown-linux-musl --release -p lldap -p migration-tool
|
||||
- name: check path
|
||||
run: ls -al target/x86_64-unknown-linux-musl/release/
|
||||
- name: upload amd64 lldap artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: amd64-lldap-bin
|
||||
path: target/x86_64-unknown-linux-musl/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-musl/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: install rsync
|
||||
run: sudo apt update && sudo apt install -y rsync
|
||||
- name: fetch repo
|
||||
uses: actions/checkout@v3.1.0
|
||||
|
||||
- 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 }}
|
||||
|
||||
######################
|
||||
#### latest build ####
|
||||
######################
|
||||
- name: Build and push latest alpine
|
||||
if: github.event_name != 'release'
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
file: ./.github/workflows/Dockerfile.ci.alpine
|
||||
tags: nitnelave/lldap:latest, nitnelave/lldap:latest-alpine
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache-new
|
||||
|
||||
- name: Build and push latest debian
|
||||
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.debian
|
||||
tags: nitnelave/lldap:latest-debian
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache-new
|
||||
|
||||
#######################
|
||||
#### release build ####
|
||||
#######################
|
||||
- name: Build and push release alpine
|
||||
if: github.event_name == 'release'
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
# Tag as latest, stable, semver, major, major.minor and major.minor.patch.
|
||||
file: ./.github/workflows/Dockerfile.ci.alpine
|
||||
tags: nitnelave/lldap:stable, nitnelave/lldap:stable-alpine, 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 }}, nitnelave/lldap:v${{ steps.slug.outputs.version-semantic }}-alpine, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}-alpine, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}-alpine.${{ steps.slug.outputs.version-minor }}-alpine, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}.${{ steps.slug.outputs.version-patch }}-alpine
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache-new
|
||||
|
||||
- name: Build and push release debian
|
||||
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.debian
|
||||
tags: nitnelave/lldap:stable-debian, nitnelave/lldap:v${{ steps.slug.outputs.version-semantic }}-debian, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}-debian, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}-debian, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}.${{ steps.slug.outputs.version-patch }}-debian
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache-new
|
||||
|
||||
- name: Move cache
|
||||
run: rsync -r /tmp/.buildx-cache-new /tmp/.buildx-cache --delete
|
||||
|
||||
- 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
|
||||
|
||||
71
.github/workflows/rust.yml
vendored
71
.github/workflows/rust.yml
vendored
@@ -10,13 +10,32 @@ env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
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:
|
||||
- id: skip_check
|
||||
uses: fkirc/skip-duplicate-actions@master
|
||||
with:
|
||||
concurrent_skipping: 'outdated_runs'
|
||||
skip_after_successful_duplicate: 'true'
|
||||
paths_ignore: '["**/*.md", "**/docs/**", "example_configs/**", "*.sh"]'
|
||||
do_not_skip: '["workflow_dispatch", "schedule"]'
|
||||
cancel_others: true
|
||||
|
||||
test:
|
||||
name: cargo test
|
||||
needs: pre_job
|
||||
if: ${{ needs.pre_job.outputs.should_skip != 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3.1.0
|
||||
- uses: Swatinem/rust-cache@v1
|
||||
- name: Build
|
||||
run: cargo build --verbose --workspace
|
||||
- name: Run tests
|
||||
@@ -29,18 +48,14 @@ jobs:
|
||||
|
||||
clippy:
|
||||
name: cargo clippy
|
||||
needs: pre_job
|
||||
if: ${{ needs.pre_job.outputs.should_skip != 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3.1.0
|
||||
|
||||
- name: Install nightly toolchain
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
profile: minimal
|
||||
toolchain: nightly
|
||||
override: true
|
||||
components: rustfmt, clippy
|
||||
- uses: Swatinem/rust-cache@v1
|
||||
|
||||
- name: Run cargo clippy
|
||||
uses: actions-rs/cargo@v1
|
||||
@@ -50,21 +65,43 @@ jobs:
|
||||
|
||||
format:
|
||||
name: cargo fmt
|
||||
needs: pre_job
|
||||
if: ${{ needs.pre_job.outputs.should_skip != 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3.1.0
|
||||
|
||||
- name: Install nightly toolchain
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
profile: minimal
|
||||
toolchain: nightly
|
||||
override: true
|
||||
components: rustfmt, clippy
|
||||
- uses: Swatinem/rust-cache@v1
|
||||
|
||||
- name: Run cargo fmt
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: fmt
|
||||
args: --all -- --check
|
||||
|
||||
coverage:
|
||||
name: Code coverage
|
||||
needs: pre_job
|
||||
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.1.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
|
||||
|
||||
- name: Generate code coverage for unit test
|
||||
run: cargo llvm-cov --workspace --no-report
|
||||
- name: Aggregate reports
|
||||
run: cargo llvm-cov --no-run --lcov --output-path lcov.info
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
files: lcov.info
|
||||
fail_ci_if_error: true
|
||||
|
||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -1,10 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target
|
||||
/serve/target/
|
||||
/app/target
|
||||
/app/pkg
|
||||
/auth/target
|
||||
|
||||
# These are backup files generated by rustfmt
|
||||
**/*.rs.bk
|
||||
@@ -21,3 +18,13 @@ package.json
|
||||
|
||||
# Server private key
|
||||
server_key
|
||||
|
||||
# Pre-build binaries
|
||||
*.tar.gz
|
||||
|
||||
# Misc
|
||||
.env
|
||||
recipe.json
|
||||
lldap_config.toml
|
||||
cert.pem
|
||||
key.pem
|
||||
|
||||
90
CHANGELOG.md
Normal file
90
CHANGELOG.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# Changelog
|
||||
|
||||
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.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
|
||||
|
||||
### Breaking
|
||||
|
||||
The `lldap_readonly` group has been renamed `lldap_password_manager` (migration happens automatically) and a new `lldap_strict_readonly` group was introduced.
|
||||
|
||||
### Added
|
||||
- A new `lldap_strict_readonly` group allows granting readonly rights to users (not able to change other's passwords, in particular).
|
||||
|
||||
### Changed
|
||||
- The `lldap_readonly` group is renamed `lldap_password_manager` since it still allows users to change (non-admin) passwords.
|
||||
|
||||
### Removed
|
||||
- The `lldap_readonly` group was removed.
|
||||
|
||||
## [0.3.0] - 2022-07-08
|
||||
|
||||
### Breaking
|
||||
As part of the update, the database will do a one-time automatic migration to
|
||||
add UUIDs and group creation times.
|
||||
|
||||
### Added
|
||||
- Added support and documentation for many services:
|
||||
- Apache Guacamole
|
||||
- Bookstack
|
||||
- Calibre
|
||||
- Dolibarr
|
||||
- Emby
|
||||
- Gitea
|
||||
- Grafana
|
||||
- Jellyfin
|
||||
- Matrix Synapse
|
||||
- NextCloud
|
||||
- Organizr
|
||||
- Portainer
|
||||
- Seafile
|
||||
- Syncthing
|
||||
- WG Portal
|
||||
- New migration tool from OpenLDAP.
|
||||
- New docker images for alternate architectures (arm64, arm/v7).
|
||||
- Added support for LDAPS.
|
||||
- New readonly group.
|
||||
- Added UUID attribute for users and groups.
|
||||
- Frontend now uses the refresh tokens to reduce the number of logins needed.
|
||||
|
||||
### Changed
|
||||
- Much improved logging format.
|
||||
- Simplified API login.
|
||||
- Allowed non-admins to run search queries on the content they can see.
|
||||
- "cn" attribute now returns the Full Name, not Username.
|
||||
- Unknown attributes now warn instead of erroring.
|
||||
- Introduced a list of attributes to silence those warnings.
|
||||
|
||||
### Deprecated
|
||||
- Deprecated "cn" as LDAP username, "uid" is the correct attribute.
|
||||
|
||||
### Fixed
|
||||
- Usernames, objectclass and attribute names are now case insensitive.
|
||||
- Handle "1.1" and other wildcard LDAP attributes.
|
||||
- Handle "memberOf" attribute.
|
||||
- Handle fully-specified scope.
|
||||
|
||||
### Security
|
||||
- Prevent SQL injections due to interaction between two libraries.
|
||||
|
||||
## [0.2.0] - 2021-11-27
|
||||
2288
Cargo.lock
generated
2288
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
15
Cargo.toml
15
Cargo.toml
@@ -2,14 +2,13 @@
|
||||
members = [
|
||||
"server",
|
||||
"auth",
|
||||
"app"
|
||||
"app",
|
||||
"migration-tool"
|
||||
]
|
||||
|
||||
# TODO: remove when there's a new release.
|
||||
[patch.crates-io.yew_form]
|
||||
git = 'https://github.com/sassman/yew_form/'
|
||||
rev = '67050812695b7a8a90b81b0637e347fc6629daed'
|
||||
default-members = ["server"]
|
||||
|
||||
[patch.crates-io.yew_form_derive]
|
||||
git = 'https://github.com/sassman/yew_form/'
|
||||
rev = '67050812695b7a8a90b81b0637e347fc6629daed'
|
||||
# Remove once https://github.com/kanidm/ldap3_proto/pull/8 is merged.
|
||||
[patch.crates-io.ldap3_proto]
|
||||
git = 'https://github.com/nitnelave/ldap3_server/'
|
||||
rev = '7b50b2b82c383f5f70e02e11072bb916629ed2bc'
|
||||
|
||||
61
Dockerfile
61
Dockerfile
@@ -1,5 +1,5 @@
|
||||
# Build image
|
||||
FROM rust:alpine AS chef
|
||||
FROM rust:alpine3.14 AS chef
|
||||
|
||||
RUN set -x \
|
||||
# Add user
|
||||
@@ -9,12 +9,13 @@ RUN set -x \
|
||||
--ingroup app \
|
||||
--home /app \
|
||||
--uid 10001 \
|
||||
app
|
||||
RUN set -x \
|
||||
app \
|
||||
# Install required packages
|
||||
&& apk add npm openssl-dev musl-dev make perl
|
||||
&& apk add npm openssl-dev musl-dev make perl curl
|
||||
|
||||
USER app
|
||||
WORKDIR /app
|
||||
|
||||
RUN set -x \
|
||||
# Install build tools
|
||||
&& RUSTFLAGS=-Ctarget-feature=-crt-static cargo install wasm-pack cargo-chef \
|
||||
@@ -24,44 +25,42 @@ RUN set -x \
|
||||
# Prepare the dependency list.
|
||||
FROM chef AS planner
|
||||
COPY . .
|
||||
RUN cargo chef prepare --recipe-path recipe.json
|
||||
RUN cargo chef prepare --recipe-path /tmp/recipe.json
|
||||
|
||||
# Build dependencies
|
||||
# Build dependencies.
|
||||
FROM chef AS builder
|
||||
COPY --from=planner /app/recipe.json recipe.json
|
||||
RUN cargo chef cook --release -p lldap --recipe-path recipe.json \
|
||||
&& cargo chef cook --release -p lldap_app --target wasm32-unknown-unknown
|
||||
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
|
||||
|
||||
# Copy the source and build the app.
|
||||
# Copy the source and build the app and server.
|
||||
COPY --chown=app:app . .
|
||||
RUN cargo build --release -p lldap
|
||||
# TODO: release mode.
|
||||
RUN ./app/build.sh
|
||||
RUN cargo build --release -p lldap -p migration-tool \
|
||||
# Build the frontend.
|
||||
&& ./app/build.sh
|
||||
|
||||
# Final image
|
||||
FROM alpine
|
||||
FROM alpine:3.14
|
||||
|
||||
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 docker-entrypoint.sh lldap_config.docker_template.toml ./
|
||||
|
||||
RUN set -x \
|
||||
# Add user
|
||||
&& addgroup --gid 10001 app \
|
||||
&& adduser --disabled-password \
|
||||
--gecos '' \
|
||||
--ingroup app \
|
||||
--home /app \
|
||||
--uid 10001 \
|
||||
app
|
||||
|
||||
RUN mkdir /data && chown app:app /data
|
||||
USER app
|
||||
WORKDIR /app
|
||||
COPY --chown=app:app --from=builder /app/app/index.html app/index.html
|
||||
COPY --chown=app:app --from=builder /app/app/main.js app/main.js
|
||||
COPY --chown=app:app --from=builder /app/app/pkg app/pkg
|
||||
COPY --chown=app:app --from=builder /app/target/release/lldap lldap
|
||||
&& apk add --no-cache bash \
|
||||
&& 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 .
|
||||
|
||||
ENV LDAP_PORT=3890
|
||||
ENV HTTP_PORT=17170
|
||||
|
||||
EXPOSE ${LDAP_PORT} ${HTTP_PORT}
|
||||
|
||||
CMD ["/app/lldap", "run", "--config-file", "/data/lldap_config.toml"]
|
||||
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
||||
CMD ["run", "--config-file", "/data/lldap_config.toml"]
|
||||
|
||||
344
README.md
344
README.md
@@ -1,25 +1,70 @@
|
||||
# lldap - Light LDAP implementation for authentication
|
||||
<h1 align="center">lldap - Light LDAP implementation for authentication</h1>
|
||||
|
||||

|
||||

|
||||

|
||||
<p align="center">
|
||||
<i style="font-size:24px">LDAP made easy.</i>
|
||||
</p>
|
||||
|
||||
WARNING: This project is still in alpha, with the basic core functionality
|
||||
implemented but still very rough. For updates, follow
|
||||
[@nitnelave1](https://twitter.com/nitnelave1) or join our [Discord
|
||||
server](https://discord.gg/h5PEdRMNyP)!
|
||||
<p align="center">
|
||||
<a href="https://github.com/nitnelave/lldap/actions/workflows/rust.yml?query=branch%3Amain">
|
||||
<img
|
||||
src="https://github.com/nitnelave/lldap/actions/workflows/rust.yml/badge.svg"
|
||||
alt="Build"/>
|
||||
</a>
|
||||
<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"
|
||||
alt="Twitter Follow"/>
|
||||
</a>
|
||||
<a href="https://github.com/rust-secure-code/safety-dance/">
|
||||
<img
|
||||
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>
|
||||
</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 client 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)
|
||||
|
||||
This project is an lightweight authentication server that provides an
|
||||
opinionated, simplified LDAP interface for authentication: clients that can
|
||||
only speak LDAP protocol can talk to it and use it as an authentication server.
|
||||
## About
|
||||
|
||||

|
||||
This project is a lightweight authentication server that provides an
|
||||
opinionated, simplified LDAP interface for authentication. It integrates with
|
||||
many backends, from KeyCloak to Authelia to Nextcloud and
|
||||
[more](#compatible-services)!
|
||||
|
||||
<img
|
||||
src="https://raw.githubusercontent.com/nitnelave/lldap/master/screenshot.png"
|
||||
alt="Screenshot of the user list page"
|
||||
width="50%"
|
||||
align="right"
|
||||
/>
|
||||
|
||||
It comes with a frontend that makes user management easy, and allows users to
|
||||
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)
|
||||
* 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.
|
||||
|
||||
@@ -31,28 +76,42 @@ 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.
|
||||
|
||||
## Setup
|
||||
## Installation
|
||||
|
||||
### With Docker
|
||||
|
||||
The image is available at `nitnelave/lldap`. You should persist the `/data`
|
||||
folder, which contains your configuration, the database and the private key
|
||||
file (unless you move them in the config).
|
||||
file.
|
||||
|
||||
Configure the server by copying the `lldap_config.docker_template.toml` to
|
||||
`/data/lldap_config.toml` and updating the configuration values (especially the
|
||||
`jwt_secret` and `ldap_user_pass`, unless you override them with env variables).
|
||||
Environment variables should be prefixed with `LLDAP_` to override the
|
||||
configuration.
|
||||
|
||||
Example for docker compose:
|
||||
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
|
||||
`_FILE` variables take precedence.
|
||||
|
||||
Example for docker compose for `:stable` tag:
|
||||
* When defined with `user: ##:##` , ensure `/data` directory had permission for the defined user, else `1000:1000` used.
|
||||
|
||||
```yaml
|
||||
version: '3'
|
||||
|
||||
volumes:
|
||||
lldap_data:
|
||||
driver: local
|
||||
|
||||
services:
|
||||
lldap:
|
||||
image: nitnelave/lldap
|
||||
image: nitnelave/lldap:stable
|
||||
# Change this to the user:group you want.
|
||||
user: "33:33"
|
||||
ports:
|
||||
# For LDAP
|
||||
- "3890:3890"
|
||||
@@ -60,10 +119,43 @@ services:
|
||||
- "17170:17170"
|
||||
volumes:
|
||||
- "lldap_data:/data"
|
||||
# Alternatively, you can mount a local folder
|
||||
# - "./lldap_data:/data"
|
||||
environment:
|
||||
- JWT_SECRET=REPLACE_WITH_RANDOM
|
||||
- LDAP_USER_PASS=REPLACE_WITH_PASSWORD
|
||||
- LDAP_BASE_DN=dc=example,dc=com
|
||||
- LLDAP_JWT_SECRET=REPLACE_WITH_RANDOM
|
||||
- LLDAP_LDAP_USER_PASS=REPLACE_WITH_PASSWORD
|
||||
- LLDAP_LDAP_BASE_DN=dc=example,dc=com
|
||||
```
|
||||
|
||||
Example for docker compose for `:latest` tag:
|
||||
* `:latest` tag image contain recent pushed codes or feature test, breaks is expected.
|
||||
* If `UID` and `GID` no defined LLDAP will use default `UID` and `GID` number `1000`
|
||||
|
||||
```yaml
|
||||
version: '3'
|
||||
|
||||
volumes:
|
||||
lldap_data:
|
||||
driver: local
|
||||
|
||||
services:
|
||||
lldap:
|
||||
image: nitnelave/lldap:latest
|
||||
ports:
|
||||
# For LDAP
|
||||
- "3890:3890"
|
||||
# For the web front-end
|
||||
- "17170:17170"
|
||||
volumes:
|
||||
- "lldap_data:/data"
|
||||
# Alternatively, you can mount a local folder
|
||||
# - "./lldap_data:/data"
|
||||
environment:
|
||||
- UID=####
|
||||
- GID=####
|
||||
- LLDAP_JWT_SECRET=REPLACE_WITH_RANDOM
|
||||
- LLDAP_LDAP_USER_PASS=REPLACE_WITH_PASSWORD
|
||||
- LLDAP_LDAP_BASE_DN=dc=example,dc=com
|
||||
```
|
||||
|
||||
Then the service will listen on two ports, one for LDAP and one for the web
|
||||
@@ -71,6 +163,20 @@ front-end.
|
||||
|
||||
### From source
|
||||
|
||||
To compile the project, you'll need:
|
||||
|
||||
* npm, curl: `sudo apt install curl npm`
|
||||
* 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 migration-tool
|
||||
```
|
||||
|
||||
The resulting binaries will be in `./target/release/`. Alternatively, you can
|
||||
just run `cargo run -- run` to run the server.
|
||||
|
||||
To bring up the server, you'll need to compile the frontend. In addition to
|
||||
cargo, you'll need:
|
||||
|
||||
@@ -80,16 +186,51 @@ cargo, you'll need:
|
||||
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).
|
||||
|
||||
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`.
|
||||
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
|
||||
|
||||
Docker images are provided for AMD64, ARM64 and ARM/V7.
|
||||
|
||||
If you want to cross-compile yourself, you can do so by installing
|
||||
[`cross`](https://github.com/rust-embedded/cross):
|
||||
|
||||
```sh
|
||||
cargo install cross
|
||||
cross build --target=armv7-unknown-linux-musleabihf -p lldap --release
|
||||
./app/build.sh
|
||||
```
|
||||
|
||||
(Replace `armv7-unknown-linux-musleabihf` with the correct Rust target for your
|
||||
device.)
|
||||
|
||||
You can then get the compiled server binary in
|
||||
`target/armv7-unknown-linux-musleabihf/release/lldap` and the various needed files
|
||||
(`index.html`, `main.js`, `pkg` folder) in the `app` folder. Copy them to the
|
||||
Raspberry Pi (or other target), with the folder structure maintained (`app`
|
||||
files in an `app` folder next to the binary).
|
||||
|
||||
## Client configuration
|
||||
|
||||
### Compatible services
|
||||
|
||||
Most services that can use LDAP as an authentication provider should work out
|
||||
of the box. For new services, it's possible that they require a bit of tweaking
|
||||
on LLDAP's side to make things work. In that case, just create an issue with
|
||||
the relevant details (logs of the service, LLDAP logs with `verbose=true` in
|
||||
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,dc=example,dc=com`.
|
||||
`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
|
||||
@@ -101,7 +242,69 @@ Testing group membership through `memberOf` is supported, so you can have a
|
||||
filter like: `(memberOf=cn=admins,ou=groups,dc=example,dc=com)`.
|
||||
|
||||
The administrator group for LLDAP is `lldap_admin`: anyone in this group has
|
||||
admin rights in the Web UI.
|
||||
admin rights in the Web UI. Most LDAP integrations should instead use a user in
|
||||
the `lldap_strict_readonly` or `lldap_password_manager` group, to avoid granting full
|
||||
administration access to many services.
|
||||
|
||||
### Sample client configurations
|
||||
|
||||
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:
|
||||
- [Airsonic Advanced](example_configs/airsonic-advanced.md)
|
||||
- [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)
|
||||
- [Dokuwiki](example_configs/dokuwiki.md)
|
||||
- [Dolibarr](example_configs/dolibarr.md)
|
||||
- [Emby](example_configs/emby.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)
|
||||
- [Matrix](example_configs/matrix_synapse.yml)
|
||||
- [Nextcloud](example_configs/nextcloud.md)
|
||||
- [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)
|
||||
- [XBackBone](example_configs/xbackbone_config.php)
|
||||
|
||||
## 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 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.
|
||||
|
||||
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 web UI.
|
||||
|
||||
### 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.
|
||||
|
||||
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 (<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.
|
||||
|
||||
## I can't log in!
|
||||
|
||||
@@ -120,91 +323,8 @@ set isn't working, try the following:
|
||||
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.
|
||||
|
||||
## Architecture
|
||||
|
||||
The server is entirely written in Rust, using [actix](https://actix.rs) for the
|
||||
backend and [yew](https://yew.rs) for the frontend.
|
||||
|
||||
Backend:
|
||||
* Listens on a port for LDAP protocol.
|
||||
* Only a small, read-only subset of the LDAP protocol is supported.
|
||||
* An extension to allow resetting the password through LDAP will be added.
|
||||
* Listens on another port for HTTP traffic.
|
||||
* The authentication API, based on JWTs, is under "/auth".
|
||||
* The user management API is a GraphQL API under "/api/graphql". The schema
|
||||
is defined in `schema.graphql`.
|
||||
* The static frontend files are served by this port too.
|
||||
|
||||
Note that secure protocols (LDAPS, HTTPS) are currently not supported. This can
|
||||
be worked around by using a reverse proxy in front of the server (for the HTTP
|
||||
API) that wraps/unwraps the HTTPS messages, or only open the service to
|
||||
localhost or other trusted docker containers (for the LDAP API).
|
||||
|
||||
Frontend:
|
||||
* User management UI.
|
||||
* Written in Rust compiled to WASM as an SPA with the Yew library.
|
||||
* Based on components, with a React-like organization.
|
||||
|
||||
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).
|
||||
|
||||
### Code organization
|
||||
|
||||
* `auth/`: Contains the shared structures needed for authentication, the
|
||||
interface between front and back-end. In particular, it contains the OPAQUE
|
||||
structures and the JWT format.
|
||||
* `app/`: The frontend.
|
||||
* `src/components`: The elements containing the business and display logic of
|
||||
the various pages and their components.
|
||||
* `src/infra`: Various tools and utilities.
|
||||
* `server/`: The backend.
|
||||
* `src/domain/`: Domain-specific logic: users, groups, checking passwords...
|
||||
* `src/infra/`: API, both GraphQL and LDAP
|
||||
|
||||
## Authentication
|
||||
|
||||
### Passwords
|
||||
|
||||
Passwords are hashed using Argon2, the state of the art in terms of password
|
||||
storage. They are hashed using a secret provided in the configuration (which
|
||||
can be given as environment variable or command line argument as well): this
|
||||
should be kept secret and shouldn't change (it would invalidate all passwords).
|
||||
|
||||
Authentication is done via the OPAQUE protocol, meaning that the passwords are
|
||||
never sent to the server, but instead the client proves that they know the
|
||||
correct password (zero-knowledge proof). This is likely overkill, especially
|
||||
considered that the LDAP interface requires sending the password to the server,
|
||||
but it's one less potential flaw (especially since the LDAP interface can be
|
||||
restricted to an internal docker-only network while the web app is exposed to
|
||||
the Internet).
|
||||
|
||||
### JWTs and refresh tokens
|
||||
|
||||
When logging in for the first time, users are provided with a refresh token
|
||||
that gets stored in an HTTP-only cookie, valid for 30 days. They can use this
|
||||
token to get a JWT to get access to various servers: the JWT lists the groups
|
||||
the user belongs to. To simplify the setup, there is a single JWT secret that
|
||||
should be shared between the authentication server and the application servers;
|
||||
and users don't get a different token per application server
|
||||
(this could be implemented, we just didn't have any use case yet).
|
||||
|
||||
JWTs are only valid for one day: when they expire, a new JWT can be obtained
|
||||
from the authentication server using the refresh token. If the user stays
|
||||
logged in, they would only have to type their password once a month.
|
||||
|
||||
#### Logout
|
||||
|
||||
In order to handle logout correctly, we rely on a blacklist of JWTs. When a
|
||||
user logs out, their refresh token is removed from the backend, and all of
|
||||
their currently valid JWTs are added to a blacklist. Incoming requests are
|
||||
checked against this blacklist (in-memory, faster than calling the database).
|
||||
Applications that want to use these JWTs should subscribe to be notified of
|
||||
blacklisted JWTs (TODO: implement the PubSub service and API).
|
||||
- If it's still not working, join the
|
||||
[Discord server](https://discord.gg/h5PEdRMNyP) to ask for help.
|
||||
|
||||
## Contributions
|
||||
|
||||
|
||||
@@ -1,31 +1,34 @@
|
||||
[package]
|
||||
name = "lldap_app"
|
||||
version = "0.1.0"
|
||||
authors = ["Valentin Tolmer <valentin@tolmer.fr>", "Steve Barrau <steve.barrau@gmail.com>", "Thomas Wickham <mackwic@gmail.com>"]
|
||||
edition = "2018"
|
||||
version = "0.4.1"
|
||||
authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
base64 = "0.13"
|
||||
graphql_client = "0.10"
|
||||
http = "0.2"
|
||||
jwt = "0.13"
|
||||
rand = "0.8"
|
||||
serde = "1"
|
||||
serde_json = "1"
|
||||
validator = "*"
|
||||
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 = "*"
|
||||
|
||||
# Needed because of https://github.com/tkaitchuck/aHash/issues/95
|
||||
indexmap = "=1.6.2"
|
||||
|
||||
[dependencies.web-sys]
|
||||
version = "0.3"
|
||||
features = [
|
||||
"Document",
|
||||
"Element",
|
||||
"FileReader",
|
||||
"HtmlDocument",
|
||||
"HtmlInputElement",
|
||||
"HtmlOptionElement",
|
||||
@@ -44,5 +47,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 = "67050812695b7a8a90b81b0637e347fc6629daed"
|
||||
|
||||
[dependencies.yew_form_derive]
|
||||
git = "https://github.com/jfbilodeau/yew_form"
|
||||
rev = "67050812695b7a8a90b81b0637e347fc6629daed"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
@@ -24,4 +24,4 @@ then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
$ROLLUP_BIN ./main.js --format iife --file ./pkg/bundle.js
|
||||
$ROLLUP_BIN ./main.js --format iife --file ./pkg/bundle.js --globals bootstrap:bootstrap
|
||||
|
||||
@@ -18,12 +18,19 @@
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.5.0/font/bootstrap-icons.css"
|
||||
as="style" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
|
||||
integrity="sha384-tKLJeE1ALTUwtXlaGjJYM3sejfssWdAaWR2s97axw4xkiAdMzQjtOjgcyw0Y50KU"
|
||||
crossorigin="anonymous" as="style" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css"
|
||||
crossorigin="anonymous" integrity="sha384-wvfXpqpZZVQGK6TAh5PVlGOfQNHSoD2xbE+QkPxCAFlNEevoEH3Sl0sibVcOQVnN" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Bebas+Neue&display=swap" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="/static/style.css" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&display=swap" rel="stylesheet">
|
||||
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
37
app/index_local.html
Normal file
37
app/index_local.html
Normal file
@@ -0,0 +1,37 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>LLDAP Administration</title>
|
||||
<script src="/pkg/bundle.js" defer></script>
|
||||
<link
|
||||
href="/static/bootstrap.min.css"
|
||||
rel="preload stylesheet"
|
||||
integrity="sha384-+0n0xVW2eSR5OomGNYDnhzAbDsOXxcvSN1TPprVMTNDbiYZCxYbOOl7+AMvyTG2x"
|
||||
as="style" />
|
||||
<script
|
||||
src="/static/bootstrap.bundle.min.js"
|
||||
integrity="sha384-/bQdsTh/da6pkI1MST/rWKFNjaCP5gBSY4sEBT38Q/9RBh9AH40zEOg7Hlq2THRZ"></script>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="/static/bootstrap-icons.css"
|
||||
integrity="sha384-tKLJeE1ALTUwtXlaGjJYM3sejfssWdAaWR2s97axw4xkiAdMzQjtOjgcyw0Y50KU"
|
||||
as="style" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
integrity="sha384-wvfXpqpZZVQGK6TAh5PVlGOfQNHSoD2xbE+QkPxCAFlNEevoEH3Sl0sibVcOQVnN"
|
||||
href="/static/font-awesome.min.css" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="/static/fonts.css" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="/static/style.css" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -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
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
use crate::{
|
||||
components::select::{Select, SelectOption, SelectOptionProps},
|
||||
infra::api::HostService,
|
||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||
};
|
||||
use anyhow::{Error, Result};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use std::collections::HashSet;
|
||||
use yew::{
|
||||
prelude::*,
|
||||
services::{fetch::FetchTask, ConsoleService},
|
||||
};
|
||||
use yewtil::NeqAssign;
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
@@ -33,14 +29,11 @@ pub struct ListUserNames;
|
||||
pub type User = list_user_names::ListUserNamesUsers;
|
||||
|
||||
pub struct AddGroupMemberComponent {
|
||||
link: ComponentLink<Self>,
|
||||
props: Props,
|
||||
common: CommonComponentParts<Self>,
|
||||
/// The list of existing users, initially not loaded.
|
||||
user_list: Option<Vec<User>>,
|
||||
/// The currently selected user.
|
||||
selected_user: Option<User>,
|
||||
// Used to keep the request alive long enough.
|
||||
task: Option<FetchTask>,
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
@@ -58,58 +51,24 @@ pub struct Props {
|
||||
pub on_error: Callback<Error>,
|
||||
}
|
||||
|
||||
impl AddGroupMemberComponent {
|
||||
fn get_user_list(&mut self) {
|
||||
self.task = HostService::graphql_query::<ListUserNames>(
|
||||
list_user_names::Variables { filters: None },
|
||||
self.link.callback(Msg::UserListResponse),
|
||||
"Error trying to fetch user list",
|
||||
)
|
||||
.map_err(|e| {
|
||||
ConsoleService::log(&e.to_string());
|
||||
e
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
fn submit_add_member(&mut self) -> Result<bool> {
|
||||
let user_id = match self.selected_user.clone() {
|
||||
None => return Ok(false),
|
||||
Some(user) => user.id,
|
||||
};
|
||||
self.task = HostService::graphql_query::<AddUserToGroup>(
|
||||
add_user_to_group::Variables {
|
||||
user: user_id,
|
||||
group: self.props.group_id,
|
||||
},
|
||||
self.link.callback(Msg::AddMemberResponse),
|
||||
"Error trying to initiate adding the user to a group",
|
||||
)
|
||||
.map_err(|e| {
|
||||
ConsoleService::log(&e.to_string());
|
||||
e
|
||||
})
|
||||
.ok();
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
impl CommonComponent<AddGroupMemberComponent> for AddGroupMemberComponent {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::UserListResponse(response) => {
|
||||
self.user_list = Some(response?.users);
|
||||
self.task = None;
|
||||
self.common.cancel_task();
|
||||
}
|
||||
Msg::SubmitAddMember => return self.submit_add_member(),
|
||||
Msg::AddMemberResponse(response) => {
|
||||
response?;
|
||||
self.task = None;
|
||||
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.props.on_user_added_to_group.emit(user);
|
||||
self.common.on_user_added_to_group.emit(user);
|
||||
}
|
||||
Msg::SelectionChanged(option_props) => {
|
||||
let was_some = self.selected_user.is_some();
|
||||
@@ -123,8 +82,38 @@ impl AddGroupMemberComponent {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl AddGroupMemberComponent {
|
||||
fn get_user_list(&mut self) {
|
||||
self.common.call_graphql::<ListUserNames, _>(
|
||||
list_user_names::Variables { filters: None },
|
||||
Msg::UserListResponse,
|
||||
"Error trying to fetch user list",
|
||||
);
|
||||
}
|
||||
|
||||
fn submit_add_member(&mut self) -> Result<bool> {
|
||||
let user_id = match self.selected_user.clone() {
|
||||
None => return Ok(false),
|
||||
Some(user) => user.id,
|
||||
};
|
||||
self.common.call_graphql::<AddUserToGroup, _>(
|
||||
add_user_to_group::Variables {
|
||||
user: user_id,
|
||||
group: self.common.group_id,
|
||||
},
|
||||
Msg::AddMemberResponse,
|
||||
"Error trying to initiate adding the user to a group",
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn get_selectable_user_list(&self, user_list: &[User]) -> Vec<User> {
|
||||
let user_groups = self.props.users.iter().collect::<HashSet<_>>();
|
||||
let user_groups = self.common.users.iter().collect::<HashSet<_>>();
|
||||
user_list
|
||||
.iter()
|
||||
.filter(|u| !user_groups.contains(u))
|
||||
@@ -136,32 +125,27 @@ impl AddGroupMemberComponent {
|
||||
impl Component for AddGroupMemberComponent {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
let mut res = Self {
|
||||
link,
|
||||
props,
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
user_list: None,
|
||||
selected_user: None,
|
||||
task: None,
|
||||
};
|
||||
res.get_user_list();
|
||||
res
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
match self.handle_msg(msg) {
|
||||
Err(e) => {
|
||||
ConsoleService::error(&e.to_string());
|
||||
self.props.on_error.emit(e);
|
||||
self.task = None;
|
||||
true
|
||||
}
|
||||
Ok(b) => b,
|
||||
}
|
||||
CommonComponentParts::<Self>::update_and_report_error(
|
||||
self,
|
||||
msg,
|
||||
self.common.on_error.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.props.neq_assign(props)
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
@@ -176,7 +160,7 @@ impl Component for AddGroupMemberComponent {
|
||||
html! {
|
||||
<div class="row">
|
||||
<div class="col-sm-3">
|
||||
<Select on_selection_change=self.link.callback(Msg::SelectionChanged)>
|
||||
<Select on_selection_change=self.common.callback(Msg::SelectionChanged)>
|
||||
{
|
||||
to_add_user_list
|
||||
.into_iter()
|
||||
@@ -188,8 +172,8 @@ impl Component for AddGroupMemberComponent {
|
||||
<div class="col-sm-1">
|
||||
<button
|
||||
class="btn btn-success"
|
||||
disabled=self.selected_user.is_none() || self.task.is_some()
|
||||
onclick=self.link.callback(|_| Msg::SubmitAddMember)>
|
||||
disabled=self.selected_user.is_none() || self.common.is_task_running()
|
||||
onclick=self.common.callback(|_| Msg::SubmitAddMember)>
|
||||
{"Add"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -3,16 +3,12 @@ use crate::{
|
||||
select::{Select, SelectOption, SelectOptionProps},
|
||||
user_details::Group,
|
||||
},
|
||||
infra::api::HostService,
|
||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||
};
|
||||
use anyhow::{Error, Result};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use std::collections::HashSet;
|
||||
use yew::{
|
||||
prelude::*,
|
||||
services::{fetch::FetchTask, ConsoleService},
|
||||
};
|
||||
use yewtil::NeqAssign;
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
@@ -45,14 +41,11 @@ impl From<GroupListGroup> for Group {
|
||||
}
|
||||
|
||||
pub struct AddUserToGroupComponent {
|
||||
link: ComponentLink<Self>,
|
||||
props: Props,
|
||||
common: CommonComponentParts<Self>,
|
||||
/// The list of existing groups, initially not loaded.
|
||||
group_list: Option<Vec<Group>>,
|
||||
/// The currently selected group.
|
||||
selected_group: Option<Group>,
|
||||
// Used to keep the request alive long enough.
|
||||
task: Option<FetchTask>,
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
@@ -70,51 +63,17 @@ pub struct Props {
|
||||
pub on_error: Callback<Error>,
|
||||
}
|
||||
|
||||
impl AddUserToGroupComponent {
|
||||
fn get_group_list(&mut self) {
|
||||
self.task = HostService::graphql_query::<GetGroupList>(
|
||||
get_group_list::Variables,
|
||||
self.link.callback(Msg::GroupListResponse),
|
||||
"Error trying to fetch group list",
|
||||
)
|
||||
.map_err(|e| {
|
||||
ConsoleService::log(&e.to_string());
|
||||
e
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
fn submit_add_group(&mut self) -> Result<bool> {
|
||||
let group_id = match &self.selected_group {
|
||||
None => return Ok(false),
|
||||
Some(group) => group.id,
|
||||
};
|
||||
self.task = HostService::graphql_query::<AddUserToGroup>(
|
||||
add_user_to_group::Variables {
|
||||
user: self.props.username.clone(),
|
||||
group: group_id,
|
||||
},
|
||||
self.link.callback(Msg::AddGroupResponse),
|
||||
"Error trying to initiate adding the user to a group",
|
||||
)
|
||||
.map_err(|e| {
|
||||
ConsoleService::log(&e.to_string());
|
||||
e
|
||||
})
|
||||
.ok();
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
impl CommonComponent<AddUserToGroupComponent> for AddUserToGroupComponent {
|
||||
fn handle_msg(&mut 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.task = None;
|
||||
self.common.cancel_task();
|
||||
}
|
||||
Msg::SubmitAddGroup => return self.submit_add_group(),
|
||||
Msg::AddGroupResponse(response) => {
|
||||
response?;
|
||||
self.task = None;
|
||||
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
|
||||
@@ -123,7 +82,7 @@ impl AddUserToGroupComponent {
|
||||
.expect("Could not get selected group")
|
||||
.clone();
|
||||
// Remove the group from the dropdown.
|
||||
self.props.on_user_added_to_group.emit(group);
|
||||
self.common.on_user_added_to_group.emit(group);
|
||||
}
|
||||
Msg::SelectionChanged(option_props) => {
|
||||
let was_some = self.selected_group.is_some();
|
||||
@@ -137,8 +96,38 @@ impl AddUserToGroupComponent {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl AddUserToGroupComponent {
|
||||
fn get_group_list(&mut self) {
|
||||
self.common.call_graphql::<GetGroupList, _>(
|
||||
get_group_list::Variables,
|
||||
Msg::GroupListResponse,
|
||||
"Error trying to fetch group list",
|
||||
);
|
||||
}
|
||||
|
||||
fn submit_add_group(&mut self) -> Result<bool> {
|
||||
let group_id = match &self.selected_group {
|
||||
None => return Ok(false),
|
||||
Some(group) => group.id,
|
||||
};
|
||||
self.common.call_graphql::<AddUserToGroup, _>(
|
||||
add_user_to_group::Variables {
|
||||
user: self.common.username.clone(),
|
||||
group: group_id,
|
||||
},
|
||||
Msg::AddGroupResponse,
|
||||
"Error trying to initiate adding the user to a group",
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn get_selectable_group_list(&self, group_list: &[Group]) -> Vec<Group> {
|
||||
let user_groups = self.props.groups.iter().collect::<HashSet<_>>();
|
||||
let user_groups = self.common.groups.iter().collect::<HashSet<_>>();
|
||||
group_list
|
||||
.iter()
|
||||
.filter(|g| !user_groups.contains(g))
|
||||
@@ -152,29 +141,24 @@ impl Component for AddUserToGroupComponent {
|
||||
type Properties = Props;
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
let mut res = Self {
|
||||
link,
|
||||
props,
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
group_list: None,
|
||||
selected_group: None,
|
||||
task: None,
|
||||
};
|
||||
res.get_group_list();
|
||||
res
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
match self.handle_msg(msg) {
|
||||
Err(e) => {
|
||||
ConsoleService::error(&e.to_string());
|
||||
self.props.on_error.emit(e);
|
||||
self.task = None;
|
||||
true
|
||||
}
|
||||
Ok(b) => b,
|
||||
}
|
||||
CommonComponentParts::<Self>::update_and_report_error(
|
||||
self,
|
||||
msg,
|
||||
self.common.on_error.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.props.neq_assign(props)
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
@@ -189,7 +173,7 @@ impl Component for AddUserToGroupComponent {
|
||||
html! {
|
||||
<div class="row">
|
||||
<div class="col-sm-3">
|
||||
<Select on_selection_change=self.link.callback(Msg::SelectionChanged)>
|
||||
<Select on_selection_change=self.common.callback(Msg::SelectionChanged)>
|
||||
{
|
||||
to_add_group_list
|
||||
.into_iter()
|
||||
@@ -201,8 +185,8 @@ impl Component for AddUserToGroupComponent {
|
||||
<div class="col-sm-1">
|
||||
<button
|
||||
class="btn btn-success"
|
||||
disabled=self.selected_group.is_none() || self.task.is_some()
|
||||
onclick=self.link.callback(|_| Msg::SubmitAddGroup)>
|
||||
disabled=self.selected_group.is_none() || self.common.is_task_running()
|
||||
onclick=self.common.callback(|_| Msg::SubmitAddGroup)>
|
||||
{"Add"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,8 @@ use crate::{
|
||||
group_table::GroupTable,
|
||||
login::LoginForm,
|
||||
logout::LogoutButton,
|
||||
reset_password_step1::ResetPasswordStep1Form,
|
||||
reset_password_step2::ResetPasswordStep2Form,
|
||||
router::{AppRoute, Link, NavButton},
|
||||
user_details::UserDetails,
|
||||
user_table::UserTable,
|
||||
@@ -83,7 +85,7 @@ impl Component for App {
|
||||
}
|
||||
if self.user_info.is_none() {
|
||||
self.route_dispatcher
|
||||
.send(RouteRequest::ReplaceRoute(Route::new_no_state("/login")));
|
||||
.send(RouteRequest::ReplaceRoute(Route::from(AppRoute::Login)));
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -98,46 +100,14 @@ impl Component for App {
|
||||
html! {
|
||||
<div class="container shadow-sm py-3">
|
||||
{self.view_banner()}
|
||||
<div class="row justify-content-center">
|
||||
<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 |switch: AppRoute| {
|
||||
match switch {
|
||||
AppRoute::Login => html! {
|
||||
<LoginForm on_logged_in=link.callback(Msg::Login)/>
|
||||
},
|
||||
AppRoute::CreateUser => html! {
|
||||
<CreateUserForm/>
|
||||
},
|
||||
AppRoute::Index | AppRoute::ListUsers => html! {
|
||||
<div>
|
||||
<UserTable />
|
||||
<NavButton classes="btn btn-primary" route=AppRoute::CreateUser>{"Create a user"}</NavButton>
|
||||
</div>
|
||||
},
|
||||
AppRoute::CreateGroup => html! {
|
||||
<CreateGroupForm/>
|
||||
},
|
||||
AppRoute::ListGroups => html! {
|
||||
<div>
|
||||
<GroupTable />
|
||||
<NavButton classes="btn btn-primary" route=AppRoute::CreateGroup>{"Create a group"}</NavButton>
|
||||
</div>
|
||||
},
|
||||
AppRoute::GroupDetails(group_id) => html! {
|
||||
<GroupDetails group_id=group_id />
|
||||
},
|
||||
AppRoute::UserDetails(username) => html! {
|
||||
<UserDetails username=username.clone() is_admin=is_admin />
|
||||
},
|
||||
AppRoute::ChangePassword(username) => html! {
|
||||
<ChangePasswordForm username=username.clone() is_admin=is_admin />
|
||||
}
|
||||
}
|
||||
})
|
||||
render = Router::render(move |s| Self::dispatch_route(s, &link, is_admin))
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{self.view_footer()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -147,7 +117,11 @@ 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") {
|
||||
if current_route.is_empty()
|
||||
|| current_route == "/"
|
||||
|| current_route.contains("login")
|
||||
|| current_route.contains("reset-password")
|
||||
{
|
||||
None
|
||||
} else {
|
||||
use yew_router::Switch;
|
||||
@@ -156,10 +130,15 @@ impl App {
|
||||
}
|
||||
|
||||
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::new_no_state("/login")));
|
||||
.send(RouteRequest::ReplaceRoute(Route::from(AppRoute::Login)));
|
||||
}
|
||||
Some((user_name, is_admin)) => match &self.redirect_to {
|
||||
Some(url) => {
|
||||
@@ -169,7 +148,7 @@ impl App {
|
||||
None => {
|
||||
if *is_admin {
|
||||
self.route_dispatcher
|
||||
.send(RouteRequest::ReplaceRoute(Route::new_no_state("/users")));
|
||||
.send(RouteRequest::ReplaceRoute(Route::from(AppRoute::ListUsers)));
|
||||
} else {
|
||||
self.route_dispatcher
|
||||
.send(RouteRequest::ReplaceRoute(Route::from(
|
||||
@@ -181,6 +160,47 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_route(switch: AppRoute, link: &ComponentLink<Self>, is_admin: bool) -> Html {
|
||||
match switch {
|
||||
AppRoute::Login => html! {
|
||||
<LoginForm on_logged_in=link.callback(Msg::Login)/>
|
||||
},
|
||||
AppRoute::CreateUser => html! {
|
||||
<CreateUserForm/>
|
||||
},
|
||||
AppRoute::Index | AppRoute::ListUsers => html! {
|
||||
<div>
|
||||
<UserTable />
|
||||
<NavButton classes="btn btn-primary" route=AppRoute::CreateUser>{"Create a user"}</NavButton>
|
||||
</div>
|
||||
},
|
||||
AppRoute::CreateGroup => html! {
|
||||
<CreateGroupForm/>
|
||||
},
|
||||
AppRoute::ListGroups => html! {
|
||||
<div>
|
||||
<GroupTable />
|
||||
<NavButton classes="btn btn-primary" route=AppRoute::CreateGroup>{"Create a group"}</NavButton>
|
||||
</div>
|
||||
},
|
||||
AppRoute::GroupDetails(group_id) => html! {
|
||||
<GroupDetails group_id=group_id />
|
||||
},
|
||||
AppRoute::UserDetails(username) => html! {
|
||||
<UserDetails username=username is_admin=is_admin />
|
||||
},
|
||||
AppRoute::ChangePassword(username) => html! {
|
||||
<ChangePasswordForm username=username is_admin=is_admin />
|
||||
},
|
||||
AppRoute::StartResetPassword => html! {
|
||||
<ResetPasswordStep1Form />
|
||||
},
|
||||
AppRoute::FinishResetPassword(token) => html! {
|
||||
<ResetPasswordStep2Form token=token />
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn view_banner(&self) -> Html {
|
||||
html! {
|
||||
<header class="p-3 mb-4 border-bottom shadow-sm">
|
||||
@@ -252,6 +272,30 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn view_footer(&self) -> Html {
|
||||
html! {
|
||||
<footer class="text-center text-muted fixed-bottom bg-light">
|
||||
<div>
|
||||
<span>{format!("LLDAP version {}", env!("CARGO_PKG_VERSION"))}</span>
|
||||
</div>
|
||||
<div>
|
||||
<a href="https://github.com/nitnelave/lldap" class="me-4 text-reset">
|
||||
<i class="bi-github"></i>
|
||||
</a>
|
||||
<a href="https://discord.gg/h5PEdRMNyP" class="me-4 text-reset">
|
||||
<i class="bi-discord"></i>
|
||||
</a>
|
||||
<a href="https://twitter.com/nitnelave1?ref_src=twsrc%5Etfw" class="me-4 text-reset">
|
||||
<i class="bi-twitter"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<span>{"License "}<a href="https://github.com/nitnelave/lldap/blob/main/LICENSE" class="link-secondary">{"GNU GPL"}</a></span>
|
||||
</div>
|
||||
</footer>
|
||||
}
|
||||
}
|
||||
|
||||
fn is_admin(&self) -> bool {
|
||||
match &self.user_info {
|
||||
None => false,
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
use crate::{
|
||||
components::router::{AppRoute, NavButton},
|
||||
infra::api::HostService,
|
||||
infra::{
|
||||
api::HostService,
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
},
|
||||
};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use lldap_auth::*;
|
||||
use validator_derive::Validate;
|
||||
use yew::{
|
||||
prelude::*,
|
||||
services::{fetch::FetchTask, ConsoleService},
|
||||
};
|
||||
use yew::{prelude::*, services::ConsoleService};
|
||||
use yew_form::Form;
|
||||
use yew_form_derive::Model;
|
||||
use yew_router::{
|
||||
@@ -36,7 +36,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",
|
||||
@@ -58,17 +58,13 @@ fn empty_or_long(value: &str) -> Result<(), validator::ValidationError> {
|
||||
}
|
||||
|
||||
pub struct ChangePasswordForm {
|
||||
link: ComponentLink<Self>,
|
||||
props: Props,
|
||||
error: Option<anyhow::Error>,
|
||||
common: CommonComponentParts<Self>,
|
||||
form: Form<FormModel>,
|
||||
opaque_data: OpaqueData,
|
||||
// Used to keep the request alive long enough.
|
||||
task: Option<FetchTask>,
|
||||
route_dispatcher: RouteAgentDispatcher,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Properties)]
|
||||
#[derive(Clone, PartialEq, Eq, Properties)]
|
||||
pub struct Props {
|
||||
pub username: String,
|
||||
pub is_admin: bool,
|
||||
@@ -83,25 +79,16 @@ pub enum Msg {
|
||||
RegistrationFinishResponse(Result<()>),
|
||||
}
|
||||
|
||||
impl ChangePasswordForm {
|
||||
fn call_backend<M, Req, C, Resp>(&mut self, method: M, req: Req, callback: C) -> Result<()>
|
||||
where
|
||||
M: Fn(Req, Callback<Resp>) -> Result<FetchTask>,
|
||||
C: Fn(Resp) -> <Self as Component>::Message + 'static,
|
||||
{
|
||||
self.task = Some(method(req, self.link.callback(callback))?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_message(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
impl CommonComponent<ChangePasswordForm> for ChangePasswordForm {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::FormUpdate => Ok(true),
|
||||
Msg::Submit => {
|
||||
if !self.form.validate() {
|
||||
bail!("Check the form for errors");
|
||||
}
|
||||
if self.props.is_admin {
|
||||
self.handle_message(Msg::SubmitNewPassword)
|
||||
if self.common.is_admin {
|
||||
self.handle_msg(Msg::SubmitNewPassword)
|
||||
} else {
|
||||
let old_password = self.form.model().old_password;
|
||||
if old_password.is_empty() {
|
||||
@@ -113,10 +100,10 @@ impl ChangePasswordForm {
|
||||
.context("Could not initialize login")?;
|
||||
self.opaque_data = OpaqueData::Login(login_start_request.state);
|
||||
let req = login::ClientLoginStartRequest {
|
||||
username: self.props.username.clone(),
|
||||
username: self.common.username.clone(),
|
||||
login_start_request: login_start_request.message,
|
||||
};
|
||||
self.call_backend(
|
||||
self.common.call_backend(
|
||||
HostService::login_start,
|
||||
req,
|
||||
Msg::AuthenticationStartResponse,
|
||||
@@ -142,7 +129,7 @@ impl ChangePasswordForm {
|
||||
}
|
||||
_ => panic!("Unexpected data in opaque_data field"),
|
||||
};
|
||||
self.handle_message(Msg::SubmitNewPassword)
|
||||
self.handle_msg(Msg::SubmitNewPassword)
|
||||
}
|
||||
Msg::SubmitNewPassword => {
|
||||
let mut rng = rand::rngs::OsRng;
|
||||
@@ -151,11 +138,11 @@ impl ChangePasswordForm {
|
||||
opaque::client::registration::start_registration(&new_password, &mut rng)
|
||||
.context("Could not initiate password change")?;
|
||||
let req = registration::ClientRegistrationStartRequest {
|
||||
username: self.props.username.clone(),
|
||||
username: self.common.username.clone(),
|
||||
registration_start_request: registration_start_request.message,
|
||||
};
|
||||
self.opaque_data = OpaqueData::Registration(registration_start_request.state);
|
||||
self.call_backend(
|
||||
self.common.call_backend(
|
||||
HostService::register_start,
|
||||
req,
|
||||
Msg::RegistrationStartResponse,
|
||||
@@ -178,7 +165,7 @@ impl ChangePasswordForm {
|
||||
server_data: res.server_data,
|
||||
registration_upload: registration_finish.message,
|
||||
};
|
||||
self.call_backend(
|
||||
self.common.call_backend(
|
||||
HostService::register_finish,
|
||||
req,
|
||||
Msg::RegistrationFinishResponse,
|
||||
@@ -189,11 +176,11 @@ impl ChangePasswordForm {
|
||||
Ok(false)
|
||||
}
|
||||
Msg::RegistrationFinishResponse(response) => {
|
||||
self.task = None;
|
||||
self.common.cancel_task();
|
||||
if response.is_ok() {
|
||||
self.route_dispatcher
|
||||
.send(RouteRequest::ChangeRoute(Route::from(
|
||||
AppRoute::UserDetails(self.props.username.clone()),
|
||||
AppRoute::UserDetails(self.common.username.clone()),
|
||||
)));
|
||||
}
|
||||
response?;
|
||||
@@ -201,6 +188,10 @@ impl ChangePasswordForm {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for ChangePasswordForm {
|
||||
@@ -209,35 +200,23 @@ impl Component for ChangePasswordForm {
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
ChangePasswordForm {
|
||||
link,
|
||||
props,
|
||||
error: None,
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
form: yew_form::Form::<FormModel>::new(FormModel::default()),
|
||||
opaque_data: OpaqueData::None,
|
||||
task: None,
|
||||
route_dispatcher: RouteAgentDispatcher::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
self.error = None;
|
||||
match self.handle_message(msg) {
|
||||
Err(e) => {
|
||||
ConsoleService::error(&e.to_string());
|
||||
self.error = Some(e);
|
||||
self.task = None;
|
||||
true
|
||||
}
|
||||
Ok(b) => b,
|
||||
}
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||
false
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
let is_admin = self.props.is_admin;
|
||||
let is_admin = self.common.is_admin;
|
||||
type Field = yew_form::Field<FormModel>;
|
||||
html! {
|
||||
<>
|
||||
@@ -257,7 +236,7 @@ impl Component for ChangePasswordForm {
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="current-password"
|
||||
oninput=self.link.callback(|_| Msg::FormUpdate) />
|
||||
oninput=self.common.callback(|_| Msg::FormUpdate) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("old_password")}
|
||||
</div>
|
||||
@@ -273,11 +252,12 @@ impl Component for ChangePasswordForm {
|
||||
<Field
|
||||
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.link.callback(|_| Msg::FormUpdate) />
|
||||
oninput=self.common.callback(|_| Msg::FormUpdate) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("password")}
|
||||
</div>
|
||||
@@ -292,11 +272,12 @@ impl Component for ChangePasswordForm {
|
||||
<Field
|
||||
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.link.callback(|_| Msg::FormUpdate) />
|
||||
oninput=self.common.callback(|_| Msg::FormUpdate) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("confirm_password")}
|
||||
</div>
|
||||
@@ -306,13 +287,13 @@ impl Component for ChangePasswordForm {
|
||||
<button
|
||||
class="btn btn-primary col-sm-1 col-form-label"
|
||||
type="submit"
|
||||
disabled=self.task.is_some()
|
||||
onclick=self.link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
|
||||
{"Submit"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{ if let Some(e) = &self.error {
|
||||
{ if let Some(e) = &self.common.error {
|
||||
html! {
|
||||
<div class="alert alert-danger">
|
||||
{e.to_string() }
|
||||
@@ -323,7 +304,7 @@ impl Component for ChangePasswordForm {
|
||||
<div>
|
||||
<NavButton
|
||||
classes="btn btn-primary"
|
||||
route=AppRoute::UserDetails(self.props.username.clone())>
|
||||
route=AppRoute::UserDetails(self.common.username.clone())>
|
||||
{"Back"}
|
||||
</NavButton>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
use crate::{components::router::AppRoute, infra::api::HostService};
|
||||
use crate::{
|
||||
components::router::AppRoute,
|
||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||
};
|
||||
use anyhow::{bail, Result};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use validator_derive::Validate;
|
||||
use yew::prelude::*;
|
||||
use yew::services::{fetch::FetchTask, ConsoleService};
|
||||
use yew::services::ConsoleService;
|
||||
use yew_form_derive::Model;
|
||||
use yew_router::{
|
||||
agent::{RouteAgentDispatcher, RouteRequest},
|
||||
@@ -20,15 +23,12 @@ use yew_router::{
|
||||
pub struct CreateGroup;
|
||||
|
||||
pub struct CreateGroupForm {
|
||||
link: ComponentLink<Self>,
|
||||
common: CommonComponentParts<Self>,
|
||||
route_dispatcher: RouteAgentDispatcher,
|
||||
form: yew_form::Form<CreateGroupModel>,
|
||||
error: Option<anyhow::Error>,
|
||||
// Used to keep the request alive long enough.
|
||||
task: Option<FetchTask>,
|
||||
}
|
||||
|
||||
#[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,
|
||||
@@ -40,7 +40,7 @@ pub enum Msg {
|
||||
CreateGroupResponse(Result<create_group::ResponseData>),
|
||||
}
|
||||
|
||||
impl CreateGroupForm {
|
||||
impl CommonComponent<CreateGroupForm> for CreateGroupForm {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::Update => Ok(true),
|
||||
@@ -52,11 +52,11 @@ impl CreateGroupForm {
|
||||
let req = create_group::Variables {
|
||||
name: model.groupname,
|
||||
};
|
||||
self.task = Some(HostService::graphql_query::<CreateGroup>(
|
||||
self.common.call_graphql::<CreateGroup, _>(
|
||||
req,
|
||||
self.link.callback(Msg::CreateGroupResponse),
|
||||
Msg::CreateGroupResponse,
|
||||
"Error trying to create group",
|
||||
)?);
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
Msg::CreateGroupResponse(response) => {
|
||||
@@ -70,37 +70,30 @@ impl CreateGroupForm {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for CreateGroupForm {
|
||||
type Message = Msg;
|
||||
type Properties = ();
|
||||
|
||||
fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
Self {
|
||||
link,
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
route_dispatcher: RouteAgentDispatcher::new(),
|
||||
form: yew_form::Form::<CreateGroupModel>::new(CreateGroupModel::default()),
|
||||
error: None,
|
||||
task: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
self.error = None;
|
||||
match self.handle_msg(msg) {
|
||||
Err(e) => {
|
||||
ConsoleService::error(&e.to_string());
|
||||
self.error = Some(e);
|
||||
self.task = None;
|
||||
true
|
||||
}
|
||||
Ok(b) => b,
|
||||
}
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||
false
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
@@ -124,7 +117,7 @@ impl Component for CreateGroupForm {
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="groupname"
|
||||
oninput=self.link.callback(|_| Msg::Update) />
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("groupname")}
|
||||
</div>
|
||||
@@ -134,13 +127,13 @@ impl Component for CreateGroupForm {
|
||||
<button
|
||||
class="btn btn-primary col-auto col-form-label"
|
||||
type="submit"
|
||||
disabled=self.task.is_some()
|
||||
onclick=self.link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})>
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})>
|
||||
{"Submit"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{ if let Some(e) = &self.error {
|
||||
{ if let Some(e) = &self.common.error {
|
||||
html! {
|
||||
<div class="alert alert-danger">
|
||||
{e.to_string() }
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
use crate::{components::router::AppRoute, infra::api::HostService};
|
||||
use crate::{
|
||||
components::router::AppRoute,
|
||||
infra::{
|
||||
api::HostService,
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
},
|
||||
};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use lldap_auth::{opaque, registration};
|
||||
use validator_derive::Validate;
|
||||
use yew::prelude::*;
|
||||
use yew::services::{fetch::FetchTask, ConsoleService};
|
||||
use yew::services::ConsoleService;
|
||||
use yew_form_derive::Model;
|
||||
use yew_router::{
|
||||
agent::{RouteAgentDispatcher, RouteRequest},
|
||||
@@ -21,15 +27,12 @@ use yew_router::{
|
||||
pub struct CreateUser;
|
||||
|
||||
pub struct CreateUserForm {
|
||||
link: ComponentLink<Self>,
|
||||
common: CommonComponentParts<Self>,
|
||||
route_dispatcher: RouteAgentDispatcher,
|
||||
form: yew_form::Form<CreateUserModel>,
|
||||
error: Option<anyhow::Error>,
|
||||
// Used to keep the request alive long enough.
|
||||
task: Option<FetchTask>,
|
||||
}
|
||||
|
||||
#[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,
|
||||
@@ -70,7 +73,7 @@ pub enum Msg {
|
||||
RegistrationFinishResponse(Result<()>),
|
||||
}
|
||||
|
||||
impl CreateUserForm {
|
||||
impl CommonComponent<CreateUserForm> for CreateUserForm {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::Update => Ok(true),
|
||||
@@ -87,13 +90,14 @@ impl CreateUserForm {
|
||||
displayName: to_option(model.display_name),
|
||||
firstName: to_option(model.first_name),
|
||||
lastName: to_option(model.last_name),
|
||||
avatar: None,
|
||||
},
|
||||
};
|
||||
self.task = Some(HostService::graphql_query::<CreateUser>(
|
||||
self.common.call_graphql::<CreateUser, _>(
|
||||
req,
|
||||
self.link.callback(Msg::CreateUserResponse),
|
||||
Msg::CreateUserResponse,
|
||||
"Error trying to create user",
|
||||
)?);
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
Msg::CreateUserResponse(r) => {
|
||||
@@ -118,14 +122,11 @@ impl CreateUserForm {
|
||||
username: user_id,
|
||||
registration_start_request: message,
|
||||
};
|
||||
self.task = Some(
|
||||
HostService::register_start(
|
||||
req,
|
||||
self.link
|
||||
.callback_once(move |r| Msg::RegistrationStartResponse((state, r))),
|
||||
)
|
||||
.context("Error trying to create user")?,
|
||||
);
|
||||
self.common
|
||||
.call_backend(HostService::register_start, req, move |r| {
|
||||
Msg::RegistrationStartResponse((state, r))
|
||||
})
|
||||
.context("Error trying to create user")?;
|
||||
} else {
|
||||
self.update(Msg::SuccessfulCreation);
|
||||
}
|
||||
@@ -143,13 +144,13 @@ impl CreateUserForm {
|
||||
server_data: response.server_data,
|
||||
registration_upload: registration_upload.message,
|
||||
};
|
||||
self.task = Some(
|
||||
HostService::register_finish(
|
||||
self.common
|
||||
.call_backend(
|
||||
HostService::register_finish,
|
||||
req,
|
||||
self.link.callback(Msg::RegistrationFinishResponse),
|
||||
Msg::RegistrationFinishResponse,
|
||||
)
|
||||
.context("Error trying to register user")?,
|
||||
);
|
||||
.context("Error trying to register user")?;
|
||||
Ok(false)
|
||||
}
|
||||
Msg::RegistrationFinishResponse(response) => {
|
||||
@@ -163,37 +164,30 @@ impl CreateUserForm {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for CreateUserForm {
|
||||
type Message = Msg;
|
||||
type Properties = ();
|
||||
|
||||
fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
Self {
|
||||
link,
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
route_dispatcher: RouteAgentDispatcher::new(),
|
||||
form: yew_form::Form::<CreateUserModel>::new(CreateUserModel::default()),
|
||||
error: None,
|
||||
task: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
self.error = None;
|
||||
match self.handle_msg(msg) {
|
||||
Err(e) => {
|
||||
ConsoleService::error(&e.to_string());
|
||||
self.error = Some(e);
|
||||
self.task = None;
|
||||
true
|
||||
}
|
||||
Ok(b) => b,
|
||||
}
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||
false
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
@@ -217,7 +211,7 @@ impl Component for CreateUserForm {
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="username"
|
||||
oninput=self.link.callback(|_| Msg::Update) />
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("username")}
|
||||
</div>
|
||||
@@ -237,7 +231,7 @@ impl Component for CreateUserForm {
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="email"
|
||||
oninput=self.link.callback(|_| Msg::Update) />
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("email")}
|
||||
</div>
|
||||
@@ -256,7 +250,7 @@ impl Component for CreateUserForm {
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
field_name="display_name"
|
||||
oninput=self.link.callback(|_| Msg::Update) />
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("display_name")}
|
||||
</div>
|
||||
@@ -275,7 +269,7 @@ impl Component for CreateUserForm {
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
field_name="first_name"
|
||||
oninput=self.link.callback(|_| Msg::Update) />
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("first_name")}
|
||||
</div>
|
||||
@@ -294,7 +288,7 @@ impl Component for CreateUserForm {
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
field_name="last_name"
|
||||
oninput=self.link.callback(|_| Msg::Update) />
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("last_name")}
|
||||
</div>
|
||||
@@ -314,7 +308,7 @@ impl Component for CreateUserForm {
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="new-password"
|
||||
oninput=self.link.callback(|_| Msg::Update) />
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("password")}
|
||||
</div>
|
||||
@@ -334,7 +328,7 @@ impl Component for CreateUserForm {
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="new-password"
|
||||
oninput=self.link.callback(|_| Msg::Update) />
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("confirm_password")}
|
||||
</div>
|
||||
@@ -343,14 +337,14 @@ 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.task.is_some()
|
||||
disabled=self.common.is_task_running()
|
||||
type="submit"
|
||||
onclick=self.link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})>
|
||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})>
|
||||
{"Submit"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{ if let Some(e) = &self.error {
|
||||
{ if let Some(e) = &self.common.error {
|
||||
html! {
|
||||
<div class="alert alert-danger">
|
||||
{e.to_string() }
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
use crate::{
|
||||
components::group_table::Group,
|
||||
infra::{api::HostService, modal::Modal},
|
||||
infra::{
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
modal::Modal,
|
||||
},
|
||||
};
|
||||
use anyhow::{Error, Result};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use yew::prelude::*;
|
||||
use yew::services::fetch::FetchTask;
|
||||
use yewtil::NeqAssign;
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
@@ -18,11 +19,9 @@ use yewtil::NeqAssign;
|
||||
pub struct DeleteGroupQuery;
|
||||
|
||||
pub struct DeleteGroup {
|
||||
link: ComponentLink<Self>,
|
||||
props: DeleteGroupProps,
|
||||
common: CommonComponentParts<Self>,
|
||||
node_ref: NodeRef,
|
||||
modal: Option<Modal>,
|
||||
task: Option<FetchTask>,
|
||||
}
|
||||
|
||||
#[derive(yew::Properties, Clone, PartialEq, Debug)]
|
||||
@@ -39,17 +38,51 @@ pub enum Msg {
|
||||
DeleteGroupResponse(Result<delete_group_query::ResponseData>),
|
||||
}
|
||||
|
||||
impl CommonComponent<DeleteGroup> for DeleteGroup {
|
||||
fn handle_msg(&mut 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.common.call_graphql::<DeleteGroupQuery, _>(
|
||||
delete_group_query::Variables {
|
||||
group_id: self.common.group.id,
|
||||
},
|
||||
Msg::DeleteGroupResponse,
|
||||
"Error trying to delete group",
|
||||
);
|
||||
}
|
||||
Msg::DismissModal => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for DeleteGroup {
|
||||
type Message = Msg;
|
||||
type Properties = DeleteGroupProps;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
Self {
|
||||
link,
|
||||
props,
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
node_ref: NodeRef::default(),
|
||||
modal: None,
|
||||
task: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,39 +97,15 @@ impl Component for DeleteGroup {
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
match msg {
|
||||
Msg::ClickedDeleteGroup => {
|
||||
self.modal.as_ref().expect("modal not initialized").show();
|
||||
}
|
||||
Msg::ConfirmDeleteGroup => {
|
||||
self.update(Msg::DismissModal);
|
||||
self.task = HostService::graphql_query::<DeleteGroupQuery>(
|
||||
delete_group_query::Variables {
|
||||
group_id: self.props.group.id,
|
||||
},
|
||||
self.link.callback(Msg::DeleteGroupResponse),
|
||||
"Error trying to delete group",
|
||||
)
|
||||
.map_err(|e| self.props.on_error.emit(e))
|
||||
.ok();
|
||||
}
|
||||
Msg::DismissModal => {
|
||||
self.modal.as_ref().expect("modal not initialized").hide();
|
||||
}
|
||||
Msg::DeleteGroupResponse(response) => {
|
||||
self.task = None;
|
||||
if let Err(e) = response {
|
||||
self.props.on_error.emit(e);
|
||||
} else {
|
||||
self.props.on_group_deleted.emit(self.props.group.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
CommonComponentParts::<Self>::update_and_report_error(
|
||||
self,
|
||||
msg,
|
||||
self.common.on_error.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.props.neq_assign(props)
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
@@ -104,8 +113,8 @@ impl Component for DeleteGroup {
|
||||
<>
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
disabled=self.task.is_some()
|
||||
onclick=self.link.callback(|_| Msg::ClickedDeleteGroup)>
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|_| Msg::ClickedDeleteGroup)>
|
||||
<i class="bi-x-circle-fill" aria-label="Delete group" />
|
||||
</button>
|
||||
{self.show_modal()}
|
||||
@@ -119,7 +128,7 @@ impl DeleteGroup {
|
||||
html! {
|
||||
<div
|
||||
class="modal fade"
|
||||
id="deleteGroupModal".to_string() + &self.props.group.id.to_string()
|
||||
id="deleteGroupModal".to_string() + &self.common.group.id.to_string()
|
||||
tabindex="-1"
|
||||
aria-labelledby="deleteGroupModalLabel"
|
||||
aria-hidden="true"
|
||||
@@ -132,24 +141,24 @@ impl DeleteGroup {
|
||||
type="button"
|
||||
class="btn-close"
|
||||
aria-label="Close"
|
||||
onclick=self.link.callback(|_| Msg::DismissModal) />
|
||||
onclick=self.common.callback(|_| Msg::DismissModal) />
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<span>
|
||||
{"Are you sure you want to delete group "}
|
||||
<b>{&self.props.group.display_name}</b>{"?"}
|
||||
<b>{&self.common.group.display_name}</b>{"?"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
onclick=self.link.callback(|_| Msg::DismissModal)>
|
||||
onclick=self.common.callback(|_| Msg::DismissModal)>
|
||||
{"Cancel"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick=self.link.callback(|_| Msg::ConfirmDeleteGroup)
|
||||
onclick=self.common.callback(|_| Msg::ConfirmDeleteGroup)
|
||||
class="btn btn-danger">{"Yes, I'm sure"}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use crate::infra::{api::HostService, modal::Modal};
|
||||
use crate::infra::{
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
modal::Modal,
|
||||
};
|
||||
use anyhow::{Error, Result};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use yew::prelude::*;
|
||||
use yew::services::fetch::FetchTask;
|
||||
use yewtil::NeqAssign;
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
@@ -15,11 +16,9 @@ use yewtil::NeqAssign;
|
||||
pub struct DeleteUserQuery;
|
||||
|
||||
pub struct DeleteUser {
|
||||
link: ComponentLink<Self>,
|
||||
props: DeleteUserProps,
|
||||
common: CommonComponentParts<Self>,
|
||||
node_ref: NodeRef,
|
||||
modal: Option<Modal>,
|
||||
task: Option<FetchTask>,
|
||||
}
|
||||
|
||||
#[derive(yew::Properties, Clone, PartialEq, Debug)]
|
||||
@@ -36,17 +35,51 @@ pub enum Msg {
|
||||
DeleteUserResponse(Result<delete_user_query::ResponseData>),
|
||||
}
|
||||
|
||||
impl CommonComponent<DeleteUser> for DeleteUser {
|
||||
fn handle_msg(&mut 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.common.call_graphql::<DeleteUserQuery, _>(
|
||||
delete_user_query::Variables {
|
||||
user: self.common.username.clone(),
|
||||
},
|
||||
Msg::DeleteUserResponse,
|
||||
"Error trying to delete user",
|
||||
);
|
||||
}
|
||||
Msg::DismissModal => {
|
||||
self.modal.as_ref().expect("modal not initialized").hide();
|
||||
}
|
||||
Msg::DeleteUserResponse(response) => {
|
||||
self.common.cancel_task();
|
||||
response?;
|
||||
self.common
|
||||
.props
|
||||
.on_user_deleted
|
||||
.emit(self.common.username.clone());
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for DeleteUser {
|
||||
type Message = Msg;
|
||||
type Properties = DeleteUserProps;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
Self {
|
||||
link,
|
||||
props,
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
node_ref: NodeRef::default(),
|
||||
modal: None,
|
||||
task: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,39 +94,15 @@ impl Component for DeleteUser {
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
match msg {
|
||||
Msg::ClickedDeleteUser => {
|
||||
self.modal.as_ref().expect("modal not initialized").show();
|
||||
}
|
||||
Msg::ConfirmDeleteUser => {
|
||||
self.update(Msg::DismissModal);
|
||||
self.task = HostService::graphql_query::<DeleteUserQuery>(
|
||||
delete_user_query::Variables {
|
||||
user: self.props.username.clone(),
|
||||
},
|
||||
self.link.callback(Msg::DeleteUserResponse),
|
||||
"Error trying to delete user",
|
||||
)
|
||||
.map_err(|e| self.props.on_error.emit(e))
|
||||
.ok();
|
||||
}
|
||||
Msg::DismissModal => {
|
||||
self.modal.as_ref().expect("modal not initialized").hide();
|
||||
}
|
||||
Msg::DeleteUserResponse(response) => {
|
||||
self.task = None;
|
||||
if let Err(e) = response {
|
||||
self.props.on_error.emit(e);
|
||||
} else {
|
||||
self.props.on_user_deleted.emit(self.props.username.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
CommonComponentParts::<Self>::update_and_report_error(
|
||||
self,
|
||||
msg,
|
||||
self.common.on_error.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.props.neq_assign(props)
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
@@ -101,8 +110,8 @@ impl Component for DeleteUser {
|
||||
<>
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
disabled=self.task.is_some()
|
||||
onclick=self.link.callback(|_| Msg::ClickedDeleteUser)>
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|_| Msg::ClickedDeleteUser)>
|
||||
<i class="bi-x-circle-fill" aria-label="Delete user" />
|
||||
</button>
|
||||
{self.show_modal()}
|
||||
@@ -116,7 +125,7 @@ impl DeleteUser {
|
||||
html! {
|
||||
<div
|
||||
class="modal fade"
|
||||
id="deleteUserModal".to_string() + &self.props.username
|
||||
id="deleteUserModal".to_string() + &self.common.username
|
||||
tabindex="-1"
|
||||
//role="dialog"
|
||||
aria-labelledby="deleteUserModalLabel"
|
||||
@@ -130,24 +139,24 @@ impl DeleteUser {
|
||||
type="button"
|
||||
class="btn-close"
|
||||
aria-label="Close"
|
||||
onclick=self.link.callback(|_| Msg::DismissModal) />
|
||||
onclick=self.common.callback(|_| Msg::DismissModal) />
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<span>
|
||||
{"Are you sure you want to delete user "}
|
||||
<b>{&self.props.username}</b>{"?"}
|
||||
<b>{&self.common.username}</b>{"?"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
onclick=self.link.callback(|_| Msg::DismissModal)>
|
||||
onclick=self.common.callback(|_| Msg::DismissModal)>
|
||||
{"Cancel"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick=self.link.callback(|_| Msg::ConfirmDeleteUser)
|
||||
onclick=self.common.callback(|_| Msg::ConfirmDeleteUser)
|
||||
class="btn btn-danger">{"Yes, I'm sure"}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,14 +4,11 @@ use crate::{
|
||||
remove_user_from_group::RemoveUserFromGroupComponent,
|
||||
router::{AppRoute, Link},
|
||||
},
|
||||
infra::api::HostService,
|
||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||
};
|
||||
use anyhow::{bail, Error, Result};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use yew::{
|
||||
prelude::*,
|
||||
services::{fetch::FetchTask, ConsoleService},
|
||||
};
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
@@ -27,15 +24,10 @@ pub type User = get_group_details::GetGroupDetailsGroupUsers;
|
||||
pub type AddGroupMemberUser = add_group_member::User;
|
||||
|
||||
pub struct GroupDetails {
|
||||
link: ComponentLink<Self>,
|
||||
props: Props,
|
||||
common: CommonComponentParts<Self>,
|
||||
/// The group info. If none, the error is in `error`. If `error` is None, then we haven't
|
||||
/// received the server response yet.
|
||||
group: Option<Group>,
|
||||
/// Error message displayed to the user.
|
||||
error: Option<Error>,
|
||||
// Used to keep the request alive long enough.
|
||||
_task: Option<FetchTask>,
|
||||
}
|
||||
|
||||
/// State machine describing the possible transitions of the component state.
|
||||
@@ -48,52 +40,20 @@ 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) {
|
||||
self._task = HostService::graphql_query::<GetGroupDetails>(
|
||||
self.common.call_graphql::<GetGroupDetails, _>(
|
||||
get_group_details::Variables {
|
||||
id: self.props.group_id,
|
||||
id: self.common.group_id,
|
||||
},
|
||||
self.link.callback(Msg::GroupDetailsResponse),
|
||||
Msg::GroupDetailsResponse,
|
||||
"Error trying to fetch group details",
|
||||
)
|
||||
.map_err(|e| {
|
||||
ConsoleService::log(&e.to_string());
|
||||
e
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::GroupDetailsResponse(response) => match response {
|
||||
Ok(group) => self.group = Some(group.group),
|
||||
Err(e) => {
|
||||
self.group = None;
|
||||
bail!("Error getting user details: {}", e);
|
||||
}
|
||||
},
|
||||
Msg::OnError(e) => return Err(e),
|
||||
Msg::OnUserAddedToGroup(user) => {
|
||||
self.group.as_mut().unwrap().users.push(User {
|
||||
id: user.id,
|
||||
display_name: user.display_name,
|
||||
});
|
||||
}
|
||||
Msg::OnUserRemovedFromGroup((user_id, _)) => {
|
||||
self.group
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.users
|
||||
.retain(|u| u.id != user_id);
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
);
|
||||
}
|
||||
|
||||
fn view_messages(&self, error: &Option<Error>) -> Html {
|
||||
@@ -108,6 +68,45 @@ impl GroupDetails {
|
||||
}
|
||||
}
|
||||
|
||||
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.date().naive_local()}</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, g: &Group) -> Html {
|
||||
let make_user_row = |user: &User| {
|
||||
let user_id = user.id.clone();
|
||||
@@ -124,15 +123,14 @@ impl GroupDetails {
|
||||
<RemoveUserFromGroupComponent
|
||||
username=user_id
|
||||
group_id=g.id
|
||||
on_user_removed_from_group=self.link.callback(Msg::OnUserRemovedFromGroup)
|
||||
on_error=self.link.callback(Msg::OnError)/>
|
||||
on_user_removed_from_group=self.common.callback(Msg::OnUserRemovedFromGroup)
|
||||
on_error=self.common.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">
|
||||
@@ -174,51 +172,74 @@ impl GroupDetails {
|
||||
<AddGroupMemberComponent
|
||||
group_id=g.id
|
||||
users=users
|
||||
on_error=self.link.callback(Msg::OnError)
|
||||
on_user_added_to_group=self.link.callback(Msg::OnUserAddedToGroup)/>
|
||||
on_error=self.common.callback(Msg::OnError)
|
||||
on_user_added_to_group=self.common.callback(Msg::OnUserAddedToGroup)/>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CommonComponent<GroupDetails> for GroupDetails {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::GroupDetailsResponse(response) => match response {
|
||||
Ok(group) => self.group = Some(group.group),
|
||||
Err(e) => {
|
||||
self.group = None;
|
||||
bail!("Error getting user details: {}", e);
|
||||
}
|
||||
},
|
||||
Msg::OnError(e) => return Err(e),
|
||||
Msg::OnUserAddedToGroup(user) => {
|
||||
self.group.as_mut().unwrap().users.push(User {
|
||||
id: user.id,
|
||||
display_name: user.display_name,
|
||||
});
|
||||
}
|
||||
Msg::OnUserRemovedFromGroup((user_id, _)) => {
|
||||
self.group
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.users
|
||||
.retain(|u| u.id != user_id);
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for GroupDetails {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
let mut table = Self {
|
||||
link,
|
||||
props,
|
||||
_task: None,
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
group: None,
|
||||
error: None,
|
||||
};
|
||||
table.get_group_details();
|
||||
table
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
self.error = None;
|
||||
match self.handle_msg(msg) {
|
||||
Err(e) => {
|
||||
ConsoleService::error(&e.to_string());
|
||||
self.error = Some(e);
|
||||
true
|
||||
}
|
||||
Ok(b) => b,
|
||||
}
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||
false
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
match (&self.group, &self.error) {
|
||||
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_details(u)}
|
||||
{self.view_user_list(u)}
|
||||
{self.view_add_user_button(u)}
|
||||
{self.view_messages(error)}
|
||||
|
||||
@@ -3,18 +3,17 @@ use crate::{
|
||||
delete_group::DeleteGroup,
|
||||
router::{AppRoute, Link},
|
||||
},
|
||||
infra::api::HostService,
|
||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||
};
|
||||
use anyhow::{Error, Result};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use yew::prelude::*;
|
||||
use yew::services::{fetch::FetchTask, ConsoleService};
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[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;
|
||||
@@ -24,11 +23,8 @@ use get_group_list::ResponseData;
|
||||
pub type Group = get_group_list::GetGroupListGroups;
|
||||
|
||||
pub struct GroupTable {
|
||||
link: ComponentLink<Self>,
|
||||
common: CommonComponentParts<Self>,
|
||||
groups: Option<Vec<Group>>,
|
||||
error: Option<Error>,
|
||||
// Used to keep the request alive long enough.
|
||||
_task: Option<FetchTask>,
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
@@ -37,63 +33,7 @@ pub enum Msg {
|
||||
OnError(Error),
|
||||
}
|
||||
|
||||
impl GroupTable {
|
||||
fn get_groups(&mut self) {
|
||||
self._task = HostService::graphql_query::<GetGroupList>(
|
||||
get_group_list::Variables {},
|
||||
self.link.callback(Msg::ListGroupsResponse),
|
||||
"Error trying to fetch groups",
|
||||
)
|
||||
.map_err(|e| {
|
||||
ConsoleService::log(&e.to_string());
|
||||
e
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for GroupTable {
|
||||
type Message = Msg;
|
||||
type Properties = ();
|
||||
|
||||
fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
let mut table = GroupTable {
|
||||
link,
|
||||
_task: None,
|
||||
groups: None,
|
||||
error: None,
|
||||
};
|
||||
table.get_groups();
|
||||
table
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
self.error = None;
|
||||
match self.handle_msg(msg) {
|
||||
Err(e) => {
|
||||
ConsoleService::error(&e.to_string());
|
||||
self.error = Some(e);
|
||||
true
|
||||
}
|
||||
Ok(b) => b,
|
||||
}
|
||||
}
|
||||
|
||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||
false
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
html! {
|
||||
<div>
|
||||
{self.view_groups()}
|
||||
{self.view_errors()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GroupTable {
|
||||
impl CommonComponent<GroupTable> for GroupTable {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::ListGroupsResponse(groups) => {
|
||||
@@ -109,6 +49,47 @@ impl GroupTable {
|
||||
}
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for GroupTable {
|
||||
type Message = Msg;
|
||||
type Properties = ();
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
let mut table = GroupTable {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
groups: None,
|
||||
};
|
||||
table.common.call_graphql::<GetGroupList, _>(
|
||||
get_group_list::Variables {},
|
||||
Msg::ListGroupsResponse,
|
||||
"Error trying to fetch groups",
|
||||
);
|
||||
table
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
html! {
|
||||
<div>
|
||||
{self.view_groups()}
|
||||
{self.view_errors()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GroupTable {
|
||||
fn view_groups(&self) -> Html {
|
||||
let make_table = |groups: &Vec<Group>| {
|
||||
html! {
|
||||
@@ -116,7 +97,8 @@ impl GroupTable {
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{"Groups"}</th>
|
||||
<th>{"Group name"}</th>
|
||||
<th>{"Creation date"}</th>
|
||||
<th>{"Delete"}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -141,18 +123,21 @@ impl GroupTable {
|
||||
{&group.display_name}
|
||||
</Link>
|
||||
</td>
|
||||
<td>
|
||||
{&group.creation_date.date().naive_local()}
|
||||
</td>
|
||||
<td>
|
||||
<DeleteGroup
|
||||
group=group.clone()
|
||||
on_group_deleted=self.link.callback(Msg::OnGroupDeleted)
|
||||
on_error=self.link.callback(Msg::OnError)/>
|
||||
on_group_deleted=self.common.callback(Msg::OnGroupDeleted)
|
||||
on_error=self.common.callback(Msg::OnError)/>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
|
||||
fn view_errors(&self) -> Html {
|
||||
match &self.error {
|
||||
match &self.common.error {
|
||||
None => html! {},
|
||||
Some(e) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
||||
}
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
use crate::infra::api::HostService;
|
||||
use crate::{
|
||||
components::router::{AppRoute, NavButton},
|
||||
infra::{
|
||||
api::HostService,
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
},
|
||||
};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use lldap_auth::*;
|
||||
use validator_derive::Validate;
|
||||
use yew::{
|
||||
prelude::*,
|
||||
services::{fetch::FetchTask, ConsoleService},
|
||||
};
|
||||
use yew::{prelude::*, services::ConsoleService};
|
||||
use yew_form::Form;
|
||||
use yew_form_derive::Model;
|
||||
|
||||
pub struct LoginForm {
|
||||
link: ComponentLink<Self>,
|
||||
on_logged_in: Callback<(String, bool)>,
|
||||
error: Option<anyhow::Error>,
|
||||
common: CommonComponentParts<Self>,
|
||||
form: Form<FormModel>,
|
||||
// Used to keep the request alive long enough.
|
||||
task: Option<FetchTask>,
|
||||
refreshing: bool,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
@@ -35,6 +35,7 @@ pub struct Props {
|
||||
pub enum Msg {
|
||||
Update,
|
||||
Submit,
|
||||
AuthenticationRefreshResponse(Result<(String, bool)>),
|
||||
AuthenticationStartResponse(
|
||||
(
|
||||
opaque::client::login::ClientLogin,
|
||||
@@ -44,8 +45,8 @@ pub enum Msg {
|
||||
AuthenticationFinishResponse(Result<(String, bool)>),
|
||||
}
|
||||
|
||||
impl LoginForm {
|
||||
fn handle_message(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
impl CommonComponent<LoginForm> for LoginForm {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::Update => Ok(true),
|
||||
Msg::Submit => {
|
||||
@@ -61,11 +62,10 @@ impl LoginForm {
|
||||
username,
|
||||
login_start_request: message,
|
||||
};
|
||||
self.task = Some(HostService::login_start(
|
||||
req,
|
||||
self.link
|
||||
.callback_once(move |r| Msg::AuthenticationStartResponse((state, r))),
|
||||
)?);
|
||||
self.common
|
||||
.call_backend(HostService::login_start, req, move |r| {
|
||||
Msg::AuthenticationStartResponse((state, r))
|
||||
})?;
|
||||
Ok(true)
|
||||
}
|
||||
Msg::AuthenticationStartResponse((login_start, res)) => {
|
||||
@@ -77,7 +77,8 @@ impl LoginForm {
|
||||
// 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));
|
||||
self.error = Some(anyhow!("Invalid username or password"));
|
||||
self.common.error = Some(anyhow!("Invalid username or password"));
|
||||
self.common.cancel_task();
|
||||
return Ok(true);
|
||||
}
|
||||
Ok(l) => l,
|
||||
@@ -86,20 +87,34 @@ impl LoginForm {
|
||||
server_data: res.server_data,
|
||||
credential_finalization: login_finish.message,
|
||||
};
|
||||
self.task = Some(HostService::login_finish(
|
||||
self.common.call_backend(
|
||||
HostService::login_finish,
|
||||
req,
|
||||
self.link.callback_once(Msg::AuthenticationFinishResponse),
|
||||
)?);
|
||||
Msg::AuthenticationFinishResponse,
|
||||
)?;
|
||||
Ok(false)
|
||||
}
|
||||
Msg::AuthenticationFinishResponse(user_info) => {
|
||||
self.task = None;
|
||||
self.on_logged_in
|
||||
self.common.cancel_task();
|
||||
self.common
|
||||
.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);
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for LoginForm {
|
||||
@@ -107,85 +122,96 @@ impl Component for LoginForm {
|
||||
type Properties = Props;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
LoginForm {
|
||||
link,
|
||||
on_logged_in: props.on_logged_in,
|
||||
error: None,
|
||||
let mut app = LoginForm {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
form: Form::<FormModel>::new(FormModel::default()),
|
||||
task: None,
|
||||
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
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
self.error = None;
|
||||
match self.handle_message(msg) {
|
||||
Err(e) => {
|
||||
ConsoleService::error(&e.to_string());
|
||||
self.error = Some(e);
|
||||
self.task = None;
|
||||
true
|
||||
}
|
||||
Ok(b) => b,
|
||||
}
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||
false
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
type Field = yew_form::Field<FormModel>;
|
||||
html! {
|
||||
<form
|
||||
class="form center-block col-sm-4 col-offset-4">
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text">
|
||||
<i class="bi-person-fill"/>
|
||||
</span>
|
||||
if self.refreshing {
|
||||
html! {
|
||||
<div>
|
||||
<img src={"spinner.gif"} alt={"Loading"} />
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<form
|
||||
class="form center-block col-sm-4 col-offset-4">
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text">
|
||||
<i class="bi-person-fill"/>
|
||||
</span>
|
||||
</div>
|
||||
<Field
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
form=&self.form
|
||||
field_name="username"
|
||||
placeholder="Username"
|
||||
autocomplete="username"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
</div>
|
||||
<Field
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
form=&self.form
|
||||
field_name="username"
|
||||
placeholder="Username"
|
||||
autocomplete="username"
|
||||
oninput=self.link.callback(|_| Msg::Update) />
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text">
|
||||
<i class="bi-lock-fill"/>
|
||||
</span>
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text">
|
||||
<i class="bi-lock-fill"/>
|
||||
</span>
|
||||
</div>
|
||||
<Field
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
form=&self.form
|
||||
field_name="password"
|
||||
input_type="password"
|
||||
placeholder="Password"
|
||||
autocomplete="current-password" />
|
||||
</div>
|
||||
<Field
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
form=&self.form
|
||||
field_name="password"
|
||||
input_type="password"
|
||||
placeholder="Password"
|
||||
autocomplete="current-password" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
disabled=self.task.is_some()
|
||||
onclick=self.link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
|
||||
{"Login"}
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
{ if let Some(e) = &self.error {
|
||||
html! { e.to_string() }
|
||||
} else { html! {} }
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
<div class="form-group mt-3">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
|
||||
{"Login"}
|
||||
</button>
|
||||
<NavButton
|
||||
classes="btn-link btn"
|
||||
disabled=self.common.is_task_running()
|
||||
route=AppRoute::StartResetPassword>
|
||||
{"Forgot your password?"}
|
||||
</NavButton>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
{ if let Some(e) = &self.common.error {
|
||||
html! { e.to_string() }
|
||||
} else { html! {} }
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
use crate::infra::{api::HostService, cookies::delete_cookie};
|
||||
use crate::infra::{
|
||||
api::HostService,
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
cookies::delete_cookie,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use yew::prelude::*;
|
||||
use yew::services::{fetch::FetchTask, ConsoleService};
|
||||
|
||||
pub struct LogoutButton {
|
||||
link: ComponentLink<Self>,
|
||||
on_logged_out: Callback<()>,
|
||||
// Used to keep the request alive long enough.
|
||||
_task: Option<FetchTask>,
|
||||
common: CommonComponentParts<Self>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Properties)]
|
||||
@@ -20,54 +20,50 @@ pub enum Msg {
|
||||
LogoutCompleted(Result<()>),
|
||||
}
|
||||
|
||||
impl CommonComponent<LogoutButton> for LogoutButton {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::LogoutRequested => {
|
||||
self.common
|
||||
.call_backend(HostService::logout, (), Msg::LogoutCompleted)?;
|
||||
}
|
||||
Msg::LogoutCompleted(res) => {
|
||||
res?;
|
||||
delete_cookie("user_id")?;
|
||||
self.common.on_logged_out.emit(());
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for LogoutButton {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
LogoutButton {
|
||||
link,
|
||||
on_logged_out: props.on_logged_out,
|
||||
_task: None,
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
match msg {
|
||||
Msg::LogoutRequested => {
|
||||
match HostService::logout(self.link.callback(Msg::LogoutCompleted)) {
|
||||
Ok(task) => self._task = Some(task),
|
||||
Err(e) => ConsoleService::error(&e.to_string()),
|
||||
};
|
||||
false
|
||||
}
|
||||
Msg::LogoutCompleted(res) => {
|
||||
if let Err(e) = res {
|
||||
ConsoleService::error(&e.to_string());
|
||||
}
|
||||
match delete_cookie("user_id") {
|
||||
Err(e) => {
|
||||
ConsoleService::error(&e.to_string());
|
||||
false
|
||||
}
|
||||
Ok(()) => {
|
||||
self.on_logged_out.emit(());
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||
false
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
html! {
|
||||
<button
|
||||
class="dropdown-item"
|
||||
onclick=self.link.callback(|_| Msg::LogoutRequested)>
|
||||
onclick=self.common.callback(|_| Msg::LogoutRequested)>
|
||||
{"Logout"}
|
||||
</button>
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ pub mod group_table;
|
||||
pub mod login;
|
||||
pub mod logout;
|
||||
pub mod remove_user_from_group;
|
||||
pub mod reset_password_step1;
|
||||
pub mod reset_password_step2;
|
||||
pub mod router;
|
||||
pub mod select;
|
||||
pub mod user_details;
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
use crate::infra::api::HostService;
|
||||
use crate::infra::common_component::{CommonComponent, CommonComponentParts};
|
||||
use anyhow::{Error, Result};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use yew::{
|
||||
prelude::*,
|
||||
services::{fetch::FetchTask, ConsoleService},
|
||||
};
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
@@ -17,10 +14,7 @@ use yew::{
|
||||
pub struct RemoveUserFromGroup;
|
||||
|
||||
pub struct RemoveUserFromGroupComponent {
|
||||
link: ComponentLink<Self>,
|
||||
props: Props,
|
||||
// Used to keep the request alive long enough.
|
||||
task: Option<FetchTask>,
|
||||
common: CommonComponentParts<Self>,
|
||||
}
|
||||
|
||||
#[derive(yew::Properties, Clone, PartialEq)]
|
||||
@@ -36,38 +30,37 @@ pub enum Msg {
|
||||
RemoveGroupResponse(Result<remove_user_from_group::ResponseData>),
|
||||
}
|
||||
|
||||
impl RemoveUserFromGroupComponent {
|
||||
fn submit_remove_group(&mut self) -> Result<bool> {
|
||||
let group = self.props.group_id;
|
||||
self.task = HostService::graphql_query::<RemoveUserFromGroup>(
|
||||
remove_user_from_group::Variables {
|
||||
user: self.props.username.clone(),
|
||||
group,
|
||||
},
|
||||
self.link.callback(Msg::RemoveGroupResponse),
|
||||
"Error trying to initiate removing the user from a group",
|
||||
)
|
||||
.map_err(|e| {
|
||||
ConsoleService::log(&e.to_string());
|
||||
e
|
||||
})
|
||||
.ok();
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
impl CommonComponent<RemoveUserFromGroupComponent> for RemoveUserFromGroupComponent {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::SubmitRemoveGroup => return self.submit_remove_group(),
|
||||
Msg::SubmitRemoveGroup => self.submit_remove_group(),
|
||||
Msg::RemoveGroupResponse(response) => {
|
||||
response?;
|
||||
self.task = None;
|
||||
self.props
|
||||
self.common.cancel_task();
|
||||
self.common
|
||||
.on_user_removed_from_group
|
||||
.emit((self.props.username.clone(), self.props.group_id));
|
||||
.emit((self.common.username.clone(), self.common.group_id));
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoveUserFromGroupComponent {
|
||||
fn submit_remove_group(&mut self) {
|
||||
self.common.call_graphql::<RemoveUserFromGroup, _>(
|
||||
remove_user_from_group::Variables {
|
||||
user: self.common.username.clone(),
|
||||
group: self.common.group_id,
|
||||
},
|
||||
Msg::RemoveGroupResponse,
|
||||
"Error trying to initiate removing the user from a group",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for RemoveUserFromGroupComponent {
|
||||
@@ -76,33 +69,28 @@ impl Component for RemoveUserFromGroupComponent {
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
Self {
|
||||
link,
|
||||
props,
|
||||
task: None,
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
match self.handle_msg(msg) {
|
||||
Err(e) => {
|
||||
self.task = None;
|
||||
self.props.on_error.emit(e);
|
||||
true
|
||||
}
|
||||
Ok(b) => b,
|
||||
}
|
||||
CommonComponentParts::<Self>::update_and_report_error(
|
||||
self,
|
||||
msg,
|
||||
self.common.on_error.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||
false
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
html! {
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
disabled=self.task.is_some()
|
||||
onclick=self.link.callback(|_| Msg::SubmitRemoveGroup)>
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|_| Msg::SubmitRemoveGroup)>
|
||||
<i class="bi-x-circle-fill" aria-label="Remove user from group" />
|
||||
</button>
|
||||
}
|
||||
|
||||
140
app/src/components/reset_password_step1.rs
Normal file
140
app/src/components/reset_password_step1.rs
Normal file
@@ -0,0 +1,140 @@
|
||||
use crate::{
|
||||
components::router::{AppRoute, NavButton},
|
||||
infra::{
|
||||
api::HostService,
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
},
|
||||
};
|
||||
use anyhow::{bail, Result};
|
||||
use validator_derive::Validate;
|
||||
use yew::prelude::*;
|
||||
use yew_form::Form;
|
||||
use yew_form_derive::Model;
|
||||
|
||||
pub struct ResetPasswordStep1Form {
|
||||
common: CommonComponentParts<Self>,
|
||||
form: Form<FormModel>,
|
||||
just_succeeded: bool,
|
||||
}
|
||||
|
||||
/// The fields of the form, with the constraints.
|
||||
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
|
||||
pub struct FormModel {
|
||||
#[validate(length(min = 1, message = "Missing username"))]
|
||||
username: String,
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
Update,
|
||||
Submit,
|
||||
PasswordResetResponse(Result<()>),
|
||||
}
|
||||
|
||||
impl CommonComponent<ResetPasswordStep1Form> for ResetPasswordStep1Form {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::Update => Ok(true),
|
||||
Msg::Submit => {
|
||||
if !self.form.validate() {
|
||||
bail!("Check the form for errors");
|
||||
}
|
||||
let FormModel { username } = self.form.model();
|
||||
self.common.call_backend(
|
||||
HostService::reset_password_step1,
|
||||
&username,
|
||||
Msg::PasswordResetResponse,
|
||||
)?;
|
||||
Ok(true)
|
||||
}
|
||||
Msg::PasswordResetResponse(response) => {
|
||||
response?;
|
||||
self.just_succeeded = true;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for ResetPasswordStep1Form {
|
||||
type Message = Msg;
|
||||
type Properties = ();
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
ResetPasswordStep1Form {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
form: Form::<FormModel>::new(FormModel::default()),
|
||||
just_succeeded: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
self.just_succeeded = false;
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
type Field = yew_form::Field<FormModel>;
|
||||
html! {
|
||||
<form
|
||||
class="form center-block col-sm-4 col-offset-4">
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text">
|
||||
<i class="bi-person-fill"/>
|
||||
</span>
|
||||
</div>
|
||||
<Field
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
form=&self.form
|
||||
field_name="username"
|
||||
placeholder="Username"
|
||||
autocomplete="username"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
</div>
|
||||
{ if self.just_succeeded {
|
||||
html! {
|
||||
{"A reset token has been sent to your email."}
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<div class="form-group mt-3">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
|
||||
{"Reset password"}
|
||||
</button>
|
||||
<NavButton
|
||||
classes="btn-link btn"
|
||||
disabled=self.common.is_task_running()
|
||||
route=AppRoute::Login>
|
||||
{"Back"}
|
||||
</NavButton>
|
||||
</div>
|
||||
}
|
||||
}}
|
||||
<div class="form-group">
|
||||
{ if let Some(e) = &self.common.error {
|
||||
html! {
|
||||
<div class="alert alert-danger">
|
||||
{e.to_string() }
|
||||
</div>
|
||||
}
|
||||
} else { html! {} }
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
}
|
||||
}
|
||||
235
app/src/components/reset_password_step2.rs
Normal file
235
app/src/components/reset_password_step2.rs
Normal file
@@ -0,0 +1,235 @@
|
||||
use crate::{
|
||||
components::router::AppRoute,
|
||||
infra::{
|
||||
api::HostService,
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
},
|
||||
};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use lldap_auth::{
|
||||
opaque::client::registration as opaque_registration,
|
||||
password_reset::ServerPasswordResetResponse, registration,
|
||||
};
|
||||
use validator_derive::Validate;
|
||||
use yew::prelude::*;
|
||||
use yew_form::Form;
|
||||
use yew_form_derive::Model;
|
||||
use yew_router::{
|
||||
agent::{RouteAgentDispatcher, RouteRequest},
|
||||
route::Route,
|
||||
};
|
||||
|
||||
/// The fields of the form, with the constraints.
|
||||
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
|
||||
pub struct FormModel {
|
||||
#[validate(length(min = 8, message = "Invalid password. Min length: 8"))]
|
||||
password: String,
|
||||
#[validate(must_match(other = "password", message = "Passwords must match"))]
|
||||
confirm_password: String,
|
||||
}
|
||||
|
||||
pub struct ResetPasswordStep2Form {
|
||||
common: CommonComponentParts<Self>,
|
||||
form: Form<FormModel>,
|
||||
username: Option<String>,
|
||||
opaque_data: Option<opaque_registration::ClientRegistration>,
|
||||
route_dispatcher: RouteAgentDispatcher,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Properties)]
|
||||
pub struct Props {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
ValidateTokenResponse(Result<ServerPasswordResetResponse>),
|
||||
FormUpdate,
|
||||
Submit,
|
||||
RegistrationStartResponse(Result<Box<registration::ServerRegistrationStartResponse>>),
|
||||
RegistrationFinishResponse(Result<()>),
|
||||
}
|
||||
|
||||
impl CommonComponent<ResetPasswordStep2Form> for ResetPasswordStep2Form {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::ValidateTokenResponse(response) => {
|
||||
self.username = Some(response?.user_id);
|
||||
self.common.cancel_task();
|
||||
Ok(true)
|
||||
}
|
||||
Msg::FormUpdate => Ok(true),
|
||||
Msg::Submit => {
|
||||
if !self.form.validate() {
|
||||
bail!("Check the form for errors");
|
||||
}
|
||||
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)
|
||||
.context("Could not initiate password change")?;
|
||||
let req = registration::ClientRegistrationStartRequest {
|
||||
username: self.username.clone().unwrap(),
|
||||
registration_start_request: registration_start_request.message,
|
||||
};
|
||||
self.opaque_data = Some(registration_start_request.state);
|
||||
self.common.call_backend(
|
||||
HostService::register_start,
|
||||
req,
|
||||
Msg::RegistrationStartResponse,
|
||||
)?;
|
||||
Ok(true)
|
||||
}
|
||||
Msg::RegistrationStartResponse(res) => {
|
||||
let res = res.context("Could not initiate password change")?;
|
||||
let registration = self.opaque_data.take().expect("Missing registration data");
|
||||
let mut rng = rand::rngs::OsRng;
|
||||
let registration_finish = opaque_registration::finish_registration(
|
||||
registration,
|
||||
res.registration_response,
|
||||
&mut rng,
|
||||
)
|
||||
.context("Error during password change")?;
|
||||
let req = registration::ClientRegistrationFinishRequest {
|
||||
server_data: res.server_data,
|
||||
registration_upload: registration_finish.message,
|
||||
};
|
||||
self.common.call_backend(
|
||||
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)));
|
||||
}
|
||||
response?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for ResetPasswordStep2Form {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
let mut component = ResetPasswordStep2Form {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
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();
|
||||
component
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
match (&self.username, &self.common.error) {
|
||||
(None, None) => {
|
||||
return html! {
|
||||
{"Validating token"}
|
||||
}
|
||||
}
|
||||
(None, Some(e)) => {
|
||||
return html! {
|
||||
<div class="alert alert-danger">
|
||||
{e.to_string() }
|
||||
</div>
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
};
|
||||
type Field = yew_form::Field<FormModel>;
|
||||
html! {
|
||||
<>
|
||||
<h2>{"Reset your password"}</h2>
|
||||
<form
|
||||
class="form">
|
||||
<div class="form-group row">
|
||||
<label for="new_password"
|
||||
class="form-label col-sm-2 col-form-label">
|
||||
{"New password*:"}
|
||||
</label>
|
||||
<div class="col-sm-10">
|
||||
<Field
|
||||
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) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("password")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<label for="confirm_password"
|
||||
class="form-label col-sm-2 col-form-label">
|
||||
{"Confirm password*:"}
|
||||
</label>
|
||||
<div class="col-sm-10">
|
||||
<Field
|
||||
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) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("confirm_password")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mt-2">
|
||||
<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})>
|
||||
{"Submit"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{ if let Some(e) = &self.common.error {
|
||||
html! {
|
||||
<div class="alert alert-danger">
|
||||
{e.to_string() }
|
||||
</div>
|
||||
}
|
||||
} else { html! {} }
|
||||
}
|
||||
</>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,10 @@ use yew_router::{
|
||||
pub enum AppRoute {
|
||||
#[to = "/login"]
|
||||
Login,
|
||||
#[to = "/reset-password/step1"]
|
||||
StartResetPassword,
|
||||
#[to = "/reset-password/step2/{token}"]
|
||||
FinishResetPassword(String),
|
||||
#[to = "/users/create"]
|
||||
CreateUser,
|
||||
#[to = "/users"]
|
||||
|
||||
@@ -81,7 +81,7 @@ 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,
|
||||
|
||||
@@ -5,14 +5,11 @@ use crate::{
|
||||
router::{AppRoute, Link, NavButton},
|
||||
user_details_form::UserDetailsForm,
|
||||
},
|
||||
infra::api::HostService,
|
||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||
};
|
||||
use anyhow::{bail, Error, Result};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use yew::{
|
||||
prelude::*,
|
||||
services::{fetch::FetchTask, ConsoleService},
|
||||
};
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
@@ -27,15 +24,10 @@ pub type User = get_user_details::GetUserDetailsUser;
|
||||
pub type Group = get_user_details::GetUserDetailsUserGroups;
|
||||
|
||||
pub struct UserDetails {
|
||||
link: ComponentLink<Self>,
|
||||
props: Props,
|
||||
common: CommonComponentParts<Self>,
|
||||
/// The user info. If none, the error is in `error`. If `error` is None, then we haven't
|
||||
/// received the server response yet.
|
||||
user: Option<User>,
|
||||
/// Error message displayed to the user.
|
||||
error: Option<Error>,
|
||||
// Used to keep the request alive long enough.
|
||||
_task: Option<FetchTask>,
|
||||
}
|
||||
|
||||
/// State machine describing the possible transitions of the component state.
|
||||
@@ -48,28 +40,13 @@ 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 UserDetails {
|
||||
fn get_user_details(&mut self) {
|
||||
self._task = HostService::graphql_query::<GetUserDetails>(
|
||||
get_user_details::Variables {
|
||||
id: self.props.username.clone(),
|
||||
},
|
||||
self.link.callback(Msg::UserDetailsResponse),
|
||||
"Error trying to fetch user details",
|
||||
)
|
||||
.map_err(|e| {
|
||||
ConsoleService::log(&e.to_string());
|
||||
e
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
impl CommonComponent<UserDetails> for UserDetails {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::UserDetailsResponse(response) => match response {
|
||||
@@ -94,6 +71,22 @@ impl UserDetails {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl UserDetails {
|
||||
fn get_user_details(&mut self) {
|
||||
self.common.call_graphql::<GetUserDetails, _>(
|
||||
get_user_details::Variables {
|
||||
id: self.common.username.clone(),
|
||||
},
|
||||
Msg::UserDetailsResponse,
|
||||
"Error trying to fetch user details",
|
||||
);
|
||||
}
|
||||
|
||||
fn view_messages(&self, error: &Option<Error>) -> Html {
|
||||
if let Some(e) = error {
|
||||
html! {
|
||||
@@ -111,7 +104,7 @@ impl UserDetails {
|
||||
let display_name = group.display_name.clone();
|
||||
html! {
|
||||
<tr key="groupRow_".to_string() + &display_name>
|
||||
{if self.props.is_admin { html! {
|
||||
{if self.common.is_admin { html! {
|
||||
<>
|
||||
<td>
|
||||
<Link route=AppRoute::GroupDetails(group.id)>
|
||||
@@ -122,8 +115,8 @@ impl UserDetails {
|
||||
<RemoveUserFromGroupComponent
|
||||
username=u.id.clone()
|
||||
group_id=group.id
|
||||
on_user_removed_from_group=self.link.callback(Msg::OnUserRemovedFromGroup)
|
||||
on_error=self.link.callback(Msg::OnError)/>
|
||||
on_user_removed_from_group=self.common.callback(Msg::OnUserRemovedFromGroup)
|
||||
on_error=self.common.callback(Msg::OnError)/>
|
||||
</td>
|
||||
</>
|
||||
} } else { html! {
|
||||
@@ -140,7 +133,7 @@ impl UserDetails {
|
||||
<thead>
|
||||
<tr key="headerRow">
|
||||
<th>{"Group"}</th>
|
||||
{ if self.props.is_admin { html!{ <th></th> }} else { html!{} }}
|
||||
{ if self.common.is_admin { html!{ <th></th> }} else { html!{} }}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -161,13 +154,13 @@ impl UserDetails {
|
||||
}
|
||||
|
||||
fn view_add_group_button(&self, u: &User) -> Html {
|
||||
if self.props.is_admin {
|
||||
if self.common.is_admin {
|
||||
html! {
|
||||
<AddUserToGroupComponent
|
||||
username=u.id.clone()
|
||||
groups=u.groups.clone()
|
||||
on_error=self.link.callback(Msg::OnError)
|
||||
on_user_added_to_group=self.link.callback(Msg::OnUserAddedToGroup)/>
|
||||
on_error=self.common.callback(Msg::OnError)
|
||||
on_user_added_to_group=self.common.callback(Msg::OnUserAddedToGroup)/>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
@@ -181,34 +174,23 @@ impl Component for UserDetails {
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
let mut table = Self {
|
||||
link,
|
||||
props,
|
||||
_task: None,
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
user: None,
|
||||
error: None,
|
||||
};
|
||||
table.get_user_details();
|
||||
table
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
self.error = None;
|
||||
match self.handle_msg(msg) {
|
||||
Err(e) => {
|
||||
ConsoleService::error(&e.to_string());
|
||||
self.error = Some(e);
|
||||
true
|
||||
}
|
||||
Ok(b) => b,
|
||||
}
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||
false
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
match (&self.user, &self.error) {
|
||||
match (&self.user, &self.common.error) {
|
||||
(None, None) => html! {{"Loading..."}},
|
||||
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
||||
(Some(u), error) => {
|
||||
@@ -216,8 +198,7 @@ impl Component for UserDetails {
|
||||
<>
|
||||
<h3>{u.id.to_string()}</h3>
|
||||
<UserDetailsForm
|
||||
user=u.clone()
|
||||
on_error=self.link.callback(Msg::OnError)/>
|
||||
user=u.clone() />
|
||||
<div class="row justify-content-center">
|
||||
<NavButton
|
||||
route=AppRoute::ChangePassword(u.id.clone())
|
||||
|
||||
@@ -1,15 +1,45 @@
|
||||
use crate::{components::user_details::User, infra::api::HostService};
|
||||
use std::str::FromStr;
|
||||
|
||||
use crate::{
|
||||
components::user_details::User,
|
||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||
};
|
||||
use anyhow::{bail, Error, Result};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use validator_derive::Validate;
|
||||
use yew::{
|
||||
prelude::*,
|
||||
services::{fetch::FetchTask, ConsoleService},
|
||||
};
|
||||
use wasm_bindgen::JsCast;
|
||||
use yew::{prelude::*, services::ConsoleService};
|
||||
use yew_form_derive::Model;
|
||||
|
||||
#[derive(PartialEq, Eq, Clone, Default)]
|
||||
struct JsFile {
|
||||
file: Option<web_sys::File>,
|
||||
contents: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl ToString for JsFile {
|
||||
fn to_string(&self) -> String {
|
||||
self.file
|
||||
.as_ref()
|
||||
.map(web_sys::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,
|
||||
@@ -25,19 +55,18 @@ 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;
|
||||
|
||||
/// A [yew::Component] to display the user details, with a form allowing to edit them.
|
||||
pub struct UserDetailsForm {
|
||||
link: ComponentLink<Self>,
|
||||
props: Props,
|
||||
common: CommonComponentParts<Self>,
|
||||
form: yew_form::Form<UserModel>,
|
||||
avatar: JsFile,
|
||||
/// True if we just successfully updated the user, to display a success message.
|
||||
just_updated: bool,
|
||||
task: Option<FetchTask>,
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
@@ -45,16 +74,74 @@ pub enum Msg {
|
||||
Update,
|
||||
/// The "Submit" button was clicked.
|
||||
SubmitClicked,
|
||||
/// A picked file finished loading.
|
||||
FileLoaded(yew::services::reader::FileData),
|
||||
/// 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> {
|
||||
match msg {
|
||||
Msg::Update => {
|
||||
let window = web_sys::window().expect("no global `window` exists");
|
||||
let document = window.document().expect("should have a document on window");
|
||||
let input = document
|
||||
.get_element_by_id("avatarInput")
|
||||
.expect("Form field avatarInput should be present")
|
||||
.dyn_into::<web_sys::HtmlInputElement>()
|
||||
.expect("Should be an HtmlInputElement");
|
||||
ConsoleService::log("Form update");
|
||||
if let Some(files) = input.files() {
|
||||
ConsoleService::log("Got file list");
|
||||
if files.length() > 0 {
|
||||
ConsoleService::log("Got a file");
|
||||
let new_avatar = JsFile {
|
||||
file: files.item(0),
|
||||
contents: None,
|
||||
};
|
||||
if self.avatar.file.as_ref().map(|f| f.name())
|
||||
!= new_avatar.file.as_ref().map(|f| f.name())
|
||||
{
|
||||
if let Some(ref file) = new_avatar.file {
|
||||
self.mut_common().read_file(file.clone(), Msg::FileLoaded)?;
|
||||
}
|
||||
self.avatar = new_avatar;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
Msg::SubmitClicked => self.submit_user_update_form(),
|
||||
Msg::UserUpdated(response) => self.user_update_finished(response),
|
||||
Msg::FileLoaded(data) => {
|
||||
self.common.cancel_task();
|
||||
if let Some(file) = &self.avatar.file {
|
||||
if file.name() == data.name {
|
||||
if !is_valid_jpeg(data.content.as_slice()) {
|
||||
// Clear the selection.
|
||||
self.avatar = JsFile::default();
|
||||
bail!("Chosen image is not a valid JPEG");
|
||||
} else {
|
||||
self.avatar.contents = Some(data.content);
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for UserDetailsForm {
|
||||
@@ -69,33 +156,27 @@ impl Component for UserDetailsForm {
|
||||
last_name: props.user.last_name.clone(),
|
||||
};
|
||||
Self {
|
||||
link,
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
form: yew_form::Form::new(model),
|
||||
props,
|
||||
avatar: JsFile::default(),
|
||||
just_updated: false,
|
||||
task: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
self.just_updated = false;
|
||||
match self.handle_msg(msg) {
|
||||
Err(e) => {
|
||||
ConsoleService::error(&e.to_string());
|
||||
self.props.on_error.emit(e);
|
||||
self.task = None;
|
||||
true
|
||||
}
|
||||
Ok(b) => b,
|
||||
}
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||
false
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
type Field = yew_form::Field<UserModel>;
|
||||
|
||||
let avatar_base64 = maybe_to_base64(&self.avatar).unwrap_or_default();
|
||||
let avatar_string = avatar_base64.as_ref().unwrap_or(&self.common.user.avatar);
|
||||
html! {
|
||||
<div class="py-3">
|
||||
<form class="form">
|
||||
@@ -105,7 +186,24 @@ impl Component for UserDetailsForm {
|
||||
{"User ID: "}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<span id="userId" class="form-constrol-static">{&self.props.user.id}</span>
|
||||
<span id="userId" class="form-constrol-static"><b>{&self.common.user.id}</b></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<div class="col-4 col-form-label">
|
||||
<img
|
||||
id="avatarDisplay"
|
||||
src={format!("data:image/jpeg;base64, {}", avatar_string)}
|
||||
style="max-height:128px;max-width:128px;height:auto;width:auto;"
|
||||
alt="Avatar" />
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<input
|
||||
class="form-control"
|
||||
id="avatarInput"
|
||||
type="file"
|
||||
accept="image/jpeg"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
@@ -121,7 +219,7 @@ impl Component for UserDetailsForm {
|
||||
form=&self.form
|
||||
field_name="email"
|
||||
autocomplete="email"
|
||||
oninput=self.link.callback(|_| Msg::Update) />
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("email")}
|
||||
</div>
|
||||
@@ -140,7 +238,7 @@ impl Component for UserDetailsForm {
|
||||
form=&self.form
|
||||
field_name="display_name"
|
||||
autocomplete="name"
|
||||
oninput=self.link.callback(|_| Msg::Update) />
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("display_name")}
|
||||
</div>
|
||||
@@ -157,7 +255,7 @@ impl Component for UserDetailsForm {
|
||||
form=&self.form
|
||||
field_name="first_name"
|
||||
autocomplete="given-name"
|
||||
oninput=self.link.callback(|_| Msg::Update) />
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("first_name")}
|
||||
</div>
|
||||
@@ -174,7 +272,7 @@ impl Component for UserDetailsForm {
|
||||
form=&self.form
|
||||
field_name="last_name"
|
||||
autocomplete="family-name"
|
||||
oninput=self.link.callback(|_| Msg::Update) />
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("last_name")}
|
||||
</div>
|
||||
@@ -186,19 +284,36 @@ impl Component for UserDetailsForm {
|
||||
{"Creation date: "}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<span id="creationDate" class="form-constrol-static">{&self.props.user.creation_date.date().naive_local()}</span>
|
||||
<span id="creationDate" class="form-constrol-static">{&self.common.user.creation_date.date().naive_local()}</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-constrol-static">{&self.common.user.uuid}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row justify-content-center">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary col-auto col-form-label"
|
||||
disabled=self.task.is_some()
|
||||
onclick=self.link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitClicked})>
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitClicked})>
|
||||
{"Update"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{ if let Some(e) = &self.common.error {
|
||||
html! {
|
||||
<div class="alert alert-danger">
|
||||
{e.to_string() }
|
||||
</div>
|
||||
}
|
||||
} else { html! {} }
|
||||
}
|
||||
<div hidden=!self.just_updated>
|
||||
<span>{"User successfully updated!"}</span>
|
||||
</div>
|
||||
@@ -208,25 +323,28 @@ impl Component for UserDetailsForm {
|
||||
}
|
||||
|
||||
impl UserDetailsForm {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::Update => Ok(true),
|
||||
Msg::SubmitClicked => self.submit_user_update_form(),
|
||||
Msg::UserUpdated(response) => self.user_update_finished(response),
|
||||
}
|
||||
}
|
||||
|
||||
fn submit_user_update_form(&mut self) -> Result<bool> {
|
||||
ConsoleService::log("Submit");
|
||||
if !self.form.validate() {
|
||||
bail!("Invalid inputs");
|
||||
}
|
||||
let base_user = &self.props.user;
|
||||
ConsoleService::log("Valid inputs");
|
||||
if let JsFile {
|
||||
file: Some(_),
|
||||
contents: None,
|
||||
} = &self.avatar
|
||||
{
|
||||
bail!("Image file hasn't finished loading, try again");
|
||||
}
|
||||
ConsoleService::log("File is correctly loaded");
|
||||
let base_user = &self.common.user;
|
||||
let mut user_input = update_user::UpdateUserInput {
|
||||
id: self.props.user.id.clone(),
|
||||
id: self.common.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();
|
||||
@@ -243,37 +361,66 @@ impl UserDetailsForm {
|
||||
if base_user.last_name != model.last_name {
|
||||
user_input.lastName = Some(model.last_name);
|
||||
}
|
||||
user_input.avatar = maybe_to_base64(&self.avatar)?;
|
||||
// Nothing changed.
|
||||
if user_input == default_user_input {
|
||||
ConsoleService::log("No changes");
|
||||
return Ok(false);
|
||||
}
|
||||
let req = update_user::Variables { user: user_input };
|
||||
self.task = Some(HostService::graphql_query::<UpdateUser>(
|
||||
ConsoleService::log("Querying");
|
||||
self.common.call_graphql::<UpdateUser, _>(
|
||||
req,
|
||||
self.link.callback(Msg::UserUpdated),
|
||||
Msg::UserUpdated,
|
||||
"Error trying to update user",
|
||||
)?);
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn user_update_finished(&mut self, r: Result<update_user::ResponseData>) -> Result<bool> {
|
||||
self.task = None;
|
||||
self.common.cancel_task();
|
||||
match r {
|
||||
Err(e) => return Err(e),
|
||||
Ok(_) => {
|
||||
let model = self.form.model();
|
||||
self.props.user = User {
|
||||
id: self.props.user.id.clone(),
|
||||
email: model.email,
|
||||
display_name: model.display_name,
|
||||
first_name: model.first_name,
|
||||
last_name: model.last_name,
|
||||
creation_date: self.props.user.creation_date,
|
||||
groups: self.props.user.groups.clone(),
|
||||
};
|
||||
self.common.user.email = model.email;
|
||||
self.common.user.display_name = model.display_name;
|
||||
self.common.user.first_name = model.first_name;
|
||||
self.common.user.last_name = model.last_name;
|
||||
if let Some(avatar) = maybe_to_base64(&self.avatar)? {
|
||||
self.common.user.avatar = avatar;
|
||||
}
|
||||
self.just_updated = true;
|
||||
}
|
||||
};
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_valid_jpeg(bytes: &[u8]) -> bool {
|
||||
image::io::Reader::with_format(std::io::Cursor::new(bytes), image::ImageFormat::Jpeg)
|
||||
.decode()
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
fn maybe_to_base64(file: &JsFile) -> Result<Option<String>> {
|
||||
match file {
|
||||
JsFile {
|
||||
file: None,
|
||||
contents: _,
|
||||
} => Ok(None),
|
||||
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(Some(base64::encode(data)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,11 @@ use crate::{
|
||||
delete_user::DeleteUser,
|
||||
router::{AppRoute, Link},
|
||||
},
|
||||
infra::api::HostService,
|
||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||
};
|
||||
use anyhow::{Error, Result};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use yew::prelude::*;
|
||||
use yew::services::{fetch::FetchTask, ConsoleService};
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
@@ -24,11 +23,8 @@ use list_users_query::{RequestFilter, ResponseData};
|
||||
type User = list_users_query::ListUsersQueryUsers;
|
||||
|
||||
pub struct UserTable {
|
||||
link: ComponentLink<Self>,
|
||||
common: CommonComponentParts<Self>,
|
||||
users: Option<Vec<User>>,
|
||||
error: Option<Error>,
|
||||
// Used to keep the request alive long enough.
|
||||
_task: Option<FetchTask>,
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
@@ -37,63 +33,7 @@ pub enum Msg {
|
||||
OnError(Error),
|
||||
}
|
||||
|
||||
impl UserTable {
|
||||
fn get_users(&mut self, req: Option<RequestFilter>) {
|
||||
self._task = HostService::graphql_query::<ListUsersQuery>(
|
||||
list_users_query::Variables { filters: req },
|
||||
self.link.callback(Msg::ListUsersResponse),
|
||||
"Error trying to fetch users",
|
||||
)
|
||||
.map_err(|e| {
|
||||
ConsoleService::log(&e.to_string());
|
||||
e
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for UserTable {
|
||||
type Message = Msg;
|
||||
type Properties = ();
|
||||
|
||||
fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
let mut table = UserTable {
|
||||
link,
|
||||
_task: None,
|
||||
users: None,
|
||||
error: None,
|
||||
};
|
||||
table.get_users(None);
|
||||
table
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
self.error = None;
|
||||
match self.handle_msg(msg) {
|
||||
Err(e) => {
|
||||
ConsoleService::error(&e.to_string());
|
||||
self.error = Some(e);
|
||||
true
|
||||
}
|
||||
Ok(b) => b,
|
||||
}
|
||||
}
|
||||
|
||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||
false
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
html! {
|
||||
<div>
|
||||
{self.view_users()}
|
||||
{self.view_errors()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UserTable {
|
||||
impl CommonComponent<UserTable> for UserTable {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::ListUsersResponse(users) => {
|
||||
@@ -109,6 +49,53 @@ impl UserTable {
|
||||
}
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl UserTable {
|
||||
fn get_users(&mut self, req: Option<RequestFilter>) {
|
||||
self.common.call_graphql::<ListUsersQuery, _>(
|
||||
list_users_query::Variables { filters: req },
|
||||
Msg::ListUsersResponse,
|
||||
"Error trying to fetch users",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for UserTable {
|
||||
type Message = Msg;
|
||||
type Properties = ();
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
let mut table = UserTable {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
users: None,
|
||||
};
|
||||
table.get_users(None);
|
||||
table
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
html! {
|
||||
<div>
|
||||
{self.view_users()}
|
||||
{self.view_errors()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UserTable {
|
||||
fn view_users(&self) -> Html {
|
||||
let make_table = |users: &Vec<User>| {
|
||||
html! {
|
||||
@@ -150,15 +137,15 @@ impl UserTable {
|
||||
<td>
|
||||
<DeleteUser
|
||||
username=user.id.clone()
|
||||
on_user_deleted=self.link.callback(Msg::OnUserDeleted)
|
||||
on_error=self.link.callback(Msg::OnError)/>
|
||||
on_user_deleted=self.common.callback(Msg::OnUserDeleted)
|
||||
on_error=self.common.callback(Msg::OnError)/>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
|
||||
fn view_errors(&self) -> Html {
|
||||
match &self.error {
|
||||
match &self.common.error {
|
||||
None => html! {},
|
||||
Some(e) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
||||
}
|
||||
|
||||
@@ -186,9 +186,13 @@ impl HostService {
|
||||
.context("Error clearing cookie")
|
||||
};
|
||||
let parse_token = move |data: String| {
|
||||
get_claims_from_jwt(&data)
|
||||
serde_json::from_str::<login::ServerLoginResponse>(&data)
|
||||
.context("Could not parse response")
|
||||
.and_then(set_cookies)
|
||||
.and_then(|r| {
|
||||
get_claims_from_jwt(r.token.as_str())
|
||||
.context("Could not parse response")
|
||||
.and_then(set_cookies)
|
||||
})
|
||||
};
|
||||
call_server(
|
||||
"/auth/opaque/login/finish",
|
||||
@@ -223,7 +227,34 @@ impl HostService {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn logout(callback: Callback<Result<()>>) -> Result<FetchTask> {
|
||||
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(
|
||||
"/auth/refresh",
|
||||
yew::format::Nothing,
|
||||
callback,
|
||||
"Could not start authentication: ",
|
||||
parse_token,
|
||||
)
|
||||
}
|
||||
|
||||
// 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,
|
||||
@@ -231,4 +262,28 @@ impl HostService {
|
||||
"Could not logout",
|
||||
)
|
||||
}
|
||||
|
||||
pub fn reset_password_step1(
|
||||
username: &str,
|
||||
callback: Callback<Result<()>>,
|
||||
) -> Result<FetchTask> {
|
||||
call_server_empty_response_with_error_message(
|
||||
&format!("/auth/reset/step1/{}", username),
|
||||
yew::format::Nothing,
|
||||
callback,
|
||||
"Could not initiate password reset",
|
||||
)
|
||||
}
|
||||
|
||||
pub fn reset_password_step2(
|
||||
token: &str,
|
||||
callback: Callback<Result<lldap_auth::password_reset::ServerPasswordResetResponse>>,
|
||||
) -> Result<FetchTask> {
|
||||
call_server_json_with_error_message(
|
||||
&format!("/auth/reset/step2/{}", token),
|
||||
yew::format::Nothing,
|
||||
callback,
|
||||
"Could not validate token",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
212
app/src/infra/common_component.rs
Normal file
212
app/src/infra/common_component.rs
Normal file
@@ -0,0 +1,212 @@
|
||||
//! Common Component module.
|
||||
//! This is used to factor out some common functionality that is recurrent in modules all over the
|
||||
//! application. In particular:
|
||||
//! - error handling
|
||||
//! - task handling
|
||||
//! - storing props
|
||||
//!
|
||||
//! The pattern used is the
|
||||
//! [CRTP](https://en.wikipedia.org/wiki/Curiously_recurring_template_pattern) pattern: The
|
||||
//! [`CommonComponent`] trait must be implemented with `Self` as the parameter, e.g.
|
||||
//!
|
||||
//! ```ignore
|
||||
//! struct MyComponent;
|
||||
//! impl CommonComponent<MyComponent> for MyComponent { ... }
|
||||
//! ```
|
||||
//!
|
||||
//! The component should also have a `CommonComponentParts<Self>` as a field, usually named
|
||||
//! `common`.
|
||||
//!
|
||||
//! Then the [`yew::prelude::Component::update`] method can delegate to
|
||||
//! [`CommonComponentParts::update`]. This will in turn call [`CommonComponent::handle_msg`] and
|
||||
//! take care of error and task handling.
|
||||
|
||||
use crate::infra::api::HostService;
|
||||
use anyhow::{Error, Result};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use yew::{
|
||||
prelude::*,
|
||||
services::{
|
||||
fetch::FetchTask,
|
||||
reader::{FileData, ReaderService, ReaderTask},
|
||||
ConsoleService,
|
||||
},
|
||||
};
|
||||
use yewtil::NeqAssign;
|
||||
|
||||
/// 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>;
|
||||
/// Get a mutable reference to the inner component parts, necessary for the CRTP.
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<C>;
|
||||
}
|
||||
|
||||
enum AnyTask {
|
||||
None,
|
||||
FetchTask(FetchTask),
|
||||
ReaderTask(ReaderTask),
|
||||
}
|
||||
|
||||
impl AnyTask {
|
||||
fn is_some(&self) -> bool {
|
||||
!matches!(self, AnyTask::None)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Option<FetchTask>> for AnyTask {
|
||||
fn from(task: Option<FetchTask>) -> Self {
|
||||
match task {
|
||||
Some(t) => AnyTask::FetchTask(t),
|
||||
None => AnyTask::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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: AnyTask,
|
||||
}
|
||||
|
||||
impl<C: CommonComponent<C>> CommonComponentParts<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 = AnyTask::None;
|
||||
}
|
||||
|
||||
pub fn create(props: <C as Component>::Properties, link: ComponentLink<C>) -> Self {
|
||||
Self {
|
||||
link,
|
||||
props,
|
||||
error: None,
|
||||
task: AnyTask::None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
com.mut_common().error = None;
|
||||
match com.handle_msg(msg) {
|
||||
Err(e) => {
|
||||
ConsoleService::error(&e.to_string());
|
||||
com.mut_common().error = Some(e);
|
||||
com.mut_common().cancel_task();
|
||||
true
|
||||
}
|
||||
Ok(b) => b,
|
||||
}
|
||||
}
|
||||
|
||||
/// Same as above, but the resulting error is instead passed to the reporting function.
|
||||
pub fn update_and_report_error(
|
||||
com: &mut C,
|
||||
msg: <C as Component>::Message,
|
||||
report_fn: Callback<Error>,
|
||||
) -> ShouldRender {
|
||||
let should_render = Self::update(com, msg);
|
||||
com.mut_common()
|
||||
.error
|
||||
.take()
|
||||
.map(|e| {
|
||||
report_fn.emit(e);
|
||||
true
|
||||
})
|
||||
.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<()>
|
||||
where
|
||||
M: Fn(Req, Callback<Resp>) -> Result<FetchTask>,
|
||||
Cb: FnOnce(Resp) -> <C as Component>::Message + 'static,
|
||||
{
|
||||
self.task = AnyTask::FetchTask(method(req, self.link.callback_once(callback))?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Call the backend with a GraphQL query.
|
||||
///
|
||||
/// `EnumCallback` should usually be left as `_`.
|
||||
pub fn call_graphql<QueryType, EnumCallback>(
|
||||
&mut self,
|
||||
variables: QueryType::Variables,
|
||||
enum_callback: EnumCallback,
|
||||
error_message: &'static str,
|
||||
) where
|
||||
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()
|
||||
.into();
|
||||
}
|
||||
|
||||
pub(crate) fn read_file<Cb>(&mut self, file: web_sys::File, callback: Cb) -> Result<()>
|
||||
where
|
||||
Cb: FnOnce(FileData) -> <C as Component>::Message + 'static,
|
||||
{
|
||||
self.task = AnyTask::ReaderTask(ReaderService::read_file(
|
||||
file,
|
||||
self.link.callback_once(callback),
|
||||
)?);
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,7 @@ use web_sys::HtmlDocument;
|
||||
|
||||
fn get_document() -> Result<HtmlDocument> {
|
||||
web_sys::window()
|
||||
.map(|w| w.document())
|
||||
.flatten()
|
||||
.and_then(|w| w.document())
|
||||
.ok_or_else(|| anyhow!("Could not get window document"))
|
||||
.and_then(|d| {
|
||||
d.dyn_into::<web_sys::HtmlDocument>()
|
||||
@@ -16,8 +15,7 @@ fn get_document() -> Result<HtmlDocument> {
|
||||
|
||||
pub fn set_cookie(cookie_name: &str, value: &str, expiration: &DateTime<Utc>) -> Result<()> {
|
||||
let doc = web_sys::window()
|
||||
.map(|w| w.document())
|
||||
.flatten()
|
||||
.and_then(|w| w.document())
|
||||
.ok_or_else(|| anyhow!("Could not get window document"))
|
||||
.and_then(|d| {
|
||||
d.dyn_into::<web_sys::HtmlDocument>()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod api;
|
||||
pub mod common_component;
|
||||
pub mod cookies;
|
||||
pub mod graphql;
|
||||
pub mod modal;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#![recursion_limit = "256"]
|
||||
#![forbid(non_ascii_idents)]
|
||||
#![allow(clippy::nonstandard_macro_braces)]
|
||||
pub mod components;
|
||||
pub mod infra;
|
||||
|
||||
18
app/static/fonts.css
Normal file
18
app/static/fonts.css
Normal file
@@ -0,0 +1,18 @@
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Bebas Neue';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(fonts/JTUSjIg69CK48gW7PXoo9Wdhyzbi.woff2) format('woff2');
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Bebas Neue';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(fonts/JTUSjIg69CK48gW7PXoo9Wlhyw.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
3
app/static/fonts/fonts.txt
Normal file
3
app/static/fonts/fonts.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
https://cdn.jsdelivr.net/npm/bootstrap-icons@1.5.0/font/fonts/bootstrap-icons.woff2
|
||||
https://fonts.gstatic.com/s/bebasneue/v2/JTUSjIg69CK48gW7PXoo9Wdhyzbi.woff2
|
||||
https://fonts.gstatic.com/s/bebasneue/v2/JTUSjIg69CK48gW7PXoo9Wlhyw.woff2
|
||||
4
app/static/libraries.txt
Normal file
4
app/static/libraries.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/css/bootstrap.min.css
|
||||
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
|
||||
BIN
app/static/spinner.gif
Normal file
BIN
app/static/spinner.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
@@ -1,8 +1,8 @@
|
||||
[package]
|
||||
name = "lldap_auth"
|
||||
version = "0.1.0"
|
||||
authors = ["Valentin Tolmer <valentin@tolmer.fr>", "Steve Barrau <steve.barrau@gmail.com>", "Thomas Wickham <mackwic@gmail.com>"]
|
||||
edition = "2018"
|
||||
version = "0.3.0-alpha.1"
|
||||
authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
|
||||
edition = "2021"
|
||||
|
||||
[features]
|
||||
default = ["opaque_server", "opaque_client"]
|
||||
@@ -13,37 +13,20 @@ js = []
|
||||
[dependencies]
|
||||
rust-argon2 = "0.8"
|
||||
curve25519-dalek = "3"
|
||||
digest = "*"
|
||||
digest = "0.9"
|
||||
generic-array = "*"
|
||||
rand = "0.8"
|
||||
serde = "*"
|
||||
sha2 = "0.9"
|
||||
thiserror = "*"
|
||||
|
||||
# TODO: update to 0.6 when out.
|
||||
[dependencies.opaque-ke]
|
||||
git = "https://github.com/novifinancial/opaque-ke"
|
||||
rev = "eb59676a940b15f77871aefe1e46d7b5bf85f40a"
|
||||
version = "0.6"
|
||||
|
||||
[dependencies.chrono]
|
||||
version = "*"
|
||||
features = [ "serde" ]
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies.sqlx]
|
||||
version = "0.5"
|
||||
features = [
|
||||
"any",
|
||||
"chrono",
|
||||
"macros",
|
||||
"mysql",
|
||||
"postgres",
|
||||
"runtime-actix-native-tls",
|
||||
"sqlite",
|
||||
]
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies.sqlx-core]
|
||||
version = "=0.5.1"
|
||||
|
||||
# For WASM targets, use the JS getrandom.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies.getrandom]
|
||||
version = "0.2"
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
#![forbid(non_ascii_idents)]
|
||||
#![allow(clippy::nonstandard_macro_braces)]
|
||||
use chrono::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::fmt;
|
||||
|
||||
pub mod opaque;
|
||||
|
||||
/// The messages for the 3-step OPAQUE login process.
|
||||
/// The messages for the 3-step OPAQUE and simple login process.
|
||||
pub mod login {
|
||||
use super::*;
|
||||
|
||||
@@ -34,6 +36,28 @@ pub mod login {
|
||||
pub server_data: String,
|
||||
pub credential_finalization: opaque::client::login::CredentialFinalization,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct ClientSimpleLoginRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
impl fmt::Debug for ClientSimpleLoginRequest {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("ClientSimpleLoginRequest")
|
||||
.field("username", &self.username)
|
||||
.field("password", &"***********")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct ServerLoginResponse {
|
||||
pub token: String,
|
||||
#[serde(rename = "refreshToken", skip_serializing_if = "Option::is_none")]
|
||||
pub refresh_token: Option<String>,
|
||||
}
|
||||
}
|
||||
|
||||
/// The messages for the 3-step OPAQUE registration process.
|
||||
@@ -67,6 +91,19 @@ pub mod registration {
|
||||
}
|
||||
}
|
||||
|
||||
/// The messages for the 3-step OPAQUE registration process.
|
||||
/// It is used to reset a user's password.
|
||||
pub mod password_reset {
|
||||
use super::*;
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct ServerPasswordResetResponse {
|
||||
#[serde(rename = "userId")]
|
||||
pub user_id: String,
|
||||
pub token: String,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct JWTClaims {
|
||||
pub exp: DateTime<Utc>,
|
||||
|
||||
2
config.toml
Normal file
2
config.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[build]
|
||||
rustflags = ["-C", "link-arg=-fuse-ld=lld"]
|
||||
33
docker-entrypoint.sh
Executable file
33
docker-entrypoint.sh
Executable file
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
CONFIG_FILE=/data/lldap_config.toml
|
||||
|
||||
if [[ ( ! -w "/data" ) ]] || [[ ( ! -d "/data" ) ]]; then
|
||||
echo "[entrypoint] The /data folder doesn't exist or cannot be written to. Make sure to mount
|
||||
a volume or folder to /data to persist data across restarts, and that the current user can
|
||||
write to it."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$CONFIG_FILE" ]]; then
|
||||
echo "[entrypoint] Copying the default config to $CONFIG_FILE"
|
||||
echo "[entrypoint] Edit this file to configure LLDAP."
|
||||
cp /app/lldap_config.docker_template.toml $CONFIG_FILE
|
||||
fi
|
||||
|
||||
if [[ ! -r "$CONFIG_FILE" ]]; then
|
||||
echo "[entrypoint] Config file is not readable. Check the permissions"
|
||||
exit 1;
|
||||
fi
|
||||
|
||||
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 "$@"
|
||||
89
docs/architecture.md
Normal file
89
docs/architecture.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# Architecture
|
||||
|
||||
The server is entirely written in Rust, using [actix](https://actix.rs) for the
|
||||
backend and [yew](https://yew.rs) for the frontend.
|
||||
|
||||
Backend:
|
||||
* Listens on a port for LDAP protocol.
|
||||
* Only a small, read-only subset of the LDAP protocol is supported.
|
||||
* In addition to that, an extension to allow resetting the password is also
|
||||
supported.
|
||||
* Listens on another port for HTTP traffic.
|
||||
* The authentication API, based on JWTs, is under "/auth".
|
||||
* The user management API is a GraphQL API under "/api/graphql". The schema
|
||||
is defined in `schema.graphql`.
|
||||
* The static frontend files are served by this port too.
|
||||
|
||||
Note that secure protocols (LDAPS, HTTPS) are currently not supported. This can
|
||||
be worked around by using a reverse proxy in front of the server (for the HTTP
|
||||
API) that wraps/unwraps the HTTPS messages, or only open the service to
|
||||
localhost or other trusted docker containers (for the LDAP API).
|
||||
|
||||
Frontend:
|
||||
* User management UI.
|
||||
* Written in Rust compiled to WASM as an SPA with the Yew library.
|
||||
* Based on components, with a React-like organization.
|
||||
|
||||
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).
|
||||
|
||||
### Code organization
|
||||
|
||||
* `auth/`: Contains the shared structures needed for authentication, the
|
||||
interface between front and back-end. In particular, it contains the OPAQUE
|
||||
structures and the JWT format.
|
||||
* `app/`: The frontend.
|
||||
* `src/components`: The elements containing the business and display logic of
|
||||
the various pages and their components.
|
||||
* `src/infra`: Various tools and utilities.
|
||||
* `server/`: The backend.
|
||||
* `src/domain/`: Domain-specific logic: users, groups, checking passwords...
|
||||
* `src/infra/`: API, both GraphQL and LDAP
|
||||
|
||||
## Authentication
|
||||
|
||||
### Passwords
|
||||
|
||||
Authentication is done via the OPAQUE protocol, meaning that the passwords are
|
||||
never sent to the server, but instead the client proves that they know the
|
||||
correct password (zero-knowledge proof). This is likely overkill, especially
|
||||
considered that the LDAP interface requires sending the password to the server,
|
||||
but it's one less potential flaw (especially since the LDAP interface can be
|
||||
restricted to an internal docker-only network while the web app is exposed to
|
||||
the Internet).
|
||||
|
||||
OPAQUE's "passwords" (user-specific blobs of data that can only be used in a
|
||||
zero-knowledge proof that the password is correct) are hashed using Argon2, the
|
||||
state of the art in terms of password storage. They are hashed using a secret
|
||||
provided in the configuration (which can be given as environment variable or
|
||||
command line argument as well): this should be kept secret and shouldn't change
|
||||
(it would invalidate all passwords). Note that even if it was compromised, the
|
||||
attacker wouldn't be able to decrypt the passwords without running an expensive
|
||||
brute-force search independently for each password.
|
||||
|
||||
### JWTs and refresh tokens
|
||||
|
||||
When logging in for the first time, users are provided with a refresh token
|
||||
that gets stored in an HTTP-only cookie, valid for 30 days. They can use this
|
||||
token to get a JWT to get access to various servers: the JWT lists the groups
|
||||
the user belongs to. To simplify the setup, there is a single JWT secret that
|
||||
should be shared between the authentication server and the application servers;
|
||||
and users don't get a different token per application server
|
||||
(this could be implemented, we just didn't have any use case yet).
|
||||
|
||||
JWTs are only valid for one day: when they expire, a new JWT can be obtained
|
||||
from the authentication server using the refresh token. If the user stays
|
||||
logged in, they would only have to type their password once a month.
|
||||
|
||||
#### Logout
|
||||
|
||||
In order to handle logout correctly, we rely on a blacklist of JWTs. When a
|
||||
user logs out, their refresh token is removed from the backend, and all of
|
||||
their currently valid JWTs are added to a blacklist. Incoming requests are
|
||||
checked against this blacklist (in-memory, faster than calling the database).
|
||||
Applications that want to use these JWTs should subscribe to be notified of
|
||||
blacklisted JWTs (TODO: implement the PubSub service and API).
|
||||
|
||||
40
example_configs/Organizr.md
Normal file
40
example_configs/Organizr.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Configuration for Organizr
|
||||
## System Settings > Main > Authentication
|
||||
---
|
||||
|
||||
### Host Address
|
||||
```
|
||||
ldap://localhost:3890
|
||||
```
|
||||
Replace `localhost:3890` with your LLDAP host & port
|
||||
|
||||
### Host Base DN
|
||||
```
|
||||
cn=%s,ou=people,dc=example,dc=com
|
||||
```
|
||||
|
||||
### Account prefix
|
||||
```
|
||||
cn=
|
||||
```
|
||||
|
||||
### Account Suffix
|
||||
```
|
||||
,ou=people,dc=example,dc=com
|
||||
```
|
||||
|
||||
### Bind Username
|
||||
```
|
||||
cn=admin,ou=people,dc=example,dc=com
|
||||
```
|
||||
|
||||
### Bind Password
|
||||
```
|
||||
Your password from your LDAP config
|
||||
```
|
||||
### LDAP Backend Type
|
||||
```
|
||||
OpenLDAP
|
||||
```
|
||||
|
||||
Replace `dc=example,dc=com` with your LLDAP configured domain for all occurances
|
||||
26
example_configs/airsonic-advanced.md
Normal file
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
|
||||
56
example_configs/apacheguacamole.md
Normal file
56
example_configs/apacheguacamole.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Configuration for Apache Guacamole
|
||||
!! IMPORTANT - LDAP only works with LLDAP if using a [database authentication](https://guacamole.apache.org/doc/gug/ldap-auth.html#associating-ldap-with-a-database). The Apache Guacamole does support using LDAP to store user config but that is not in scope here.
|
||||
This was achieved by using the docker [jasonbean/guacamole](https://registry.hub.docker.com/r/jasonbean/guacamole/).
|
||||
|
||||
## To setup LDAP
|
||||
|
||||
### Using `guacamole.properties`
|
||||
Open and edit your Apache Guacamole properties files
|
||||
|
||||
Located at `guacamole/guacamole.properties`
|
||||
|
||||
Uncomment and insert the below into your properties file
|
||||
|
||||
```
|
||||
### http://guacamole.apache.org/doc/gug/ldap-auth.html
|
||||
### LDAP Properties
|
||||
ldap-hostname: localhost
|
||||
ldap-port: 3890
|
||||
ldap-user-base-dn: ou=people,dc=example,dc=com
|
||||
ldap-username-attribute: uid
|
||||
ldap-search-bind-dn: uid=admin,ou=people,dc=example,dc=com
|
||||
ldap-search-bind-password: replacewithyoursecret
|
||||
ldap-user-search-filter: (memberof=cn=lldap_apacheguac,ou=groups,dc=example,dc=com)
|
||||
```
|
||||
|
||||
### Using docker variables
|
||||
|
||||
```
|
||||
LDAP_HOSTNAME: localhost
|
||||
LDAP_PORT: 3890
|
||||
LDAP_ENCRYPTION_METHOD: none
|
||||
LDAP_USER_BASE_DN: ou=people,dc=example,dc=com
|
||||
LDAP_USERNAME_ATTRIBUTE: uid
|
||||
LDAP_SEARCH_BIND_DN: uid=admin,ou=people,dc=example,dc=com
|
||||
LDAP_SEARCH_BIND_PASSWORD: replacewithyoursecret
|
||||
LDAP_USER_SEARCH_FILTER: (memberof=cn=lldap_guacamole,ou=groups,dc=example,dc=com)
|
||||
```
|
||||
|
||||
### Notes
|
||||
* You set it either through `guacamole.properties` or docker variables, not both.
|
||||
* Exclude `ldap-user-search-filter/LDAP_USER_SEARCH_FILTER` if you do not want to limit users based on a group(s)
|
||||
* it is a filter that permits users with `lldap_guacamole` sample group.
|
||||
* Replace `dc=example,dc=com` with your LLDAP configured domain for all occurances
|
||||
* Apache Guacamole does not lock you out when enabling LDAP. Your `static` IDs still are able to log in.
|
||||
* setting `LDAP_ENCRYPTION_METHOD` is disabling SSL
|
||||
|
||||
## To enable LDAP
|
||||
Restart your Apache Guacamole app for changes to take effect
|
||||
|
||||
## To enable users
|
||||
Before logging in with an LLDAP user, you have to manually create it using your static ID in Apache Guacamole. This applies to each user that you want to log in with using LDAP authentication. Otherwise the user will be logged in without any permissions/connections/etc.
|
||||
|
||||
Using your static ID, create a username that matches your target LDAP username. If applicable, tick the permissions and/or connections that you want this user to see.
|
||||
|
||||
Log in with LDAP user.
|
||||
|
||||
48
example_configs/authelia_config.yml
Normal file
48
example_configs/authelia_config.yml
Normal file
@@ -0,0 +1,48 @@
|
||||
###############################################################
|
||||
# Authelia configuration #
|
||||
###############################################################
|
||||
|
||||
# This is just the LDAP part of the Authelia configuration!
|
||||
|
||||
|
||||
authentication_backend:
|
||||
# Password reset through authelia works normally.
|
||||
password_reset:
|
||||
disable: false
|
||||
# How often authelia should check if there is an user update in LDAP
|
||||
refresh_interval: 1m
|
||||
ldap:
|
||||
implementation: custom
|
||||
# Pattern is ldap://HOSTNAME-OR-IP:PORT
|
||||
# Normal ldap port is 389, standard in LLDAP is 3890
|
||||
url: ldap://lldap:3890
|
||||
# The dial timeout for LDAP.
|
||||
timeout: 5s
|
||||
# Use StartTLS with the LDAP connection, TLS not supported right now
|
||||
start_tls: false
|
||||
#tls:
|
||||
# skip_verify: false
|
||||
# minimum_version: TLS1.2
|
||||
# Set base dn, like dc=google,dc.com
|
||||
base_dn: dc=example,dc=com
|
||||
username_attribute: uid
|
||||
# You need to set this to ou=people, because all users are stored in this ou!
|
||||
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))
|
||||
# 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})
|
||||
# The attribute holding the name of the group.
|
||||
group_name_attribute: cn
|
||||
# Email attribute
|
||||
mail_attribute: mail
|
||||
# The attribute holding the display name of the user. This will be used to greet an authenticated user.
|
||||
display_name_attribute: displayName
|
||||
# The username and password of the admin user.
|
||||
# "admin" should be the admin username you set in the LLDAP configuration
|
||||
user: uid=admin,ou=people,dc=example,dc=com
|
||||
# Password can also be set using a secret: https://www.authelia.com/docs/configuration/secrets.html
|
||||
password: 'REPLACE_ME'
|
||||
66
example_configs/bookstack.env.example
Normal file
66
example_configs/bookstack.env.example
Normal file
@@ -0,0 +1,66 @@
|
||||
|
||||
## ADD after values in the existing .env file.
|
||||
## To keep existing documents, you might need to alter ownership/permission in the bookstack database.
|
||||
|
||||
# General auth
|
||||
AUTH_METHOD=ldap
|
||||
|
||||
# The LDAP host, Adding a port is optional
|
||||
LDAP_SERVER=ldap://lldap:3890
|
||||
|
||||
# If using LDAP over SSL you should also define the protocol:
|
||||
# LDAP_SERVER=ldaps://example.com:636
|
||||
|
||||
# The base DN from where users will be dk within
|
||||
LDAP_BASE_DN=ou=people,dc=example,dc=com
|
||||
|
||||
# The full DN and password of the user used to search the server
|
||||
# Can both be left as false to bind anonymously
|
||||
LDAP_DN=uid=admin,ou=people,dc=example,dc=com
|
||||
LDAP_PASS=YOUR-ADMIN-PASSWORD-HERE
|
||||
|
||||
# A filter to use when searching for users
|
||||
# The user-provided user-name used to replace any occurrences of '${user}'
|
||||
# If you're setting this option via other means, such as within a docker-compose.yml,
|
||||
# you may need escape the $, often using $$ or \$ instead.
|
||||
LDAP_USER_FILTER=(&(uid=${user}))
|
||||
|
||||
# Set the LDAP version to use when connecting to the server
|
||||
# Should be set to 3 in most cases.
|
||||
LDAP_VERSION=3
|
||||
|
||||
# Set the property to use as a unique identifier for this user.
|
||||
# Stored and used to match LDAP users with existing BookStack users.
|
||||
# Prefixing the value with 'BIN;' will assume the LDAP service provides the attribute value as
|
||||
# binary data and BookStack will convert the value to a hexidecimal representation.
|
||||
# Defaults to 'uid'.
|
||||
LDAP_ID_ATTRIBUTE=uid
|
||||
|
||||
# Set the default 'email' attribute. Defaults to 'mail'
|
||||
LDAP_EMAIL_ATTRIBUTE=mail
|
||||
|
||||
# Set the property to use for a user's display name. Defaults to 'cn'
|
||||
LDAP_DISPLAY_NAME_ATTRIBUTE=cn
|
||||
|
||||
# Set the attribute to use for the user's avatar image.
|
||||
# Must provide JPEG binary image data.
|
||||
# Will be used upon login or registration when the user doesn't
|
||||
# already have an avatar image set.
|
||||
# Remove this option or set to 'null' to disable LDAP avatar import.
|
||||
|
||||
#LDAP_THUMBNAIL_ATTRIBUTE=jpegphoto
|
||||
|
||||
# Force TLS to be used for LDAP communication.
|
||||
# Use this if you can but your LDAP support will need to support it and
|
||||
# you may need to import your certificate to the BookStack host machine.
|
||||
# Defaults to 'false'.
|
||||
LDAP_START_TLS=false
|
||||
|
||||
# If you need to allow untrusted LDAPS certificates, add the below and uncomment (remove the #)
|
||||
# Only set this option if debugging or you're absolutely sure it's required for your setup.
|
||||
# If using php-fpm, you may want to restart it after changing this option to avoid instability.
|
||||
#LDAP_TLS_INSECURE=true
|
||||
|
||||
# If you need to debug the details coming from your LDAP server, add the below and uncomment (remove the #)
|
||||
# Only set this option if debugging since it will block logins and potentially show private details.
|
||||
#LDAP_DUMP_USER_DETAILS=true
|
||||
97
example_configs/calibre_web.md
Normal file
97
example_configs/calibre_web.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# Configuration for Calibre-Web
|
||||
|
||||
Replace `dc=example,dc=com` with your LLDAP configured domain.
|
||||
|
||||
|
||||
### Login type
|
||||
|
||||
```
|
||||
Use LDAP Authentication
|
||||
```
|
||||
|
||||
### LDAP Server Host Name or IP Address
|
||||
|
||||
```
|
||||
lldap
|
||||
```
|
||||
|
||||
### LDAP Server Port
|
||||
|
||||
```
|
||||
3890
|
||||
```
|
||||
|
||||
### LDAP Encryption
|
||||
|
||||
```
|
||||
none
|
||||
```
|
||||
|
||||
### LDAP Authentication
|
||||
|
||||
```
|
||||
simple
|
||||
```
|
||||
|
||||
### LDAP Administrator Username
|
||||
|
||||
```
|
||||
uid=admin,ou=people,dc=example,dc=com
|
||||
```
|
||||
|
||||
### LDAP Administrator Password
|
||||
|
||||
```
|
||||
CHANGE_ME
|
||||
```
|
||||
|
||||
### LDAP Distinguished Name (DN)
|
||||
|
||||
```
|
||||
dc=example,dc=com
|
||||
```
|
||||
|
||||
### LDAP User Object Filter
|
||||
|
||||
```
|
||||
(&(objectclass=person)(uid=%s))
|
||||
```
|
||||
|
||||
### LDAP Server is OpenLDAP?
|
||||
|
||||
```
|
||||
yes
|
||||
```
|
||||
|
||||
### LDAP Group Object Filter
|
||||
|
||||
```
|
||||
(&(objectclass=groupOfUniqueNames)(cn=%s))
|
||||
```
|
||||
|
||||
### LDAP Group Name
|
||||
|
||||
```
|
||||
calibre_web
|
||||
```
|
||||
|
||||
Note: Create a group in lldap and add users to it that will have access to your Calibre-Web instance
|
||||
|
||||
### LDAP Group Members Field
|
||||
|
||||
```
|
||||
uniqueMember
|
||||
```
|
||||
|
||||
### LDAP Member User Filter Detection
|
||||
|
||||
```
|
||||
Custom Filter
|
||||
```
|
||||
|
||||
### LDAP Member User Filter
|
||||
|
||||
```
|
||||
(&(objectclass=person)(uid=%s))
|
||||
```
|
||||
Note: lowercase the word "person" until this bug is fixed
|
||||
25
example_configs/dokuwiki.md
Normal file
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'] = '(&(objectClass=group)(memberUID=member))';
|
||||
$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.
|
||||
89
example_configs/dolibarr.md
Normal file
89
example_configs/dolibarr.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# Configuration pour Dolibarr
|
||||
|
||||
This example will help you to create user in dolibarr from your users in your lldap server from a specific group and to login with the password from the lldap server.
|
||||
|
||||
## To connect ldap->dolibarr
|
||||
|
||||
In Dolibarr, install the LDAP module from `Home` -> `Modules/Applications`
|
||||
Go to the configuration of this module and fill it like this:
|
||||
|
||||
|
||||
- Users and groups synchronization: `LDAP -> Dolibarr`
|
||||
- Contacts' synchronization: `No`
|
||||
- Type: `OpenLdap`
|
||||
- Version: `Version 3`
|
||||
- Primary server: `ldap://example.com`
|
||||
- Secondary server: `Empty`
|
||||
- Server port: port `3890`
|
||||
- Server DN: `dc=example,dc=com`
|
||||
- Use TLS: `No`
|
||||
- Administrator DN: `uid=admin,ou=people,dc=example,dc=com`
|
||||
- Administrator password: `secret`
|
||||
|
||||
Click on modify then "TEST LDAP CONNECTION".
|
||||
You should get this result on the bottom:
|
||||
```
|
||||
TCP connect to LDAP server successful (Server=ldap://example.com, Port=389)
|
||||
Connect/Authenticate to LDAP server successful (Server=ldap://example.com, Port=389, Admin=uid=admin,ou=people,dc=example,dc=com, Password=**********)
|
||||
LDAP server configured for version 3
|
||||
```
|
||||
|
||||
And two new tabs will appear on the top: `Users` and `Groups`.
|
||||
|
||||
We will use only `Users` in this example to get the users we want to import.
|
||||
The tab `Groups` would be to import groups.
|
||||
|
||||
Click on the `Users` tab and fill it like this:
|
||||
- Users' DN: `ou=people,dc=example,dc=com`
|
||||
- List of objectClass: `person`
|
||||
- Search filter: `memberOf=cn=yournamegroup,ou=groups,dc=example,dc=com`
|
||||
|
||||
(or if you don't have a group for your users, leave the search filter empty)
|
||||
|
||||
- Full name: `cn`
|
||||
- Name: `sn`
|
||||
- First name: `givenname`
|
||||
- Login `uid`
|
||||
- Email address `mail`
|
||||
|
||||
Click on "MODIFY" and then on "TEST A LDAP SEARCH".
|
||||
|
||||
You should get the number of users in the group or all users if you didn't use a filter.
|
||||
|
||||
|
||||
## To import ldap users into the dolibarr database (needed to login with those users):
|
||||
|
||||
Navigate to `Users & Groups` -> `New Users`.
|
||||
Click on the blank form "Users in LDAP database", you will get the list of the users in the group filled above. With the "GET" button, you will import the selected user.
|
||||
|
||||
|
||||
## To enable LDAP login:
|
||||
|
||||
Modify your `conf.php` in your dolibarr folder in `htdocs/conf`.
|
||||
Replace
|
||||
```
|
||||
// Authentication settings
|
||||
$dolibarr_main_authentication='dolibarr';
|
||||
```
|
||||
|
||||
with:
|
||||
```
|
||||
// Authentication settings
|
||||
// Only add "ldap" to only login using the ldap server, or/and "dolibar" to compare with local users. In any case, you need to have the user existing in dolibarr.
|
||||
$dolibarr_main_authentication='ldap,dolibarr';
|
||||
$dolibarr_main_auth_ldap_host='ldap://127.0.0.1:3890';
|
||||
$dolibarr_main_auth_ldap_port='3890';
|
||||
$dolibarr_main_auth_ldap_version='3';
|
||||
$dolibarr_main_auth_ldap_servertype='openldap';
|
||||
$dolibarr_main_auth_ldap_login_attribute='uid';
|
||||
$dolibarr_main_auth_ldap_dn='ou=people,dc=example,dc=com';
|
||||
$dolibarr_main_auth_ldap_admin_login='uid=admin,ou=people,dc=example,dc=com';
|
||||
$dolibarr_main_auth_ldap_admin_pass='secret';
|
||||
```
|
||||
|
||||
You can add this line to enable debug in case anything is wrong:
|
||||
```
|
||||
$dolibarr_main_auth_ldap_debug='true';
|
||||
```
|
||||
|
||||
|
||||
29
example_configs/emby.md
Normal file
29
example_configs/emby.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Configuration for Emby
|
||||
|
||||
Emby only uses LDAP to create users and validate passwords upon login. Emby administrators are always validated via native emby login.
|
||||
https://emby.media/introducing-ldap-support-for-emby.html
|
||||
|
||||
Replace `dc=example,dc=com` with your LLDAP configured domain.
|
||||
|
||||
### Bind DN
|
||||
```
|
||||
cn=admin,ou=people,dc=example,dc=com
|
||||
```
|
||||
|
||||
### Bind Credentials
|
||||
```
|
||||
changeme (replace with your password)
|
||||
```
|
||||
|
||||
### User search base
|
||||
```
|
||||
ou=people,dc=example,dc=com
|
||||
```
|
||||
|
||||
### User search filter
|
||||
|
||||
replace the `emby_user` cn with the group name for accounts that should be able to login to Emby, otherwise leave the default `(uid={0})`.
|
||||
|
||||
```
|
||||
(&(uid={0})(memberOf=cn=emby_user,ou=groups,dc=example,dc=com))
|
||||
```
|
||||
22
example_configs/gitea.md
Normal file
22
example_configs/gitea.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Configuration for Gitea
|
||||
In Gitea, go to `Site Administration > Authentication Sources` and click `Add Authentication Source`
|
||||
Select `LDAP (via BindDN)`
|
||||
|
||||
* Host: Your lldap server's ip/hostname
|
||||
* Port: Your lldap server's port (3890 by default)
|
||||
* Bind DN: `uid=admin,ou=people,dc=example,dc=com`
|
||||
* Bind Password: Your bind user's password
|
||||
* User Search Base: `ou=people,dc=example,dc=com`
|
||||
* User Filter: If you want all users to be able to log in, use<br>
|
||||
`(&(objectClass=person)(|(uid=%[1]s)(mail=%[1]s)))`.<br>
|
||||
To log in they can either use their email address or user name. If you only want members a specific group to be able to log in, in this case the group `git_user`, use<br>
|
||||
`(&(memberof=cn=git_user,ou=groups,dc=example,dc=com)(|(uid=%[1]s)(mail=%[1]s)))`<br>
|
||||
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`
|
||||
* Email Attribute: `mail`
|
||||
* 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.
|
||||
49
example_configs/grafana_ldap_config.toml
Normal file
49
example_configs/grafana_ldap_config.toml
Normal file
@@ -0,0 +1,49 @@
|
||||
# This is only the ldap config, you also need to enable ldap support in the main config file
|
||||
# of Grafana. See https://grafana.com/docs/grafana/latest/auth/ldap/#enable-ldap
|
||||
# You can test that it is working correctly by trying usernames at: https://<your grafana instance>/admin/ldap
|
||||
|
||||
[[servers]]
|
||||
# Ldap server host (specify multiple hosts space separated)
|
||||
host = "<your ldap host>"
|
||||
# Default port is 389 or 636 if use_ssl = true
|
||||
port = 3890
|
||||
# Set to true if LDAP server should use an encrypted TLS connection (either with STARTTLS or LDAPS)
|
||||
use_ssl = false
|
||||
# If set to true, use LDAP with STARTTLS instead of LDAPS
|
||||
start_tls = false
|
||||
# set to true if you want to skip SSL cert validation
|
||||
ssl_skip_verify = false
|
||||
# set to the path to your root CA certificate or leave unset to use system defaults
|
||||
# root_ca_cert = "/path/to/certificate.crt"
|
||||
# Authentication against LDAP servers requiring client certificates
|
||||
# client_cert = "/path/to/client.crt"
|
||||
# client_key = "/path/to/client.key"
|
||||
|
||||
# Search user bind dn
|
||||
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>"
|
||||
|
||||
# User search filter
|
||||
search_filter = "(uid=%s)"
|
||||
# If you want to limit to only users of a specific group use this instead:
|
||||
# search_filter = "(&(uid=%s)(memberOf=cn=<your group>,ou=groups,dc=example,dc=org))"
|
||||
|
||||
# An array of base dns to search through
|
||||
search_base_dns = ["dc=example,dc=org"]
|
||||
|
||||
# Specify names of the LDAP attributes your LDAP uses
|
||||
[servers.attributes]
|
||||
member_of = "memberOf"
|
||||
email = "mail"
|
||||
name = "givenName"
|
||||
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 = "uid=lldap_admin,ou=groups,dc=example,dc=org"
|
||||
# org_role = "Admin"
|
||||
# grafana_admin = true
|
||||
16
example_configs/hedgedoc.md
Normal file
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
|
||||
BIN
example_configs/images/nextcloud_groups.png
Normal file
BIN
example_configs/images/nextcloud_groups.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
BIN
example_configs/images/nextcloud_ldap_srv.png
Normal file
BIN
example_configs/images/nextcloud_ldap_srv.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
BIN
example_configs/images/nextcloud_login_attributes.png
Normal file
BIN
example_configs/images/nextcloud_login_attributes.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 115 KiB |
BIN
example_configs/images/nextcloud_loginfilter.png
Normal file
BIN
example_configs/images/nextcloud_loginfilter.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 168 KiB |
BIN
example_configs/images/nextcloud_sharing_options.png
Normal file
BIN
example_configs/images/nextcloud_sharing_options.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 110 KiB |
50
example_configs/jellyfin.md
Normal file
50
example_configs/jellyfin.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Configuration for Jellyfin
|
||||
|
||||
Replace `dc=example,dc=com` with your LLDAP configured domain.
|
||||
|
||||
### LDAP Bind User
|
||||
```
|
||||
uid=admin,ou=people,dc=example,dc=com
|
||||
```
|
||||
|
||||
### LDAP Base DN for searches
|
||||
```
|
||||
ou=people,dc=example,dc=com
|
||||
```
|
||||
|
||||
### LDAP Attributes
|
||||
|
||||
```
|
||||
uid, mail
|
||||
```
|
||||
|
||||
### LDAP Name Attribute
|
||||
|
||||
```
|
||||
uid
|
||||
```
|
||||
|
||||
### User Filter
|
||||
|
||||
If you have a `media` group, you can use:
|
||||
```
|
||||
(memberof=cn=media,ou=groups,dc=example,dc=com)
|
||||
```
|
||||
|
||||
Otherwise, just use:
|
||||
```
|
||||
(uid=*)
|
||||
```
|
||||
|
||||
### Admin Filter
|
||||
|
||||
Same here. If you have `media_admin` group (doesn't have to be named like
|
||||
that), use:
|
||||
```
|
||||
(memberof=cn=media_admin,ou=groups,dc=example,dc=com)
|
||||
```
|
||||
|
||||
Otherwise, you can use LLDAP's admin group:
|
||||
```
|
||||
(memberof=cn=lldap_admin,ou=groups,dc=example,dc=com)
|
||||
```
|
||||
30
example_configs/jitsi_meet.conf
Normal file
30
example_configs/jitsi_meet.conf
Normal file
@@ -0,0 +1,30 @@
|
||||
# .env file
|
||||
|
||||
# Enable authentication
|
||||
ENABLE_AUTH=1
|
||||
|
||||
# Enable guest access
|
||||
ENABLE_GUESTS=1
|
||||
|
||||
# Select authentication type
|
||||
AUTH_TYPE=ldap
|
||||
|
||||
# LDAP authentication
|
||||
|
||||
# LDAP url for connection
|
||||
LDAP_URL=ldap://IP:3890
|
||||
|
||||
# LDAP base DN.
|
||||
LDAP_BASE=ou=people,dc=example,dc=com
|
||||
|
||||
# LDAP user DN.
|
||||
LDAP_BINDDN=uid=admin,ou=people,dc=example,dc=com
|
||||
|
||||
# LLDAP admin password.
|
||||
LDAP_BINDPW=password
|
||||
|
||||
# LDAP filter.
|
||||
LDAP_FILTER=(&(uid=%u)(objectClass=person))
|
||||
|
||||
# LDAP authentication method
|
||||
LDAP_AUTH_METHOD=bind
|
||||
64
example_configs/keycloak.md
Normal file
64
example_configs/keycloak.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# KeyCloak configuration
|
||||
|
||||
Configuring [KeyCloak](https://www.keycloak.org) takes a bit of effort. Once
|
||||
the KeyCloak instance is up and you logged in as admin (see [this
|
||||
guide](https://www.keycloak.org/getting-started/getting-started-docker) to get
|
||||
started with KeyCloak), you'll need to configure the LDAP mapping.
|
||||
|
||||
Keep in mind that LLDAP is _read-only_: that means that if you create some
|
||||
users in KeyCloak, they won't be reflected to LLDAP. Instead, you should create
|
||||
the user from LLDAP, and it will appear in KeyCloak. Same for groups. However,
|
||||
you can set the permissions associated with users or groups in KeyCloak.
|
||||
|
||||
## Configure user authentication
|
||||
|
||||
In the admin console of KeyCloak, on the left, go to "User Federation". You can
|
||||
then add an LDAP backend.
|
||||
|
||||
The key settings are:
|
||||
|
||||
- Edit Mode: `READ_ONLY`
|
||||
- Vendor: `Other`
|
||||
- Username LDAP attribute: `uid`
|
||||
- UUID LDAP attribute: `uid`
|
||||
- User Object Classes: `person`
|
||||
- Connection URL: `ldap://<your-lldap-container>:3890`
|
||||
- Users DN: `ou=people,dc=example,dc=com` (or whatever `dc` you have)
|
||||
- Bind Type: `simple`
|
||||
- Bind DN: `uid=admin,ou=people,dc=example,dc=com` (replace with your admin user and `dc`)
|
||||
- Bind Credential: your LLDAP admin password
|
||||
|
||||
Test the connection and authentication, it should work.
|
||||
|
||||
In the "Advanced Settings", you can "Query Supported Extensions", or just
|
||||
enable the "LDAPv3 Password Modify Extended Operation".
|
||||
|
||||
Turn "Pagination" off.
|
||||
|
||||
Save the provider.
|
||||
|
||||
## Configure group mapping
|
||||
|
||||
Getting the LDAP groups to be imported into KeyCloak requires one more step:
|
||||
|
||||
Go back to "User Federation", and edit your LDAP integration. At the top, click
|
||||
on the "Mappers" tab.
|
||||
|
||||
Find or create the `groups` mapper, with type `group-ldap-mapper`. The key
|
||||
settings are:
|
||||
|
||||
- LDAP Groups DN: `ou=groups,dc=example,dc=com` (or whatever `dc` you have)
|
||||
- Group Name LDAP Attribute: `cn`
|
||||
- Group Object Classes: `groupOfUniqueNames`
|
||||
- Mode: `READ_ONLY`
|
||||
|
||||
Save, then sync LDAP groups to KeyCloak, and (from the LDAP integration page)
|
||||
sync the users to KeyCloak as well.
|
||||
|
||||
## Give the LDAP admin user admin rights to KeyCloak
|
||||
|
||||
Once the groups are synchronized, go to "Manage > Groups" on the left. Click on
|
||||
`lldap_admin`, then "Edit".
|
||||
|
||||
Assign the role "admin" to the group. Now you can log in as the LLDAP admin to
|
||||
the KeyCloak admin console.
|
||||
22
example_configs/lldap.service
Normal file
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
|
||||
14
example_configs/matrix_synapse.yml
Normal file
14
example_configs/matrix_synapse.yml
Normal file
@@ -0,0 +1,14 @@
|
||||
modules:
|
||||
- module: "ldap_auth_provider.LdapAuthProviderModule"
|
||||
config:
|
||||
enabled: true
|
||||
uri: "ldap://lldap"
|
||||
start_tls: false
|
||||
base: "ou=people,dc=example,dc=com"
|
||||
attributes:
|
||||
uid: "uid"
|
||||
mail: "mail"
|
||||
name: "cn"
|
||||
bind_dn: "uid=admin,ou=people,dc=example,dc=com"
|
||||
bind_password: "password"
|
||||
filter: "(objectClass=person)"
|
||||
111
example_configs/nextcloud.md
Normal file
111
example_configs/nextcloud.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# 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`
|
||||
* Several accounts, members of `users` group will be authorized to log in Nextcloud (eg neither `admin` nor `ro_admin`)
|
||||
* 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)
|
||||
|
||||
## 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: users group, contains the users who can login to Nextcloud
|
||||
occ ldap:set-config s01 ldapUserFilter "(&(objectclass=person)(memberOf=cn=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 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 small amount of luck, you should be able to log in your nextcloud instance with LLDAP accounts in the `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 `users` group to be allowed to log in Nextcloud :
|
||||
```
|
||||
(&(objectclass=person)(memberOf=cn=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.
|
||||
|
||||

|
||||
|
||||
The resulting LDAP filter could be simplified removing the first 'OR' condition (I think).
|
||||
|
||||
## Sharing restrictions
|
||||
|
||||
Go to Settings > Administration > Sharing and check following boxes :
|
||||
|
||||
* "Allow username autocompletion to users within the same groups"
|
||||
* "Restrict users to only share with users in their groups"
|
||||
|
||||

|
||||
64
example_configs/portainer.md
Normal file
64
example_configs/portainer.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Configuration for Portainer CE/BE
|
||||
### Settings > Authentication > LDAP > Custom
|
||||
---
|
||||
|
||||
## LDAP configuration
|
||||
|
||||
#### LDAP Server
|
||||
```
|
||||
localhost:3890 or ip-address:3890
|
||||
```
|
||||
#### Anonymous mode
|
||||
```
|
||||
off
|
||||
```
|
||||
#### Reader DN
|
||||
```
|
||||
uid=admin,ou=people,dc=example,dc=com
|
||||
```
|
||||
#### Password
|
||||
```
|
||||
xxx
|
||||
```
|
||||
* Password is the ENV you set at *LLDAP_LDAP_USER_PASS=* or `lldap_config.toml`
|
||||
|
||||
## User search configurations
|
||||
|
||||
#### Base DN
|
||||
```
|
||||
ou=people,dc=example,dc=com
|
||||
```
|
||||
#### Username attribute
|
||||
```
|
||||
uid
|
||||
```
|
||||
### Filter
|
||||
#### All available user(s)
|
||||
```
|
||||
(objectClass=person)
|
||||
```
|
||||
* Using this filter will list all user registered in LLDAP
|
||||
|
||||
#### All user(s) from specific group
|
||||
```
|
||||
(&(objectClass=person)(memberof=cn=lldap_portainer,ou=groups,dc=example,dc=com))
|
||||
```
|
||||
* Using this filter will only list user that included in `lldap_portainer` group.
|
||||
* Admin should manually configure groups and add a user to it. **lldap_portainer** only sample.
|
||||
|
||||
|
||||
|
||||
## Group search configurations
|
||||
|
||||
#### Group Base DN
|
||||
```
|
||||
ou=groups,dc=example,dc=com
|
||||
```
|
||||
#### Group Membership Attribute
|
||||
```
|
||||
cn
|
||||
```
|
||||
#### Group Filter
|
||||
```
|
||||
is optional
|
||||
```
|
||||
89
example_configs/seafile.md
Normal file
89
example_configs/seafile.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# Configuration for Seafile
|
||||
Seafile's LDAP interface requires a unique, immutable user identifier in the format of `username@domain`. Since LLDAP does not provide an attribute like `userPrincipalName`, the only attribute that somewhat qualifies is therefore `mail`. However, using `mail` as the user identifier results in the issue that Seafile will treat you as an entirely new user if you change your email address through LLDAP. If this is not an issue for you, you can configure LLDAP as an authentication source in Seafile directly. A better but more elaborate way to use Seafile with LLDAP is by using Authelia as an intermediary. This document will guide you through both setups.
|
||||
|
||||
## Configuring Seafile to use LLDAP directly
|
||||
Add the following to your `seafile/conf/ccnet.conf` file:
|
||||
```
|
||||
[LDAP]
|
||||
HOST = ldap://192.168.1.100:3890
|
||||
BASE = ou=people,dc=example,dc=com
|
||||
USER_DN = uid=admin,ou=people,dc=example,dc=com
|
||||
PASSWORD = CHANGE_ME
|
||||
LOGIN_ATTR = mail
|
||||
```
|
||||
* Replace `192.168.1.100:3890` with your LLDAP server's ip/hostname and port.
|
||||
* Replace every instance of `dc=example,dc=com` with your configured domain.
|
||||
|
||||
After restarting the Seafile server, users should be able to log in with their email address and password.
|
||||
|
||||
### Filtering by group membership
|
||||
If you only want members of a specific group to be able to log in, add the following line:
|
||||
```
|
||||
FILTER = memberOf=cn=seafile_user,ou=groups,dc=example,dc=com
|
||||
```
|
||||
* Replace `seafile_user` with the name of your group.
|
||||
|
||||
## Configuring Seafile to use LLDAP with Authelia as an intermediary
|
||||
Authelia is an open-source authentication and authorization server that can use LLDAP as a backend and act as an OpenID Connect Provider. We're going to assume that you have already set up Authelia and configured it with LLDAP.
|
||||
If not, you can find an example configuration [here](authelia_config.yml).
|
||||
|
||||
1. Add the following to Authelia's `configuration.yml`:
|
||||
```
|
||||
identity_providers:
|
||||
oidc:
|
||||
hmac_secret: Your_HMAC_Secret #Replace with a random string
|
||||
issuer_private_key: |
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
Your_Private_Key
|
||||
#See https://www.authelia.com/configuration/identity-providers/open-id-connect/#issuer_private_key for instructions on how to generate a key
|
||||
-----END RSA PRIVATE KEY-----
|
||||
cors:
|
||||
endpoints:
|
||||
- authorization
|
||||
- token
|
||||
- revocation
|
||||
- introspection
|
||||
- userinfo
|
||||
clients:
|
||||
- id: seafile
|
||||
description: Seafile #The display name of the application. Will show up on Authelia consent screens
|
||||
secret: Your_Shared_Secret #Replace with random string
|
||||
public: false
|
||||
authorization_policy: one_factor #Can also be two_factor
|
||||
scopes:
|
||||
- openid
|
||||
- profile
|
||||
- email
|
||||
redirect_uris:
|
||||
- https://seafile.example.com/oauth/callback/
|
||||
userinfo_signing_algorithm: none
|
||||
pre_configured_consent_duration: 6M
|
||||
#On first login you must consent to sharing information between Authelia and Seafile. This option configures the amount of time after which you need to reconsent.
|
||||
# y = years, M = months, w = weeks, d = days
|
||||
```
|
||||
|
||||
2. Add the following to `seafile/conf/seahub_settings.py`
|
||||
```
|
||||
ENABLE_OAUTH = True
|
||||
OAUTH_ENABLE_INSECURE_TRANSPORT = True
|
||||
OAUTH_CLIENT_ID = 'seafile' #Must be the same as in Authelia
|
||||
OAUTH_CLIENT_SECRET = 'Your_Shared_Secret' #Must be the same as in Authelia
|
||||
OAUTH_REDIRECT_URL = 'https://seafile.example.com/oauth/callback/'
|
||||
OAUTH_PROVIDER_DOMAIN = 'auth.example.com'
|
||||
OAUTH_AUTHORIZATION_URL = 'https://auth.example.com/api/oidc/authorization'
|
||||
OAUTH_TOKEN_URL = 'https://auth.example.com/api/oidc/token'
|
||||
OAUTH_USER_INFO_URL = 'https://auth.example.com/api/oidc/userinfo'
|
||||
OAUTH_SCOPE = [
|
||||
"openid",
|
||||
"profile",
|
||||
"email",
|
||||
]
|
||||
OAUTH_ATTRIBUTE_MAP = {
|
||||
"preferred_username": (True, "email"), #Seafile will create a unique identifier of your <LLDAP's User ID >@<the value specified in OAUTH_PROVIDER_DOMAIN>. The identifier is not visible to the user and not actually used as the email address unlike the value suggests
|
||||
"name": (False, "name"),
|
||||
"id": (False, "not used"),
|
||||
"email": (False, "contact_email"),
|
||||
}
|
||||
```
|
||||
|
||||
Restart both your Authelia and Seafile server. You should see a "Single Sign-On" button on Seafile's login page. Clicking it should redirect you to Authelia. If you use the [example config for Authelia](authelia_config.yml), you should be able to log in using your LLDAP User ID.
|
||||
30
example_configs/syncthing.md
Normal file
30
example_configs/syncthing.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Configuration for Syncthing
|
||||
## Actions > Advanced > LDAP
|
||||
---
|
||||
|
||||
| Parameter | Value | Details |
|
||||
|----------------------|------------------------------------------------------------------------|-------------------------------------------------------|
|
||||
| Address | `localhost:3890` | Replace `localhost:3890` with your LLDAP host & port |
|
||||
| Bind DN | `cn=%s,ou=people,dc=example,dc=com` | |
|
||||
| Insecure Skip Verify | *unchecked* | |
|
||||
| Search Base DN | `ou=people,dc=example,dc=com` | Only used when using filters. |
|
||||
| Search Filter | `(&(uid=%s)(memberof=cn=lldap_syncthing,ou=groups,dc=example,dc=com))` | Filters on users belonging to group `lldap_syncthing` |
|
||||
| Transport | `plain` | |
|
||||
|
||||
Replace `dc=example,dc=com` with your LLDAP configured domain for all occurances
|
||||
|
||||
Leave **Search Base DN** and **Search Filter** both blank if you are not using any filters.
|
||||
|
||||
## Actions > Advanced > GUI
|
||||
|
||||
Change **Auth Mode** from `static` to `ldap`
|
||||
|
||||
|
||||
If you get locked out of the UI due to invalid LDAP settings, you can always change the settings from the `config.xml`, save the file, and force restart the app.
|
||||
|
||||
### Example
|
||||
|
||||
Change the below and restart
|
||||
|
||||
` <authMode>ldap</authMode>` to ` <authMode>static</authMode>`
|
||||
|
||||
16
example_configs/wg_portal.env.example
Normal file
16
example_configs/wg_portal.env.example
Normal file
@@ -0,0 +1,16 @@
|
||||
# Config for wg-portal (https://github.com/h44z/wg-portal)
|
||||
# Replace dc=example,dc=com with your base DN
|
||||
|
||||
# Connection to LLDAP
|
||||
# Remember that wg-portal requires host networking when ran in docker, so you cannot use docker networks to manage this
|
||||
LDAP_URL: ldap://localhost:3890
|
||||
|
||||
LDAP_BASEDN: "dc=example,dc=com"
|
||||
LDAP_USER: "uid=admin,ou=people,dc=example,dc=com"
|
||||
LDAP_PASSWORD: "CHANGEME"
|
||||
|
||||
LDAP_LOGIN_FILTER: "(&(objectClass=person)(|(mail={{login_identifier}})(uid={{login_identifier}})))"
|
||||
LDAP_SYNC_FILTER: "(&(objectClass=person)(mail=*))"
|
||||
LDAP_ADMIN_GROUP: "uid=everyone,ou=groups,dc=example,dc=com"
|
||||
LDAP_ATTR_EMAIL: "mail"
|
||||
LDAP_STARTTLS: "false"
|
||||
21
example_configs/xbackbone_config.php
Normal file
21
example_configs/xbackbone_config.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
return array (
|
||||
'ldap' =>
|
||||
array (
|
||||
'enabled' => true,
|
||||
'schema' => 'ldap',
|
||||
// If using same docker network, use 'lldap', otherwise put ip/hostname
|
||||
'host' => 'lldap',
|
||||
// Normal ldap port is 389, standard in LLDAP is 3890
|
||||
'port' => 3890,
|
||||
'base_domain' => 'ou=people,dc=example,dc=com',
|
||||
// ???? is replaced with user-provided username, authenticates users in an lldap group called "xbackbone"
|
||||
// Remove the "(memberof=...)" if you want to allow all users.
|
||||
'search_filter' => '(&(uid=????)(objectClass=person)(memberof=cn=xbackbone,ou=groups,dc=example,dc=com))',
|
||||
// the attribute to use as username
|
||||
'rdn_attribute' => 'uid',
|
||||
// LDAP admin/service account info below
|
||||
'service_account_dn' => 'cn=admin,ou=people,dc=example,dc=com',
|
||||
'service_account_password' => 'REPLACE_ME',
|
||||
),
|
||||
);
|
||||
@@ -1,6 +1,11 @@
|
||||
## Default configuration for Docker.
|
||||
## All the values can be overridden through environment variables. For
|
||||
## instance, "ldap_port" can be overridden with the "LDAP_PORT" variable.
|
||||
## All the values can be overridden through environment variables, prefixed
|
||||
## with "LLDAP_". For instance, "ldap_port" can be overridden with the
|
||||
## "LLDAP_LDAP_PORT" variable.
|
||||
|
||||
## Tune the logging to be more verbose by setting this to be true.
|
||||
## You can set it with the LLDAP_VERBOSE environment variable.
|
||||
# verbose=false
|
||||
|
||||
## The port on which to have the LDAP server.
|
||||
#ldap_port = 3890
|
||||
@@ -9,13 +14,18 @@
|
||||
## administration.
|
||||
#http_port = 17170
|
||||
|
||||
## The public URL of the server, for password reset links.
|
||||
#http_url = "http://localhost"
|
||||
|
||||
## Random secret for JWT signature.
|
||||
## This secret should be random, and should be shared with application
|
||||
## servers that need to consume the JWTs.
|
||||
## Changing this secret will invalidate all user sessions and require
|
||||
## them to re-login.
|
||||
## You should probably set it through the JWT_SECRET environment
|
||||
## You should probably set it through the LLDAP_JWT_SECRET environment
|
||||
## variable from a secret ".env" file.
|
||||
## This can also be set from a file's contents by specifying the file path
|
||||
## in the LLDAP_JWT_SECRET_FILE environment variable
|
||||
## You can generate it with (on linux):
|
||||
## LC_ALL=C tr -dc 'A-Za-z0-9!"#%&'\''()*+,-./:;<=>?@[\]^_{|}~' </dev/urandom | head -c 32; echo ''
|
||||
#jwt_secret = "REPLACE_WITH_RANDOM"
|
||||
@@ -31,16 +41,24 @@
|
||||
|
||||
## Admin username.
|
||||
## For the LDAP interface, a value of "admin" here will create the LDAP
|
||||
## user "cn=admin,dc=example,dc=com" (with the base DN above).
|
||||
## user "cn=admin,ou=people,dc=example,dc=com" (with the base DN above).
|
||||
## For the administration interface, this is the username.
|
||||
#ldap_user_dn = "admin"
|
||||
|
||||
## Admin email.
|
||||
## Email for the admin account. It is only used when initially creating
|
||||
## the admin user, and can safely be omitted.
|
||||
#ldap_user_email = "admin@example.com"
|
||||
|
||||
## Admin password.
|
||||
## Password for the admin account, both for the LDAP bind and for the
|
||||
## administration interface.
|
||||
## administration interface. It is only used when initially creating
|
||||
## the admin user.
|
||||
## It should be minimum 8 characters long.
|
||||
## You can set it with the LDAP_USER_PASS environment variable.
|
||||
## Note: you can create another admin user for LDAP/administration, this
|
||||
## You can set it with the LLDAP_LDAP_USER_PASS environment variable.
|
||||
## This can also be set from a file's contents by specifying the file path
|
||||
## in the LLDAP_LDAP_USER_PASS_FILE environment variable
|
||||
## Note: you can create another admin user for user administration, this
|
||||
## is just the default one.
|
||||
#ldap_user_pass = "REPLACE_WITH_PASSWORD"
|
||||
|
||||
@@ -64,3 +82,46 @@ database_url = "sqlite:///data/users.db?mode=rwc"
|
||||
## each password.
|
||||
## Randomly generated on first run if it doesn't exist.
|
||||
key_file = "/data/private_key"
|
||||
|
||||
## Ignored attributes.
|
||||
## Some services will request attributes that are not present in LLDAP. When it
|
||||
## is the case, LLDAP will warn about the attribute being unknown. If you want
|
||||
## to ignore the attribute and the service works without, you can add it to this
|
||||
## list to silence the warning.
|
||||
#ignored_user_attributes = [ "sAMAccountName" ]
|
||||
#ignored_group_attributes = [ "mail", "userPrincipalName" ]
|
||||
|
||||
## Options to configure SMTP parameters, to send password reset emails.
|
||||
## To set these options from environment variables, use the following format
|
||||
## (example with "password"): LLDAP_SMTP_OPTIONS__PASSWORD
|
||||
#[smtp_options]
|
||||
## Whether to enabled password reset via email, from LLDAP.
|
||||
#enable_password_reset=true
|
||||
## The SMTP server.
|
||||
#server="smtp.gmail.com"
|
||||
## The SMTP port.
|
||||
#port=587
|
||||
## How the connection is encrypted, either "TLS" or "STARTTLS".
|
||||
#smtp_encryption = "TLS"
|
||||
## The SMTP user, usually your email address.
|
||||
#user="sender@gmail.com"
|
||||
## The SMTP password.
|
||||
#password="password"
|
||||
## The header field, optional: how the sender appears in the email. The first
|
||||
## is a free-form name, followed by an email between <>.
|
||||
#from="LLDAP Admin <sender@gmail.com>"
|
||||
## Same for reply-to, optional.
|
||||
#reply_to="Do not reply <noreply@localhost>"
|
||||
|
||||
## Options to configure LDAPS.
|
||||
## To set these options from environment variables, use the following format
|
||||
## (example with "port"): LLDAP_LDAPS_OPTIONS__PORT
|
||||
#[ldaps_options]
|
||||
## Whether to enable LDAPS.
|
||||
#enabled=true
|
||||
## Port on which to listen.
|
||||
#port=6360
|
||||
## Certificate file.
|
||||
#cert_file="/data/cert.pem"
|
||||
## Certificate key file.
|
||||
#key_file="/data/key.pem"
|
||||
|
||||
33
migration-tool/Cargo.toml
Normal file
33
migration-tool/Cargo.toml
Normal file
@@ -0,0 +1,33 @@
|
||||
[package]
|
||||
name = "migration-tool"
|
||||
version = "0.4.1"
|
||||
edition = "2021"
|
||||
authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
|
||||
|
||||
[dependencies]
|
||||
anyhow = "*"
|
||||
base64 = "0.13"
|
||||
rand = "0.8"
|
||||
requestty = "0.4.1"
|
||||
serde = "1"
|
||||
serde_json = "1"
|
||||
smallvec = "*"
|
||||
|
||||
[dependencies.lldap_auth]
|
||||
path = "../auth"
|
||||
features = ["opaque_client"]
|
||||
|
||||
[dependencies.graphql_client]
|
||||
features = ["graphql_query_derive", "reqwest-rustls"]
|
||||
default-features = false
|
||||
version = "0.11"
|
||||
|
||||
[dependencies.reqwest]
|
||||
version = "*"
|
||||
default-features = false
|
||||
features = ["json", "blocking", "rustls-tls"]
|
||||
|
||||
[dependencies.ldap3]
|
||||
version = "*"
|
||||
default-features = false
|
||||
features = ["sync", "tls-rustls"]
|
||||
5
migration-tool/queries/add_user_to_group.graphql
Normal file
5
migration-tool/queries/add_user_to_group.graphql
Normal file
@@ -0,0 +1,5 @@
|
||||
mutation AddUserToGroup($user: String!, $group: Int!) {
|
||||
addUserToGroup(userId: $user, groupId: $group) {
|
||||
ok
|
||||
}
|
||||
}
|
||||
6
migration-tool/queries/create_group.graphql
Normal file
6
migration-tool/queries/create_group.graphql
Normal file
@@ -0,0 +1,6 @@
|
||||
mutation CreateGroup($name: String!) {
|
||||
createGroup(name: $name) {
|
||||
id
|
||||
displayName
|
||||
}
|
||||
}
|
||||
5
migration-tool/queries/create_user.graphql
Normal file
5
migration-tool/queries/create_user.graphql
Normal file
@@ -0,0 +1,5 @@
|
||||
mutation CreateUser($user: CreateUserInput!) {
|
||||
createUser(user: $user) {
|
||||
id
|
||||
}
|
||||
}
|
||||
9
migration-tool/queries/list_groups.graphql
Normal file
9
migration-tool/queries/list_groups.graphql
Normal file
@@ -0,0 +1,9 @@
|
||||
query ListGroups {
|
||||
groups {
|
||||
id
|
||||
displayName
|
||||
users {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
5
migration-tool/queries/list_users.graphql
Normal file
5
migration-tool/queries/list_users.graphql
Normal file
@@ -0,0 +1,5 @@
|
||||
query ListUsers {
|
||||
users(filters: null) {
|
||||
id
|
||||
}
|
||||
}
|
||||
435
migration-tool/src/ldap.rs
Normal file
435
migration-tool/src/ldap.rs
Normal file
@@ -0,0 +1,435 @@
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use ldap3::{ResultEntry, SearchEntry};
|
||||
use requestty::{prompt_one, Question};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use crate::lldap::User;
|
||||
|
||||
pub struct LdapClient {
|
||||
domain: String,
|
||||
connection: ldap3::LdapConn,
|
||||
}
|
||||
|
||||
/// Checks if the URL starts with the protocol, and whether the host is valid (DNS and listening),
|
||||
/// potentially with the given port. Returns the address + port that managed to connect, if any.
|
||||
pub fn check_host_exists(
|
||||
url: &str,
|
||||
protocol_and_port: &[(&str, u16)],
|
||||
) -> std::result::Result<Option<String>, String> {
|
||||
for (protocol, port) in protocol_and_port {
|
||||
if url.starts_with(protocol) {
|
||||
use std::net::ToSocketAddrs;
|
||||
let trimmed_url = url.trim_start_matches(protocol);
|
||||
return match trimmed_url.to_socket_addrs() {
|
||||
Ok(_) => Ok(Some(url.to_owned())),
|
||||
Err(_) => {
|
||||
let new_url = format!("{}:{}", trimmed_url, port);
|
||||
new_url
|
||||
.to_socket_addrs()
|
||||
.map_err(|_| format!("Could not resolve host: '{}'", trimmed_url))
|
||||
.map(|_| Some(format!("{}{}", protocol, new_url)))
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn autocomplete_domain_suffix(input: String, domain: &str) -> SmallVec<[String; 1]> {
|
||||
let mut answers = SmallVec::<[String; 1]>::new();
|
||||
for part in input.split(',') {
|
||||
if !part.starts_with('d') {
|
||||
continue;
|
||||
}
|
||||
if domain.starts_with(part) {
|
||||
answers.push(input.clone() + domain.trim_start_matches(part));
|
||||
}
|
||||
}
|
||||
answers.push(input);
|
||||
answers
|
||||
}
|
||||
|
||||
/// Asks the user for the URL of the LDAP server, and checks that a connection can be established.
|
||||
/// Returns the LDAP URL.
|
||||
fn get_ldap_url() -> Result<String> {
|
||||
let ldap_protocols = &[("ldap://", 389), ("ldaps://", 636)];
|
||||
let question = Question::input("ldap_url")
|
||||
.message("LDAP_URL (ldap://...)")
|
||||
.auto_complete(|answer, _| {
|
||||
let mut answers = SmallVec::<[String; 1]>::new();
|
||||
if "ldap://".starts_with(&answer) {
|
||||
answers.push("ldap://".to_owned());
|
||||
}
|
||||
if "ldaps://".starts_with(&answer) {
|
||||
answers.push("ldaps://".to_owned());
|
||||
}
|
||||
answers.push(answer);
|
||||
answers
|
||||
})
|
||||
.validate(|url, _| {
|
||||
if let Some(url) = check_host_exists(url, ldap_protocols)? {
|
||||
ldap3::LdapConn::new(&url)
|
||||
.map_err(|e| format!("Could not connect to LDAP server: {}", e))?;
|
||||
Ok(())
|
||||
} else {
|
||||
Err("LDAP URL should start with 'ldap://' or 'ldaps://'".to_owned())
|
||||
}
|
||||
})
|
||||
.build();
|
||||
let answer = prompt_one(question)?;
|
||||
Ok(
|
||||
check_host_exists(answer.as_string().unwrap(), ldap_protocols)
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Binds the LDAP connection by asking the user for the bind DN and password, and returns the bind
|
||||
/// DN.
|
||||
fn bind_ldap(
|
||||
ldap_connection: &mut ldap3::LdapConn,
|
||||
previous_binddn: Option<String>,
|
||||
) -> Result<String> {
|
||||
let binddn = {
|
||||
let question = Question::input("ldap_binddn")
|
||||
.message("LDAP_BIND_DN (cn=...)")
|
||||
.validate(|dn, _| {
|
||||
if dn.contains(',') && dn.contains('=') {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(
|
||||
"Invalid bind DN, expected something like 'cn=admin,dc=example,dc=com'"
|
||||
.to_owned(),
|
||||
)
|
||||
}
|
||||
})
|
||||
.auto_complete(|answer, _| {
|
||||
let mut answers = SmallVec::<[String; 1]>::new();
|
||||
if let Some(binddn) = &previous_binddn {
|
||||
answers.push(binddn.clone());
|
||||
}
|
||||
answers.push(answer);
|
||||
answers
|
||||
})
|
||||
.build();
|
||||
let answer = prompt_one(question)?;
|
||||
answer.as_string().unwrap().to_owned()
|
||||
};
|
||||
let password = {
|
||||
let question = Question::password("ldap_bind_password")
|
||||
.message("LDAP_BIND_PASSWORD")
|
||||
.validate(|password, _| {
|
||||
if !password.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Empty password".to_owned())
|
||||
}
|
||||
})
|
||||
.build();
|
||||
let answer = prompt_one(question)?;
|
||||
answer.as_string().unwrap().to_owned()
|
||||
};
|
||||
if let Err(e) = ldap_connection
|
||||
.simple_bind(&binddn, &password)
|
||||
.and_then(ldap3::LdapResult::success)
|
||||
{
|
||||
println!("Error connecting as '{}': {}", binddn, e);
|
||||
bind_ldap(ldap_connection, Some(binddn))
|
||||
} else {
|
||||
Ok(binddn)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<ResultEntry> for User {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(value: ResultEntry) -> Result<Self> {
|
||||
let entry = SearchEntry::construct(value);
|
||||
let get_required_attribute = |attr| {
|
||||
entry
|
||||
.attrs
|
||||
.get(attr)
|
||||
.ok_or_else(|| anyhow!("Missing {} for user", attr))
|
||||
.and_then(|u| -> Result<String> {
|
||||
u.iter()
|
||||
.next()
|
||||
.map(String::to_owned)
|
||||
.ok_or_else(|| anyhow!("Too many {}s", attr))
|
||||
})
|
||||
};
|
||||
let id = get_required_attribute("uid")
|
||||
.or_else(|_| get_required_attribute("sAMAccountName"))
|
||||
.or_else(|_| get_required_attribute("userPrincipalName"))?;
|
||||
let email = get_required_attribute("mail")
|
||||
.or_else(|_| get_required_attribute("rfc822mailbox"))
|
||||
.context(format!("for user '{}'", id))?;
|
||||
|
||||
let get_optional_attribute = |attr: &str| {
|
||||
entry
|
||||
.attrs
|
||||
.get(attr)
|
||||
.and_then(|v| v.first().map(|s| s.as_str()))
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_owned)
|
||||
};
|
||||
let last_name = get_optional_attribute("sn").or_else(|| get_optional_attribute("surname"));
|
||||
let display_name = get_optional_attribute("cn")
|
||||
.or_else(|| get_optional_attribute("commonName"))
|
||||
.or_else(|| get_optional_attribute("name"))
|
||||
.or_else(|| get_optional_attribute("displayName"));
|
||||
let first_name = get_optional_attribute("givenName");
|
||||
let avatar = entry
|
||||
.attrs
|
||||
.get("jpegPhoto")
|
||||
.map(|v| v.iter().map(|s| s.as_bytes().to_vec()).collect::<Vec<_>>())
|
||||
.or_else(|| entry.bin_attrs.get("jpegPhoto").map(Clone::clone))
|
||||
.and_then(|v| v.into_iter().next().filter(|s| !s.is_empty()));
|
||||
let password =
|
||||
get_optional_attribute("userPassword").or_else(|| get_optional_attribute("password"));
|
||||
Ok(User::new(
|
||||
crate::lldap::CreateUserInput {
|
||||
id,
|
||||
email,
|
||||
display_name,
|
||||
first_name,
|
||||
last_name,
|
||||
avatar: avatar.map(base64::encode),
|
||||
},
|
||||
password,
|
||||
entry.dn,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
enum OuType {
|
||||
User,
|
||||
Group,
|
||||
}
|
||||
|
||||
fn detect_ou(
|
||||
ldap_connection: &mut ldap3::LdapConn,
|
||||
domain: &str,
|
||||
for_type: OuType,
|
||||
) -> Result<(Option<String>, Vec<String>), anyhow::Error> {
|
||||
let ous = ldap_connection
|
||||
.search(
|
||||
domain,
|
||||
ldap3::Scope::Subtree,
|
||||
"(objectClass=organizationalUnit)",
|
||||
vec!["dn"],
|
||||
)?
|
||||
.success()?
|
||||
.0;
|
||||
let mut detected_ou = None;
|
||||
let mut all_ous = Vec::new();
|
||||
for result_entry in ous {
|
||||
let dn = SearchEntry::construct(result_entry).dn;
|
||||
match for_type {
|
||||
OuType::User => {
|
||||
if dn.contains("user") || dn.contains("people") || dn.contains("person") {
|
||||
detected_ou = Some(dn.clone());
|
||||
}
|
||||
}
|
||||
OuType::Group => {
|
||||
if dn.contains("group") {
|
||||
detected_ou = Some(dn.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
all_ous.push(dn);
|
||||
}
|
||||
Ok((detected_ou, all_ous))
|
||||
}
|
||||
|
||||
pub fn get_users(connection: &mut LdapClient) -> Result<Vec<User>, anyhow::Error> {
|
||||
let LdapClient {
|
||||
connection: ldap_connection,
|
||||
domain,
|
||||
} = connection;
|
||||
let domain = domain.as_str();
|
||||
let (maybe_user_ou, all_ous) = detect_ou(ldap_connection, domain, OuType::User)?;
|
||||
let user_ou = {
|
||||
let question = Question::input("ldap_user_ou")
|
||||
.message(format!(
|
||||
"Where are the users located (under '{}')? {}(LDAP_USERS_DN)",
|
||||
domain,
|
||||
maybe_user_ou
|
||||
.as_ref()
|
||||
.map(|ou| format!("Detected: {}", ou))
|
||||
.unwrap_or_default()
|
||||
))
|
||||
.validate(|dn, _| {
|
||||
if dn.contains('=') {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"Invalid bind DN, expected something like 'ou=people,{}'",
|
||||
domain
|
||||
))
|
||||
}
|
||||
})
|
||||
.default(maybe_user_ou.unwrap_or_default())
|
||||
.auto_complete(|s, _| {
|
||||
let mut answers = autocomplete_domain_suffix(s, domain);
|
||||
answers.extend(all_ous.clone().into_iter());
|
||||
answers
|
||||
})
|
||||
.build();
|
||||
let answer = prompt_one(question)?;
|
||||
let mut answer = answer.as_string().unwrap().to_owned();
|
||||
if !answer.ends_with(domain) {
|
||||
if !answer.is_empty() {
|
||||
answer += ",";
|
||||
}
|
||||
answer += domain;
|
||||
}
|
||||
answer
|
||||
};
|
||||
let users = ldap_connection
|
||||
.search(
|
||||
&user_ou,
|
||||
ldap3::Scope::Subtree,
|
||||
"(|(objectClass=inetOrgPerson)(objectClass=person)(objectClass=mailAccount)(objectClass=posixAccount)(objectClass=user)(objectClass=organizationalPerson))",
|
||||
vec![
|
||||
"uid",
|
||||
"sAMAccountName",
|
||||
"userPrincipalName",
|
||||
"mail",
|
||||
"rfc822mailbox",
|
||||
"givenName",
|
||||
"sn",
|
||||
"surname",
|
||||
"cn",
|
||||
"commonName",
|
||||
"displayName",
|
||||
"name",
|
||||
"userPassword",
|
||||
],
|
||||
)?
|
||||
.success()?
|
||||
.0;
|
||||
users
|
||||
.into_iter()
|
||||
.map(TryFrom::try_from)
|
||||
.collect::<Result<Vec<User>>>()
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct LdapGroup {
|
||||
pub name: String,
|
||||
pub members: Vec<String>,
|
||||
}
|
||||
|
||||
impl TryFrom<ResultEntry> for LdapGroup {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
// https://github.com/graphql-rust/graphql-client/issues/386
|
||||
#[allow(non_snake_case)]
|
||||
fn try_from(value: ResultEntry) -> Result<Self> {
|
||||
let entry = SearchEntry::construct(value);
|
||||
let get_required_attribute = |attr| {
|
||||
entry
|
||||
.attrs
|
||||
.get(attr)
|
||||
.ok_or_else(|| anyhow!("Missing {} for user", attr))
|
||||
.and_then(|u| {
|
||||
if u.len() > 1 {
|
||||
Err(anyhow!("Too many {}s", attr))
|
||||
} else {
|
||||
Ok(u.first().unwrap().to_owned())
|
||||
}
|
||||
})
|
||||
};
|
||||
let name = get_required_attribute("cn")
|
||||
.or_else(|_| get_required_attribute("commonName"))
|
||||
.or_else(|_| get_required_attribute("displayName"))
|
||||
.or_else(|_| get_required_attribute("name"))?;
|
||||
|
||||
let get_repeated_attribute = |attr: &str| entry.attrs.get(attr).map(|v| v.to_owned());
|
||||
let members = get_repeated_attribute("member")
|
||||
.or_else(|| get_repeated_attribute("uniqueMember"))
|
||||
.unwrap_or_default();
|
||||
Ok(LdapGroup { name, members })
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_groups(connection: &mut LdapClient) -> Result<Vec<LdapGroup>> {
|
||||
let LdapClient {
|
||||
connection: ldap_connection,
|
||||
domain,
|
||||
} = connection;
|
||||
let domain = domain.as_str();
|
||||
let (maybe_group_ou, all_ous) = detect_ou(ldap_connection, domain, OuType::Group)?;
|
||||
let group_ou = {
|
||||
let question = Question::input("ldap_group_ou")
|
||||
.message(format!(
|
||||
"Where are the groups located (under '{}')? {}(LDAP_GROUPS_DN)",
|
||||
domain,
|
||||
maybe_group_ou
|
||||
.as_ref()
|
||||
.map(|ou| format!("Detected: {}", ou))
|
||||
.unwrap_or_default()
|
||||
))
|
||||
.validate(|dn, _| {
|
||||
if dn.contains('=') {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"Invalid bind DN, expected something like 'ou=groups,{}'",
|
||||
domain
|
||||
))
|
||||
}
|
||||
})
|
||||
.default(maybe_group_ou.unwrap_or_default())
|
||||
.auto_complete(|s, _| {
|
||||
let mut answers = autocomplete_domain_suffix(s, domain);
|
||||
answers.extend(all_ous.clone().into_iter());
|
||||
answers
|
||||
})
|
||||
.build();
|
||||
let answer = prompt_one(question)?;
|
||||
let mut answer = answer.as_string().unwrap().to_owned();
|
||||
if !answer.ends_with(domain) {
|
||||
if !answer.is_empty() {
|
||||
answer += ",";
|
||||
}
|
||||
answer += domain;
|
||||
}
|
||||
answer
|
||||
};
|
||||
let groups = ldap_connection
|
||||
.search(
|
||||
&group_ou,
|
||||
ldap3::Scope::Subtree,
|
||||
"(|(objectClass=group)(objectClass=groupOfNames)(objectClass=groupOfUniqueNames))",
|
||||
vec![
|
||||
"cn",
|
||||
"commonName",
|
||||
"displayName",
|
||||
"name",
|
||||
"member",
|
||||
"uniqueMember",
|
||||
],
|
||||
)?
|
||||
.success()?
|
||||
.0;
|
||||
let input_groups = groups
|
||||
.into_iter()
|
||||
.map(TryFrom::try_from)
|
||||
.collect::<Result<Vec<LdapGroup>>>()?;
|
||||
Ok(input_groups)
|
||||
}
|
||||
|
||||
pub fn get_ldap_connection() -> Result<LdapClient, anyhow::Error> {
|
||||
let url = get_ldap_url()?;
|
||||
let mut ldap_connection = ldap3::LdapConn::new(&url)?;
|
||||
println!("Server found");
|
||||
let bind_dn = bind_ldap(&mut ldap_connection, None)?;
|
||||
println!("Connection established");
|
||||
let domain = &bind_dn[(bind_dn.find(",dc=").expect("Could not find domain?!") + 1)..];
|
||||
// domain is 'dc=example,dc=com'
|
||||
Ok(LdapClient {
|
||||
connection: ldap_connection,
|
||||
domain: domain.to_owned(),
|
||||
})
|
||||
}
|
||||
499
migration-tool/src/lldap.rs
Normal file
499
migration-tool/src/lldap.rs
Normal file
@@ -0,0 +1,499 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use requestty::{prompt_one, Question};
|
||||
use reqwest::blocking::{Client, ClientBuilder};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use crate::ldap::{check_host_exists, LdapGroup};
|
||||
|
||||
pub struct GraphQLClient {
|
||||
url: String,
|
||||
auth_header: reqwest::header::HeaderValue,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl GraphQLClient {
|
||||
fn new(url: String, auth_token: &str, client: Client) -> Result<Self> {
|
||||
Ok(Self {
|
||||
url: format!("{}/api/graphql", url),
|
||||
auth_header: format!("Bearer {}", auth_token).parse()?,
|
||||
client,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn post<QueryType>(
|
||||
&self,
|
||||
variables: QueryType::Variables,
|
||||
) -> Result<QueryType::ResponseData>
|
||||
where
|
||||
QueryType: GraphQLQuery + 'static,
|
||||
{
|
||||
let unwrap_graphql_response = |graphql_client::Response { data, errors, .. }| {
|
||||
data.ok_or_else(|| {
|
||||
anyhow!(
|
||||
"Errors: [{}]",
|
||||
errors
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
})
|
||||
};
|
||||
self.client
|
||||
.post(&self.url)
|
||||
.header(reqwest::header::AUTHORIZATION, &self.auth_header)
|
||||
// Request body.
|
||||
.json(&QueryType::build_query(variables))
|
||||
.send()
|
||||
.context("while sending a request to the LLDAP server")?
|
||||
.error_for_status()
|
||||
.context("error from an LLDAP response")?
|
||||
// Parse response as Json.
|
||||
.json::<graphql_client::Response<QueryType::ResponseData>>()
|
||||
.context("while parsing backend response")
|
||||
.and_then(unwrap_graphql_response)
|
||||
.context("GraphQL error from an LLDAP response")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct User {
|
||||
pub user_input: create_user::CreateUserInput,
|
||||
pub password: Option<String>,
|
||||
pub dn: String,
|
||||
}
|
||||
|
||||
impl User {
|
||||
// https://github.com/graphql-rust/graphql-client/issues/386
|
||||
pub fn new(
|
||||
user_input: create_user::CreateUserInput,
|
||||
password: Option<String>,
|
||||
dn: String,
|
||||
) -> User {
|
||||
User {
|
||||
user_input,
|
||||
password,
|
||||
dn,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "../schema.graphql",
|
||||
query_path = "queries/create_user.graphql",
|
||||
response_derives = "Debug",
|
||||
variables_derives = "Debug,Clone",
|
||||
custom_scalars_module = "crate::infra::graphql"
|
||||
)]
|
||||
struct CreateUser;
|
||||
|
||||
pub type CreateUserInput = create_user::CreateUserInput;
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "../schema.graphql",
|
||||
query_path = "queries/create_group.graphql",
|
||||
response_derives = "Debug",
|
||||
variables_derives = "Debug,Clone",
|
||||
custom_scalars_module = "crate::infra::graphql"
|
||||
)]
|
||||
struct CreateGroup;
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "../schema.graphql",
|
||||
query_path = "queries/list_users.graphql",
|
||||
response_derives = "Debug",
|
||||
custom_scalars_module = "crate::infra::graphql"
|
||||
)]
|
||||
struct ListUsers;
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "../schema.graphql",
|
||||
query_path = "queries/list_groups.graphql",
|
||||
response_derives = "Debug",
|
||||
custom_scalars_module = "crate::infra::graphql"
|
||||
)]
|
||||
struct ListGroups;
|
||||
|
||||
pub type LldapGroup = list_groups::ListGroupsGroups;
|
||||
|
||||
fn try_login(
|
||||
lldap_server: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
client: &Client,
|
||||
) -> Result<String> {
|
||||
let mut rng = rand::rngs::OsRng;
|
||||
use lldap_auth::login::*;
|
||||
use lldap_auth::opaque::client::login::*;
|
||||
let ClientLoginStartResult { state, message } =
|
||||
start_login(password, &mut rng).context("Could not initialize login")?;
|
||||
let req = ClientLoginStartRequest {
|
||||
username: username.to_owned(),
|
||||
login_start_request: message,
|
||||
};
|
||||
let response = client
|
||||
.post(format!("{}/auth/opaque/login/start", lldap_server))
|
||||
.json(&req)
|
||||
.send()
|
||||
.context("while trying to login to LLDAP")?;
|
||||
if !response.status().is_success() {
|
||||
bail!(
|
||||
"Failed to start logging in to LLDAP: {}",
|
||||
response.status().as_str()
|
||||
);
|
||||
}
|
||||
let login_start_response = response.json::<lldap_auth::login::ServerLoginStartResponse>()?;
|
||||
let login_finish = finish_login(state, login_start_response.credential_response)?;
|
||||
let req = ClientLoginFinishRequest {
|
||||
server_data: login_start_response.server_data,
|
||||
credential_finalization: login_finish.message,
|
||||
};
|
||||
let response = client
|
||||
.post(format!("{}/auth/opaque/login/finish", lldap_server))
|
||||
.json(&req)
|
||||
.send()?;
|
||||
if !response.status().is_success() {
|
||||
bail!(
|
||||
"Failed to finish logging in to LLDAP: {}",
|
||||
response.status().as_str()
|
||||
);
|
||||
}
|
||||
let json = serde_json::from_str::<lldap_auth::login::ServerLoginResponse>(&response.text()?)
|
||||
.context("Could not parse response")?;
|
||||
Ok(json.token)
|
||||
}
|
||||
|
||||
pub fn get_lldap_user_and_password(
|
||||
lldap_server: &str,
|
||||
client: &Client,
|
||||
previous_username: Option<String>,
|
||||
) -> Result<String> {
|
||||
let username = {
|
||||
let question = Question::input("lldap_username")
|
||||
.message("LLDAP_USERNAME (default=admin)")
|
||||
.default("admin")
|
||||
.auto_complete(|answer, _| {
|
||||
let mut answers = SmallVec::<[String; 1]>::new();
|
||||
if let Some(username) = &previous_username {
|
||||
answers.push(username.clone());
|
||||
}
|
||||
answers.push(answer);
|
||||
answers
|
||||
})
|
||||
.build();
|
||||
let answer = prompt_one(question)?;
|
||||
answer.as_string().unwrap().to_owned()
|
||||
};
|
||||
let password = {
|
||||
let question = Question::password("lldap_password")
|
||||
.message("LLDAP_PASSWORD")
|
||||
.validate(|password, _| {
|
||||
if !password.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Empty password".to_owned())
|
||||
}
|
||||
})
|
||||
.build();
|
||||
let answer = prompt_one(question)?;
|
||||
answer.as_string().unwrap().to_owned()
|
||||
};
|
||||
match try_login(lldap_server, &username, &password, client) {
|
||||
Err(e) => {
|
||||
println!("Could not login: {:#?}", e);
|
||||
get_lldap_user_and_password(lldap_server, client, Some(username))
|
||||
}
|
||||
Ok(token) => Ok(token),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_lldap_client() -> Result<GraphQLClient> {
|
||||
let client = ClientBuilder::new()
|
||||
.connect_timeout(std::time::Duration::from_secs(2))
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()?;
|
||||
let lldap_server = get_lldap_server(&client)?;
|
||||
let token = get_lldap_user_and_password(&lldap_server, &client, None)?;
|
||||
println!("Successfully connected to LLDAP");
|
||||
GraphQLClient::new(lldap_server, &token, client)
|
||||
}
|
||||
|
||||
pub fn insert_users_into_lldap(
|
||||
users: Vec<User>,
|
||||
existing_users: &mut Vec<String>,
|
||||
graphql_client: &GraphQLClient,
|
||||
) -> Result<()> {
|
||||
let mut added_users_count = 0;
|
||||
let mut skip_all = false;
|
||||
for user in users {
|
||||
let uid = user.user_input.id.clone();
|
||||
loop {
|
||||
print!("Adding {}... ", &uid);
|
||||
match graphql_client
|
||||
.post::<CreateUser>(create_user::Variables {
|
||||
user: user.user_input.clone(),
|
||||
})
|
||||
.context(format!("while creating user '{}'", uid))
|
||||
{
|
||||
Err(e) => {
|
||||
println!("Error: {:#?}", e);
|
||||
if skip_all {
|
||||
break;
|
||||
}
|
||||
let question = requestty::Question::select("skip_user")
|
||||
.message(format!("Error while adding user {}", &uid))
|
||||
.choices(vec!["Skip", "Retry", "Skip all"])
|
||||
.default_separator()
|
||||
.choice("Abort")
|
||||
.build();
|
||||
let answer = prompt_one(question)?;
|
||||
let choice = answer.as_list_item().unwrap();
|
||||
match choice.text.as_str() {
|
||||
"Skip" => break,
|
||||
"Retry" => continue,
|
||||
"Skip all" => {
|
||||
skip_all = true;
|
||||
break;
|
||||
}
|
||||
"Abort" => return Err(e),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
Ok(response) => {
|
||||
println!("Done!");
|
||||
added_users_count += 1;
|
||||
existing_users.push(response.create_user.id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("{} users successfully added", added_users_count);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn insert_groups_into_lldap(
|
||||
groups: &[LdapGroup],
|
||||
lldap_groups: &mut Vec<LldapGroup>,
|
||||
graphql_client: &GraphQLClient,
|
||||
) -> Result<()> {
|
||||
let mut added_groups_count = 0;
|
||||
let mut skip_all = false;
|
||||
let existing_group_names =
|
||||
HashSet::<&str>::from_iter(lldap_groups.iter().map(|g| g.display_name.as_str()));
|
||||
let new_groups = groups
|
||||
.iter()
|
||||
.filter(|g| !existing_group_names.contains(g.name.as_str()))
|
||||
.collect::<Vec<_>>();
|
||||
for group in new_groups {
|
||||
let name = group.name.clone();
|
||||
loop {
|
||||
print!("Adding {}... ", &name);
|
||||
match graphql_client
|
||||
.post::<CreateGroup>(create_group::Variables { name: name.clone() })
|
||||
.context(format!("while creating group '{}'", &name))
|
||||
{
|
||||
Err(e) => {
|
||||
println!("Error: {:#?}", e);
|
||||
if skip_all {
|
||||
break;
|
||||
}
|
||||
let question = requestty::Question::select("skip_group")
|
||||
.message(format!("Error while adding group {}", &name))
|
||||
.choices(vec!["Skip", "Retry", "Skip all"])
|
||||
.default_separator()
|
||||
.choice("Abort")
|
||||
.build();
|
||||
let answer = prompt_one(question)?;
|
||||
let choice = answer.as_list_item().unwrap();
|
||||
match choice.text.as_str() {
|
||||
"Skip" => break,
|
||||
"Retry" => continue,
|
||||
"Skip all" => {
|
||||
skip_all = true;
|
||||
break;
|
||||
}
|
||||
"Abort" => return Err(e),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
Ok(response) => {
|
||||
println!("Done!");
|
||||
added_groups_count += 1;
|
||||
lldap_groups.push(LldapGroup {
|
||||
id: response.create_group.id,
|
||||
display_name: group.name.clone(),
|
||||
users: Vec::new(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("{} groups successfully added", added_groups_count);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_lldap_users(graphql_client: &GraphQLClient) -> Result<Vec<String>> {
|
||||
Ok(graphql_client
|
||||
.post::<ListUsers>(list_users::Variables {})?
|
||||
.users
|
||||
.into_iter()
|
||||
.map(|u| u.id)
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn get_lldap_groups(graphql_client: &GraphQLClient) -> Result<Vec<LldapGroup>> {
|
||||
Ok(graphql_client
|
||||
.post::<ListGroups>(list_groups::Variables {})?
|
||||
.groups)
|
||||
}
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "../schema.graphql",
|
||||
query_path = "queries/add_user_to_group.graphql",
|
||||
response_derives = "Debug",
|
||||
variables_derives = "Debug,Clone",
|
||||
custom_scalars_module = "crate::infra::graphql"
|
||||
)]
|
||||
struct AddUserToGroup;
|
||||
|
||||
pub fn insert_group_memberships_into_lldap(
|
||||
ldap_users: &[User],
|
||||
ldap_groups: &[LdapGroup],
|
||||
existing_users: &[String],
|
||||
existing_groups: &[LldapGroup],
|
||||
graphql_client: &GraphQLClient,
|
||||
) -> Result<()> {
|
||||
let existing_users = HashSet::<&str>::from_iter(existing_users.iter().map(String::as_str));
|
||||
let existing_groups = HashMap::<&str, &LldapGroup>::from_iter(
|
||||
existing_groups.iter().map(|g| (g.display_name.as_str(), g)),
|
||||
);
|
||||
let dn_resolver = HashMap::<&str, &str>::from_iter(
|
||||
ldap_users
|
||||
.iter()
|
||||
.map(|u| (u.dn.as_str(), u.user_input.id.as_str())),
|
||||
);
|
||||
let mut skip_all = false;
|
||||
let mut added_membership_count = 0;
|
||||
for group in ldap_groups {
|
||||
if let Some(lldap_group) = existing_groups.get(group.name.as_str()) {
|
||||
let lldap_members =
|
||||
HashSet::<&str>::from_iter(lldap_group.users.iter().map(|u| u.id.as_str()));
|
||||
let mut skip_group = false;
|
||||
for user in &group.members {
|
||||
let user = if let Some(id) = dn_resolver.get(user.as_str()) {
|
||||
id
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
if lldap_members.contains(user) || !existing_users.contains(user) {
|
||||
continue;
|
||||
}
|
||||
loop {
|
||||
print!("Adding '{}' to '{}'... ", &user, &group.name);
|
||||
if let Err(e) = graphql_client
|
||||
.post::<AddUserToGroup>(add_user_to_group::Variables {
|
||||
user: user.to_string(),
|
||||
group: lldap_group.id,
|
||||
})
|
||||
.context(format!(
|
||||
"while adding user '{}' to group '{}'",
|
||||
&user, &group.name
|
||||
))
|
||||
{
|
||||
println!("Error: {:#?}", e);
|
||||
if skip_all || skip_group {
|
||||
break;
|
||||
}
|
||||
let question = requestty::Question::select("skip_membership")
|
||||
.message(format!(
|
||||
"Error while adding '{}' to group '{}",
|
||||
&user, &group.name
|
||||
))
|
||||
.choices(vec!["Skip", "Retry", "Skip group", "Skip all"])
|
||||
.default_separator()
|
||||
.choice("Abort")
|
||||
.build();
|
||||
let answer = prompt_one(question)?;
|
||||
let choice = answer.as_list_item().unwrap();
|
||||
match choice.text.as_str() {
|
||||
"Skip" => break,
|
||||
"Retry" => continue,
|
||||
"Skip group" => {
|
||||
skip_group = true;
|
||||
break;
|
||||
}
|
||||
"Skip all" => {
|
||||
skip_all = true;
|
||||
break;
|
||||
}
|
||||
"Abort" => return Err(e),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
} else {
|
||||
println!("Done!");
|
||||
added_membership_count += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("{} memberships successfully added", added_membership_count);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_lldap_server(client: &Client) -> Result<String> {
|
||||
let http_protocols = &[("http://", 17170), ("https://", 17170)];
|
||||
let question = Question::input("lldap_url")
|
||||
.message("LLDAP_URL (http://...)")
|
||||
.auto_complete(|answer, _| {
|
||||
let mut answers = SmallVec::<[String; 1]>::new();
|
||||
if "http://".starts_with(&answer) {
|
||||
answers.push("http://".to_owned());
|
||||
}
|
||||
if "https://".starts_with(&answer) {
|
||||
answers.push("https://".to_owned());
|
||||
}
|
||||
answers.push(answer);
|
||||
answers
|
||||
})
|
||||
.validate(|url, _| {
|
||||
if let Some(url) = check_host_exists(url, http_protocols)? {
|
||||
client
|
||||
.get(format!("{}/api/graphql", url))
|
||||
.send()
|
||||
.map_err(|e| format!("Host did not answer: {}", e))
|
||||
.and_then(|response| {
|
||||
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Host doesn't seem to be an LLDAP server".to_owned())
|
||||
}
|
||||
})
|
||||
} else {
|
||||
Err(
|
||||
"Could not resolve host (make sure it starts with 'http://' or 'https://')"
|
||||
.to_owned(),
|
||||
)
|
||||
}
|
||||
})
|
||||
.build();
|
||||
let answer = prompt_one(question)?;
|
||||
Ok(
|
||||
check_host_exists(answer.as_string().unwrap(), http_protocols)
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
205
migration-tool/src/main.rs
Normal file
205
migration-tool/src/main.rs
Normal file
@@ -0,0 +1,205 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use requestty::{prompt_one, Question};
|
||||
|
||||
mod ldap;
|
||||
mod lldap;
|
||||
|
||||
use ldap::LdapGroup;
|
||||
use lldap::{LldapGroup, User};
|
||||
|
||||
fn ask_generic_confirmation(name: &str, message: &str) -> Result<bool> {
|
||||
let confirm = Question::confirm(name)
|
||||
.message(message)
|
||||
.default(true)
|
||||
.build();
|
||||
let answer = prompt_one(confirm)?;
|
||||
Ok(answer.as_bool().unwrap())
|
||||
}
|
||||
|
||||
fn get_users_to_add(users: &[User], existing_users: &[String]) -> Result<Option<Vec<User>>> {
|
||||
let existing_users = HashSet::<&String>::from_iter(existing_users);
|
||||
let num_found_users = users.len();
|
||||
let input_users: Vec<_> = users
|
||||
.iter()
|
||||
.filter(|u| !existing_users.contains(&u.user_input.id))
|
||||
.map(User::clone)
|
||||
.collect();
|
||||
println!(
|
||||
"Found {} users, of which {} new users: [\n {}\n]",
|
||||
num_found_users,
|
||||
input_users.len(),
|
||||
input_users
|
||||
.iter()
|
||||
.map(|u| format!(
|
||||
"\"{}\" ({})",
|
||||
&u.user_input.id,
|
||||
if u.password.is_some() {
|
||||
"with password"
|
||||
} else {
|
||||
"no password"
|
||||
}
|
||||
))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",\n ")
|
||||
);
|
||||
if !input_users.is_empty()
|
||||
&& ask_generic_confirmation(
|
||||
"proceed_users",
|
||||
"Do you want to proceed to add those users to LLDAP?",
|
||||
)?
|
||||
{
|
||||
Ok(Some(input_users))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn should_insert_groups(
|
||||
input_groups: &[LdapGroup],
|
||||
existing_groups: &[LldapGroup],
|
||||
) -> Result<bool> {
|
||||
let existing_group_names =
|
||||
HashSet::<&str>::from_iter(existing_groups.iter().map(|g| g.display_name.as_str()));
|
||||
let new_groups = input_groups
|
||||
.iter()
|
||||
.filter(|g| !existing_group_names.contains(g.name.as_str()));
|
||||
let num_new_groups = new_groups.clone().count();
|
||||
println!(
|
||||
"Found {} groups, of which {} new groups: [\n {}\n]",
|
||||
input_groups.len(),
|
||||
num_new_groups,
|
||||
new_groups
|
||||
.map(|g| g.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",\n ")
|
||||
);
|
||||
Ok(num_new_groups != 0
|
||||
&& ask_generic_confirmation(
|
||||
"proceed_groups",
|
||||
"Do you want to proceed to add those groups to LLDAP?",
|
||||
)?)
|
||||
}
|
||||
|
||||
struct GroupList {
|
||||
ldap_groups: Vec<LdapGroup>,
|
||||
lldap_groups: Vec<LldapGroup>,
|
||||
}
|
||||
|
||||
fn migrate_groups(
|
||||
graphql_client: &lldap::GraphQLClient,
|
||||
ldap_connection: &mut ldap::LdapClient,
|
||||
) -> Result<Option<GroupList>> {
|
||||
Ok(
|
||||
if ask_generic_confirmation("should_import_groups", "Do you want to import groups?")? {
|
||||
let mut existing_groups = lldap::get_lldap_groups(graphql_client)?;
|
||||
let ldap_groups = ldap::get_groups(ldap_connection)?;
|
||||
if should_insert_groups(&ldap_groups, &existing_groups)? {
|
||||
lldap::insert_groups_into_lldap(
|
||||
&ldap_groups,
|
||||
&mut existing_groups,
|
||||
graphql_client,
|
||||
)?;
|
||||
}
|
||||
Some(GroupList {
|
||||
ldap_groups,
|
||||
lldap_groups: existing_groups,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
struct UserList {
|
||||
lldap_users: Vec<String>,
|
||||
ldap_users: Vec<User>,
|
||||
}
|
||||
|
||||
fn migrate_users(
|
||||
graphql_client: &lldap::GraphQLClient,
|
||||
ldap_connection: &mut ldap::LdapClient,
|
||||
) -> Result<Option<UserList>> {
|
||||
Ok(
|
||||
if ask_generic_confirmation("should_import_users", "Do you want to import users?")? {
|
||||
let mut existing_users = lldap::get_lldap_users(graphql_client)?;
|
||||
let users = ldap::get_users(ldap_connection)?;
|
||||
if let Some(users_to_add) = get_users_to_add(&users, &existing_users)? {
|
||||
lldap::insert_users_into_lldap(users_to_add, &mut existing_users, graphql_client)?;
|
||||
}
|
||||
Some(UserList {
|
||||
lldap_users: existing_users,
|
||||
ldap_users: users,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn migrate_memberships(
|
||||
user_list: Option<UserList>,
|
||||
group_list: Option<GroupList>,
|
||||
graphql_client: lldap::GraphQLClient,
|
||||
ldap_connection: &mut ldap::LdapClient,
|
||||
) -> Result<()> {
|
||||
let (ldap_users, existing_users) = user_list
|
||||
.map(
|
||||
|UserList {
|
||||
ldap_users,
|
||||
lldap_users,
|
||||
}| (Some(ldap_users), Some(lldap_users)),
|
||||
)
|
||||
.unwrap_or_default();
|
||||
let (ldap_groups, existing_groups) = group_list
|
||||
.map(
|
||||
|GroupList {
|
||||
ldap_groups,
|
||||
lldap_groups,
|
||||
}| (Some(ldap_groups), Some(lldap_groups)),
|
||||
)
|
||||
.unwrap_or_default();
|
||||
let ldap_users = ldap_users
|
||||
.ok_or_else(|| anyhow!("Missing LDAP users"))
|
||||
.or_else(|_| ldap::get_users(ldap_connection))?;
|
||||
let ldap_groups = ldap_groups
|
||||
.ok_or_else(|| anyhow!("Missing LDAP groups"))
|
||||
.or_else(|_| ldap::get_groups(ldap_connection))?;
|
||||
let existing_groups = existing_groups
|
||||
.ok_or_else(|| anyhow!("Missing LLDAP groups"))
|
||||
.or_else(|_| lldap::get_lldap_groups(&graphql_client))?;
|
||||
let existing_users = existing_users
|
||||
.ok_or_else(|| anyhow!("Missing LLDAP users"))
|
||||
.or_else(|_| lldap::get_lldap_users(&graphql_client))?;
|
||||
lldap::insert_group_memberships_into_lldap(
|
||||
&ldap_users,
|
||||
&ldap_groups,
|
||||
&existing_users,
|
||||
&existing_groups,
|
||||
&graphql_client,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
println!(
|
||||
"The migration tool requires access to both the original LDAP \
|
||||
server and the HTTP API of the target LLDAP server."
|
||||
);
|
||||
if !ask_generic_confirmation("setup_ready", "Are you ready to start?")? {
|
||||
return Ok(());
|
||||
}
|
||||
let mut ldap_connection = ldap::get_ldap_connection()?;
|
||||
let graphql_client = lldap::get_lldap_client()?;
|
||||
let user_list = migrate_users(&graphql_client, &mut ldap_connection)?;
|
||||
let group_list = migrate_groups(&graphql_client, &mut ldap_connection)?;
|
||||
if ask_generic_confirmation(
|
||||
"should_import_memberships",
|
||||
"Do you want to import group memberships?",
|
||||
)? {
|
||||
migrate_memberships(user_list, group_list, graphql_client, &mut ldap_connection)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
25
prepare-release.sh
Executable file
25
prepare-release.sh
Executable file
@@ -0,0 +1,25 @@
|
||||
#! /bin/sh
|
||||
|
||||
set -e
|
||||
set -x
|
||||
|
||||
# Build the binary server, for x86_64.
|
||||
cargo build --release -p lldap
|
||||
|
||||
cargo install cross
|
||||
cross build --target=armv7-unknown-linux-musleabihf -p lldap --release
|
||||
|
||||
# Build the frontend.
|
||||
./app/build.sh
|
||||
|
||||
VERSION=$(git describe --tags)
|
||||
|
||||
mkdir -p /tmp/release/x86_64
|
||||
cp target/release/lldap /tmp/release/x86_64
|
||||
cp -R app/index.html app/main.js app/pkg lldap_config.docker_template.toml README.md LICENSE /tmp/release/x86_64
|
||||
tar -czvf lldap-x86_64-${VERSION}.tar.gz /tmp/release/x86_64
|
||||
|
||||
mkdir -p /tmp/release/armv7
|
||||
cp target/armv7-unknown-linux-musleabihf/release/lldap /tmp/release/armv7
|
||||
cp -R app/index.html app/main.js app/pkg lldap_config.docker_template.toml README.md LICENSE /tmp/release/armv7
|
||||
tar -czvf lldap-armv7-${VERSION}.tar.gz /tmp/release/armv7
|
||||
@@ -17,6 +17,8 @@ type Mutation {
|
||||
type Group {
|
||||
id: Int!
|
||||
displayName: String!
|
||||
creationDate: DateTimeUtc!
|
||||
uuid: String!
|
||||
"The groups to which this user belongs."
|
||||
users: [User!]!
|
||||
}
|
||||
@@ -58,6 +60,7 @@ input CreateUserInput {
|
||||
displayName: String
|
||||
firstName: String
|
||||
lastName: String
|
||||
avatar: String
|
||||
}
|
||||
|
||||
type User {
|
||||
@@ -66,7 +69,9 @@ type User {
|
||||
displayName: String!
|
||||
firstName: String!
|
||||
lastName: String!
|
||||
avatar: String!
|
||||
creationDate: DateTimeUtc!
|
||||
uuid: String!
|
||||
"The groups to which this user belongs."
|
||||
groups: [Group!]!
|
||||
}
|
||||
@@ -82,6 +87,7 @@ input UpdateUserInput {
|
||||
displayName: String
|
||||
firstName: String
|
||||
lastName: String
|
||||
avatar: String
|
||||
}
|
||||
|
||||
schema {
|
||||
|
||||
@@ -1,76 +1,128 @@
|
||||
[package]
|
||||
authors = ["Valentin Tolmer <valentin@tolmer.fr>", "Steve Barrau <steve.barrau@gmail.com>", "Thomas Wickham <mackwic@gmail.com>"]
|
||||
edition = "2018"
|
||||
authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
|
||||
edition = "2021"
|
||||
name = "lldap"
|
||||
version = "0.1.0"
|
||||
version = "0.4.1"
|
||||
|
||||
[dependencies]
|
||||
actix = "0.12"
|
||||
actix-files = "0.6.0-beta.6"
|
||||
actix-http = "3.0.0-beta.9"
|
||||
actix-http = "=3.0.0-beta.9"
|
||||
actix-rt = "2.2.0"
|
||||
actix-server = "2.0.0-beta.5"
|
||||
actix-server = "=2.0.0-beta.5"
|
||||
actix-service = "2.0.0"
|
||||
actix-web = "4.0.0-beta.8"
|
||||
actix-web = "=4.0.0-beta.8"
|
||||
actix-web-httpauth = "0.6.0-beta.2"
|
||||
anyhow = "*"
|
||||
async-trait = "0.1"
|
||||
base64 = "0.13"
|
||||
bincode = "1.3"
|
||||
chrono = { version = "*", features = [ "serde" ]}
|
||||
clap = "3.0.0-beta.4"
|
||||
cron = "*"
|
||||
derive_builder = "0.10.2"
|
||||
figment_file_provider_adapter = "0.1"
|
||||
futures = "*"
|
||||
futures-util = "*"
|
||||
hmac = "0.10"
|
||||
http = "*"
|
||||
itertools = "0.10.1"
|
||||
juniper = "0.15.10"
|
||||
juniper_actix = "0.4.0"
|
||||
jwt = "0.13"
|
||||
ldap3_server = "*"
|
||||
lldap_auth = { path = "../auth" }
|
||||
ldap3_proto = "*"
|
||||
log = "*"
|
||||
orion = "0.16"
|
||||
rustls = "0.20"
|
||||
serde = "*"
|
||||
serde_json = "1"
|
||||
sha2 = "0.9"
|
||||
sqlx-core = "=0.5.1"
|
||||
sqlx-core = "0.5.11"
|
||||
thiserror = "*"
|
||||
time = "0.2"
|
||||
tokio = { version = "1.2.0", features = ["full"] }
|
||||
tokio-util = "0.6.3"
|
||||
tokio-rustls = "0.23"
|
||||
tokio-stream = "*"
|
||||
tokio-util = "0.7.3"
|
||||
tracing = "*"
|
||||
tracing-actix-web = "0.4.0-beta.7"
|
||||
tracing-attributes = "^0.1.21"
|
||||
tracing-log = "*"
|
||||
tracing-subscriber = "*"
|
||||
rand = { version = "0.8", features = ["small_rng", "getrandom"] }
|
||||
juniper_actix = "0.4.0"
|
||||
juniper = "0.15.6"
|
||||
itertools = "0.10.1"
|
||||
rustls-pemfile = "1.0.0"
|
||||
serde_bytes = "0.11.7"
|
||||
|
||||
# TODO: update to 0.6 when out.
|
||||
[dependencies.opaque-ke]
|
||||
git = "https://github.com/novifinancial/opaque-ke"
|
||||
rev = "eb59676a940b15f77871aefe1e46d7b5bf85f40a"
|
||||
[dependencies.chrono]
|
||||
features = ["serde"]
|
||||
version = "*"
|
||||
|
||||
[dependencies.clap]
|
||||
features = ["std", "color", "suggestions", "derive", "env"]
|
||||
version = "3.1.15"
|
||||
|
||||
[dependencies.figment]
|
||||
features = ["env", "toml"]
|
||||
version = "*"
|
||||
|
||||
[dependencies.tracing-subscriber]
|
||||
version = "0.3"
|
||||
features = ["env-filter", "tracing-log"]
|
||||
|
||||
[dependencies.lettre]
|
||||
features = ["builder", "serde", "smtp-transport", "tokio1-rustls-tls"]
|
||||
default-features = false
|
||||
version = "0.10.0-rc.3"
|
||||
|
||||
[dependencies.sqlx]
|
||||
version = "0.5.1"
|
||||
version = "0.5.11"
|
||||
features = [
|
||||
"any",
|
||||
"chrono",
|
||||
"macros",
|
||||
"mysql",
|
||||
"postgres",
|
||||
"runtime-actix-native-tls",
|
||||
"runtime-actix-rustls",
|
||||
"sqlite",
|
||||
]
|
||||
|
||||
[dependencies.sea-query]
|
||||
version = "0.9.4"
|
||||
features = ["with-chrono"]
|
||||
[dependencies.lldap_auth]
|
||||
path = "../auth"
|
||||
|
||||
[dependencies.figment]
|
||||
features = ["env", "toml"]
|
||||
[dependencies.sea-query]
|
||||
version = "^0.25"
|
||||
features = ["with-chrono", "sqlx-sqlite"]
|
||||
|
||||
[dependencies.sea-query-binder]
|
||||
version = "0.1"
|
||||
features = ["with-chrono", "sqlx-sqlite", "sqlx-any"]
|
||||
|
||||
[dependencies.opaque-ke]
|
||||
version = "0.6"
|
||||
|
||||
[dependencies.rand]
|
||||
features = ["small_rng", "getrandom"]
|
||||
version = "0.8"
|
||||
|
||||
[dependencies.secstr]
|
||||
features = ["serde"]
|
||||
version = "*"
|
||||
|
||||
[dependencies.tokio]
|
||||
features = ["full"]
|
||||
version = "1.17"
|
||||
|
||||
[dependencies.uuid]
|
||||
features = ["v3"]
|
||||
version = "*"
|
||||
|
||||
[dependencies.tracing-forest]
|
||||
features = ["smallvec", "chrono", "tokio"]
|
||||
version = "^0.1.4"
|
||||
|
||||
[dependencies.actix-tls]
|
||||
features = ["default", "rustls"]
|
||||
version = "=3.0.0-beta.5"
|
||||
|
||||
[dependencies.image]
|
||||
features = ["jpeg"]
|
||||
default-features = false
|
||||
version = "0.24"
|
||||
|
||||
[dev-dependencies]
|
||||
mockall = "0.9.1"
|
||||
|
||||
@@ -3,7 +3,7 @@ use thiserror::Error;
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
#[derive(Error, Debug)]
|
||||
pub enum DomainError {
|
||||
#[error("Authentication error for `{0}`")]
|
||||
#[error("Authentication error: `{0}`")]
|
||||
AuthenticationError(String),
|
||||
#[error("Database error: `{0}`")]
|
||||
DatabaseError(#[from] sqlx::Error),
|
||||
|
||||
@@ -3,28 +3,179 @@ use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), derive(sqlx::FromRow))]
|
||||
#[derive(
|
||||
PartialEq, Hash, Eq, Clone, Debug, Default, Serialize, Deserialize, sqlx::FromRow, sqlx::Type,
|
||||
)]
|
||||
#[serde(try_from = "&str")]
|
||||
#[sqlx(transparent)]
|
||||
pub struct Uuid(String);
|
||||
|
||||
impl Uuid {
|
||||
pub fn from_name_and_date(name: &str, creation_date: &chrono::DateTime<chrono::Utc>) -> Self {
|
||||
Uuid(
|
||||
uuid::Uuid::new_v3(
|
||||
&uuid::Uuid::NAMESPACE_X500,
|
||||
&[name.as_bytes(), creation_date.to_rfc3339().as_bytes()].concat(),
|
||||
)
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn into_string(self) -> String {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> std::convert::TryFrom<&'a str> for Uuid {
|
||||
type Error = anyhow::Error;
|
||||
fn try_from(s: &'a str) -> anyhow::Result<Self> {
|
||||
Ok(Uuid(uuid::Uuid::parse_str(s)?.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::string::ToString for Uuid {
|
||||
fn to_string(&self) -> String {
|
||||
self.0.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[macro_export]
|
||||
macro_rules! uuid {
|
||||
($s:literal) => {
|
||||
$crate::domain::handler::Uuid::try_from($s).unwrap()
|
||||
};
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Clone, Debug, Default, Serialize, Deserialize, sqlx::Type)]
|
||||
#[serde(from = "String")]
|
||||
#[sqlx(transparent)]
|
||||
pub struct UserId(String);
|
||||
|
||||
impl UserId {
|
||||
pub fn new(user_id: &str) -> Self {
|
||||
Self(user_id.to_lowercase())
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
self.0.as_str()
|
||||
}
|
||||
|
||||
pub fn into_string(self) -> String {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for UserId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for UserId {
|
||||
fn from(s: String) -> Self {
|
||||
Self::new(&s)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Clone, Debug, Default, Serialize, Deserialize, sqlx::Type)]
|
||||
#[sqlx(transparent)]
|
||||
pub struct JpegPhoto(#[serde(with = "serde_bytes")] Vec<u8>);
|
||||
|
||||
impl From<JpegPhoto> for sea_query::Value {
|
||||
fn from(photo: JpegPhoto) -> Self {
|
||||
photo.0.into()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&JpegPhoto> for sea_query::Value {
|
||||
fn from(photo: &JpegPhoto) -> Self {
|
||||
photo.0.as_slice().into()
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<Vec<u8>> for JpegPhoto {
|
||||
type Error = anyhow::Error;
|
||||
fn try_from(bytes: Vec<u8>) -> anyhow::Result<Self> {
|
||||
// Confirm that it's a valid Jpeg, then store only the bytes.
|
||||
image::io::Reader::with_format(
|
||||
std::io::Cursor::new(bytes.as_slice()),
|
||||
image::ImageFormat::Jpeg,
|
||||
)
|
||||
.decode()?;
|
||||
Ok(JpegPhoto(bytes))
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<String> for JpegPhoto {
|
||||
type Error = anyhow::Error;
|
||||
fn try_from(string: String) -> anyhow::Result<Self> {
|
||||
// The String format is in base64.
|
||||
Self::try_from(base64::decode(string.as_str())?)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&JpegPhoto> for String {
|
||||
fn from(val: &JpegPhoto) -> Self {
|
||||
base64::encode(&val.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl JpegPhoto {
|
||||
pub fn into_bytes(self) -> Vec<u8> {
|
||||
self.0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn for_tests() -> Self {
|
||||
use image::{ImageOutputFormat, Rgb, RgbImage};
|
||||
let img = RgbImage::from_fn(32, 32, |x, y| {
|
||||
if (x + y) % 2 == 0 {
|
||||
Rgb([0, 0, 0])
|
||||
} else {
|
||||
Rgb([255, 255, 255])
|
||||
}
|
||||
});
|
||||
let mut bytes: Vec<u8> = Vec::new();
|
||||
img.write_to(
|
||||
&mut std::io::Cursor::new(&mut bytes),
|
||||
ImageOutputFormat::Jpeg(0),
|
||||
)
|
||||
.unwrap();
|
||||
Self(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct User {
|
||||
pub user_id: String,
|
||||
pub user_id: UserId,
|
||||
pub email: String,
|
||||
pub display_name: String,
|
||||
pub first_name: String,
|
||||
pub last_name: String,
|
||||
// pub avatar: ?,
|
||||
pub avatar: JpegPhoto,
|
||||
pub creation_date: chrono::DateTime<chrono::Utc>,
|
||||
pub uuid: Uuid,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Default for User {
|
||||
fn default() -> Self {
|
||||
use chrono::TimeZone;
|
||||
let epoch = chrono::Utc.timestamp(0, 0);
|
||||
User {
|
||||
user_id: String::new(),
|
||||
user_id: UserId::default(),
|
||||
email: String::new(),
|
||||
display_name: String::new(),
|
||||
first_name: String::new(),
|
||||
last_name: String::new(),
|
||||
creation_date: chrono::Utc.timestamp(0, 0),
|
||||
avatar: JpegPhoto::default(),
|
||||
creation_date: epoch,
|
||||
uuid: Uuid::from_name_and_date("", &epoch),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,20 +184,23 @@ impl Default for User {
|
||||
pub struct Group {
|
||||
pub id: GroupId,
|
||||
pub display_name: String,
|
||||
pub users: Vec<String>,
|
||||
pub creation_date: chrono::DateTime<chrono::Utc>,
|
||||
pub uuid: Uuid,
|
||||
pub users: Vec<UserId>,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct BindRequest {
|
||||
pub name: String,
|
||||
pub name: UserId,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
|
||||
pub enum RequestFilter {
|
||||
And(Vec<RequestFilter>),
|
||||
Or(Vec<RequestFilter>),
|
||||
Not(Box<RequestFilter>),
|
||||
pub enum UserRequestFilter {
|
||||
And(Vec<UserRequestFilter>),
|
||||
Or(Vec<UserRequestFilter>),
|
||||
Not(Box<UserRequestFilter>),
|
||||
UserId(UserId),
|
||||
Equality(String, String),
|
||||
// Check if a user belongs to a group identified by name.
|
||||
MemberOf(String),
|
||||
@@ -54,24 +208,38 @@ pub enum RequestFilter {
|
||||
MemberOfId(GroupId),
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
|
||||
pub enum GroupRequestFilter {
|
||||
And(Vec<GroupRequestFilter>),
|
||||
Or(Vec<GroupRequestFilter>),
|
||||
Not(Box<GroupRequestFilter>),
|
||||
DisplayName(String),
|
||||
Uuid(Uuid),
|
||||
GroupId(GroupId),
|
||||
// Check if the group contains a user identified by uid.
|
||||
Member(UserId),
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone, Default)]
|
||||
pub struct CreateUserRequest {
|
||||
// Same fields as User, but no creation_date, and with password.
|
||||
pub user_id: String,
|
||||
pub user_id: UserId,
|
||||
pub email: String,
|
||||
pub display_name: Option<String>,
|
||||
pub first_name: Option<String>,
|
||||
pub last_name: Option<String>,
|
||||
pub avatar: Option<JpegPhoto>,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone, Default)]
|
||||
pub struct UpdateUserRequest {
|
||||
// Same fields as CreateUserRequest, but no with an extra layer of Option.
|
||||
pub user_id: String,
|
||||
pub user_id: UserId,
|
||||
pub email: Option<String>,
|
||||
pub display_name: Option<String>,
|
||||
pub first_name: Option<String>,
|
||||
pub last_name: Option<String>,
|
||||
pub avatar: Option<JpegPhoto>,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
|
||||
@@ -85,27 +253,43 @@ pub trait LoginHandler: Clone + Send {
|
||||
async fn bind(&self, request: BindRequest) -> Result<()>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
|
||||
#[sqlx(transparent)]
|
||||
pub struct GroupId(pub i32);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct GroupIdAndName(pub GroupId, pub String);
|
||||
pub struct GroupDetails {
|
||||
pub group_id: GroupId,
|
||||
pub display_name: String,
|
||||
pub creation_date: chrono::DateTime<chrono::Utc>,
|
||||
pub uuid: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UserAndGroups {
|
||||
pub user: User,
|
||||
pub groups: Option<Vec<GroupDetails>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait BackendHandler: Clone + Send {
|
||||
async fn list_users(&self, filters: Option<RequestFilter>) -> Result<Vec<User>>;
|
||||
async fn list_groups(&self) -> Result<Vec<Group>>;
|
||||
async fn get_user_details(&self, user_id: &str) -> Result<User>;
|
||||
async fn get_group_details(&self, group_id: GroupId) -> Result<GroupIdAndName>;
|
||||
async fn list_users(
|
||||
&self,
|
||||
filters: Option<UserRequestFilter>,
|
||||
get_groups: bool,
|
||||
) -> Result<Vec<UserAndGroups>>;
|
||||
async fn list_groups(&self, filters: Option<GroupRequestFilter>) -> Result<Vec<Group>>;
|
||||
async fn get_user_details(&self, user_id: &UserId) -> Result<User>;
|
||||
async fn get_group_details(&self, group_id: GroupId) -> Result<GroupDetails>;
|
||||
async fn create_user(&self, request: CreateUserRequest) -> Result<()>;
|
||||
async fn update_user(&self, request: UpdateUserRequest) -> Result<()>;
|
||||
async fn update_group(&self, request: UpdateGroupRequest) -> Result<()>;
|
||||
async fn delete_user(&self, user_id: &str) -> Result<()>;
|
||||
async fn delete_user(&self, user_id: &UserId) -> Result<()>;
|
||||
async fn create_group(&self, group_name: &str) -> Result<GroupId>;
|
||||
async fn delete_group(&self, group_id: GroupId) -> Result<()>;
|
||||
async fn add_user_to_group(&self, user_id: &str, group_id: GroupId) -> Result<()>;
|
||||
async fn remove_user_from_group(&self, user_id: &str, group_id: GroupId) -> Result<()>;
|
||||
async fn get_user_groups(&self, user: &str) -> Result<HashSet<GroupIdAndName>>;
|
||||
async fn add_user_to_group(&self, user_id: &UserId, group_id: GroupId) -> Result<()>;
|
||||
async fn remove_user_from_group(&self, user_id: &UserId, group_id: GroupId) -> Result<()>;
|
||||
async fn get_user_groups(&self, user_id: &UserId) -> Result<HashSet<GroupDetails>>;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -116,22 +300,45 @@ mockall::mock! {
|
||||
}
|
||||
#[async_trait]
|
||||
impl BackendHandler for TestBackendHandler {
|
||||
async fn list_users(&self, filters: Option<RequestFilter>) -> Result<Vec<User>>;
|
||||
async fn list_groups(&self) -> Result<Vec<Group>>;
|
||||
async fn get_user_details(&self, user_id: &str) -> Result<User>;
|
||||
async fn get_group_details(&self, group_id: GroupId) -> Result<GroupIdAndName>;
|
||||
async fn list_users(&self, filters: Option<UserRequestFilter>, get_groups: bool) -> Result<Vec<UserAndGroups>>;
|
||||
async fn list_groups(&self, filters: Option<GroupRequestFilter>) -> Result<Vec<Group>>;
|
||||
async fn get_user_details(&self, user_id: &UserId) -> Result<User>;
|
||||
async fn get_group_details(&self, group_id: GroupId) -> Result<GroupDetails>;
|
||||
async fn create_user(&self, request: CreateUserRequest) -> Result<()>;
|
||||
async fn update_user(&self, request: UpdateUserRequest) -> Result<()>;
|
||||
async fn update_group(&self, request: UpdateGroupRequest) -> Result<()>;
|
||||
async fn delete_user(&self, user_id: &str) -> Result<()>;
|
||||
async fn delete_user(&self, user_id: &UserId) -> Result<()>;
|
||||
async fn create_group(&self, group_name: &str) -> Result<GroupId>;
|
||||
async fn delete_group(&self, group_id: GroupId) -> Result<()>;
|
||||
async fn get_user_groups(&self, user: &str) -> Result<HashSet<GroupIdAndName>>;
|
||||
async fn add_user_to_group(&self, user_id: &str, group_id: GroupId) -> Result<()>;
|
||||
async fn remove_user_from_group(&self, user_id: &str, group_id: GroupId) -> Result<()>;
|
||||
async fn get_user_groups(&self, user_id: &UserId) -> Result<HashSet<GroupDetails>>;
|
||||
async fn add_user_to_group(&self, user_id: &UserId, group_id: GroupId) -> Result<()>;
|
||||
async fn remove_user_from_group(&self, user_id: &UserId, group_id: GroupId) -> Result<()>;
|
||||
}
|
||||
#[async_trait]
|
||||
impl LoginHandler for TestBackendHandler {
|
||||
async fn bind(&self, request: BindRequest) -> Result<()>;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn test_uuid_time() {
|
||||
use chrono::prelude::*;
|
||||
let user_id = "bob";
|
||||
let date1 = Utc.ymd(2014, 7, 8).and_hms(9, 10, 11);
|
||||
let date2 = Utc.ymd(2014, 7, 8).and_hms(9, 10, 12);
|
||||
assert_ne!(
|
||||
Uuid::from_name_and_date(user_id, &date1),
|
||||
Uuid::from_name_and_date(user_id, &date2)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jpeg_try_from_bytes() {
|
||||
let base64_raw = "/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////2wBDAf//////////////////////////////////////////////////////////////////////////////////////wAARCADqATkDASIAAhEBAxEB/8QAFwABAQEBAAAAAAAAAAAAAAAAAAECA//EACQQAQEBAAIBBAMBAQEBAAAAAAABESExQQISUXFhgZGxocHw/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAH/xAAWEQEBAQAAAAAAAAAAAAAAAAAAEQH/2gAMAwEAAhEDEQA/AMriLyCKgg1gQwCgs4FTMOdutepjQak+FzMSVqgxZdRdPPIIvH5WzzGdBriphtTeAXg2ZjKA1pqKDUGZca3foBek8gFv8Ie3fKdA1qb8s7hoL6eLVt51FsAnql3Ut1M7AWbflLMDkEMX/F6/YjK/pADFQAUNA6alYagKk72m/j9p4Bq2fDDSYKLNXPNLoHE/NT6RYC31cJxZ3yWVM+aBYi/S2ZgiAsnYJx5D21vPmqrm3PTfpQQwyAC8JZvSKDni41ZrMuUVVl+Uz9w9v/1QWrZsZ5nFPHYH+JZyureQSF5M+fJ0CAfwRAVRBQA1DAWVUayoJUWoDpsxntPsueBV4+VxhdyAtv8AjOLGpIDMLbeGvbF4iozJfr/WukAVABAXAQXEAAASzVAZdO2WNordm+emFl7XcQSNZiFtv0C9w90nhJf4mA1u+GcJFwIyAqL/AOovwgGNfSRqdIrNa29M0gKCAojU9PAMjWXpckEJFNFEAAXEUBABYz6rZ0ureQc9vyt9XxDF2QAXtABcQAs0AZywkvluJbyipifas52DcyxjlZweAO0xri/hc+wZOEKIu6nSyeToVZyWXwvCg53gW81QQ7aTNAn5dGZJPs1UXURQAUEMCXQLZE93PRZ5hPTgNMrbIzKCm52LZwCs+2M8w2g3sjPuZAXb4IsMAUACzVUGM4/K+md6vEXUUyM5PDR0IxYe6ramih0VNBrS4xoqN8Q1BFQk3yqyAsioioAAKgDSJL4/jQIn5igLrPqtOuf6oOaxbMoAltUAhhIoJiiggrPu+AaOIxtAX3JbaAIaLwi4t9X4T3fg2AFtqcrUUarP20zUDAmqoE0WRBZPNVUVEAAAAVAC8kvih2DSKxOdBqs7Z0l0gI0mKAC4AuHE7ZtBriM+744QAAAAABAFsveIttBICyaikvy1+r/Cen5rWQHIBQa4rIDRqSl5qDWqziqgAAAATA7BpGdqXb2C2+J/UgAtRQBSQtkBWb6vhLbQAAAAAEBRAAAAAUbm+GZNdPxAP+ql2Tjwx7/wIgZ8iKvBk+CJoCXii9gaqZ/qqihAAAEVABGkBFUwBftNkZ3QW34QAAABFAQAVAAAAAARVkl8gs/43sk1jL45LvHArepk+E9XTG35oLqsmIKmLAEygKg0y1AFQBUXwgAAAoBC34S3UAAABAVAAAAAABAUQAVABdRQa1PcYyit2z58M8C4ouM2NXpOEGeWtNZUatiAIoAKIoCoAoG4C9MW6dgIoAIAAAAAAACKWAgL0CAAAALiANCKioNLgM1CrLihmTafkt1EF3SZ5ZVUW4mnIKvAi5fhEURVDWVQBRAAAAAAAAQFRVyAyulgAqCKlF8IqLsEgC9mGoC+IusqCrv5ZEUVOk1RuJfwSLOOkGFi4XPCoYYrNiKauosBGi9ICstM1UAAAAAAFQ0VcTBAXUGgIqGoKhKAzRRUQUAwxoSrGRpkQA/qiosOL9oJptMRRVZa0VUqSiChE6BqMgCwqKqIogAIAqKCKgKoogg0lBFuIKgAAAKNRlf2gqsftsEtZWoAAqAACKoMqAAeSoqp39kL2AqLOlE8rEBFQARYALhigrNC9gGmooLp4TweEQFFBFAECgIoAu0ifIAqAAA//9k=";
|
||||
let base64_jpeg = base64::decode(base64_raw).unwrap();
|
||||
JpegPhoto::try_from(base64_jpeg).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user