Compare commits
100 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7119d96712 | ||
|
|
2973529c97 | ||
|
|
442c70b6d2 | ||
|
|
64140b4939 | ||
|
|
6ebeee4126 | ||
|
|
a05ae617a1 | ||
|
|
7538059f6a | ||
|
|
ee4a62e1e2 | ||
|
|
8a6ce87fb5 | ||
|
|
af670dbc93 | ||
|
|
5840b3009d | ||
|
|
18f814ba02 | ||
|
|
b55caae3cc | ||
|
|
93b4840e93 | ||
|
|
e0e0da9ebf | ||
|
|
3316f54133 | ||
|
|
c012c2891b | ||
|
|
d459ac0c78 | ||
|
|
c9f9a687a3 | ||
|
|
4c47d06c9b | ||
|
|
e88db526b4 | ||
|
|
e947b8eef0 | ||
|
|
ee72b571d0 | ||
|
|
cf492db570 | ||
|
|
6120a0dca5 | ||
|
|
523d418459 | ||
|
|
55225bc15b | ||
|
|
bd0a58b476 | ||
|
|
4adb636d53 | ||
|
|
6f905b1ca9 | ||
|
|
2ea17c04ba | ||
|
|
10609b25e9 | ||
|
|
9f8364ca1a | ||
|
|
56078c0b47 | ||
|
|
8b7852bf1c | ||
|
|
c4be7f5b6f | ||
|
|
337101edea | ||
|
|
dc140f1675 | ||
|
|
f74f88f0c0 | ||
|
|
708d927e90 | ||
|
|
0d48b7f8c9 | ||
|
|
f2b1e73929 | ||
|
|
997119cdcf | ||
|
|
a147085a2f | ||
|
|
f363ff9437 | ||
|
|
b6e6269956 | ||
|
|
ff0ea51121 | ||
|
|
9ac96e8c6e | ||
|
|
63f802648f | ||
|
|
1aba962cd3 | ||
|
|
06697a5305 | ||
|
|
5a5d5b1d0e | ||
|
|
2e0d65e665 | ||
|
|
2c54ad895d | ||
|
|
272c84c574 | ||
|
|
71d37b9e5e | ||
|
|
c55e0f3bcf | ||
|
|
f2946e6cf6 | ||
|
|
f3e2f8c52d | ||
|
|
70d85524db | ||
|
|
ec0737c58a | ||
|
|
33f50d13a2 | ||
|
|
5cd4499328 | ||
|
|
a65ad14349 | ||
|
|
2ca5e9e720 | ||
|
|
4f72153bd4 | ||
|
|
829c3f2bb1 | ||
|
|
a6481dde56 | ||
|
|
35146ac904 | ||
|
|
d488802e68 | ||
|
|
927c79bb55 | ||
|
|
3b6f24dd17 | ||
|
|
8ab900dfce | ||
|
|
504227eb13 | ||
|
|
1b97435853 | ||
|
|
1fddd87470 | ||
|
|
af8277dbbd | ||
|
|
609d0ddb7d | ||
|
|
3df42ae707 | ||
|
|
8f9520b640 | ||
|
|
7c9f61e2eb | ||
|
|
5275af8f96 | ||
|
|
0db41f6278 | ||
|
|
4574538c76 | ||
|
|
9d5714ee0b | ||
|
|
c6ecf8d58a | ||
|
|
9e88bfe6b4 | ||
|
|
5bd81780b3 | ||
|
|
4fd71ff02f | ||
|
|
f0046692b8 | ||
|
|
439fde434b | ||
|
|
2a5fd01439 | ||
|
|
2c398d0e8e | ||
|
|
93e9985a81 | ||
|
|
ed3be02384 | ||
|
|
3fadfb1944 | ||
|
|
81204dcee5 | ||
|
|
39a75b2c35 | ||
|
|
8e1515c27b | ||
|
|
ddfd719884 |
@@ -1,4 +1,4 @@
|
||||
FROM rust:1.72
|
||||
FROM rust:1.74
|
||||
|
||||
ARG USERNAME=lldapdev
|
||||
# We need to keep the user as 1001 to match the GitHub runner's UID.
|
||||
|
||||
29
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
29
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: "[BUG]"
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Logs**
|
||||
If applicable, add logs to explain the problem.
|
||||
LLDAP should be started in verbose mode (`LLDAP_VERBOSE=true` env variable, or `verbose = true` in the config). Include the logs in triple-backtick "```"
|
||||
If integrating with another service, please add its configuration (paste it or screenshot it) as well as any useful logs or screenshots (showing the error, for instance).
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: "[FEATURE REQUEST]"
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Motivation**
|
||||
Why do you want the feature? What problem do you have, what use cases would it enable?
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered. You can include workarounds that are currently possible.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
25
.github/ISSUE_TEMPLATE/integration-request.md
vendored
Normal file
25
.github/ISSUE_TEMPLATE/integration-request.md
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
name: Integration request
|
||||
about: Request for integration with a service
|
||||
title: "[INTEGRATION]"
|
||||
labels: integration
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Checklist**
|
||||
- [ ] Check if there is already an [example config](https://github.com/lldap/lldap/tree/main/example_configs) for it.
|
||||
- [ ] Try to figure out the configuration values for the new service yourself.
|
||||
- You can use other example configs for inspiration.
|
||||
- If you're having trouble, you can ask on [Discord](https://discord.gg/h5PEdRMNyP) or create an issue.
|
||||
- If you succeed, make sure to contribute an example configuration, or a configuration guide.
|
||||
- If you hit a block because of an unimplemented feature, create an issue.
|
||||
|
||||
**Description of the service**
|
||||
Quick summary of what the service is and how it's using LDAP. Link to the service's documentation on configuring LDAP.
|
||||
|
||||
**What you've tried**
|
||||
A sample configuration that you've tried.
|
||||
|
||||
**What's not working**
|
||||
Error logs, error screenshots, features that are not working, missing features.
|
||||
11
.github/codecov.yml
vendored
11
.github/codecov.yml
vendored
@@ -1,10 +1,19 @@
|
||||
codecov:
|
||||
require_ci_to_pass: yes
|
||||
comment:
|
||||
layout: "diff,flags"
|
||||
layout: "header,diff,files"
|
||||
require_changes: true
|
||||
require_base: true
|
||||
require_head: true
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
target: "75%"
|
||||
threshold: "0.1%"
|
||||
removed_code_behavior: adjust_base
|
||||
github_checks:
|
||||
annotations: true
|
||||
ignore:
|
||||
- "app"
|
||||
- "docs"
|
||||
|
||||
94
.github/workflows/Dockerfile.ci.alpine
vendored
94
.github/workflows/Dockerfile.ci.alpine
vendored
@@ -1,72 +1,6 @@
|
||||
FROM debian:bullseye AS lldap
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
ARG TARGETPLATFORM
|
||||
RUN apt update && apt install -y wget
|
||||
WORKDIR /dim
|
||||
COPY bin/ bin/
|
||||
COPY web/ web/
|
||||
|
||||
RUN mkdir -p target/
|
||||
RUN mkdir -p /lldap/app
|
||||
|
||||
RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \
|
||||
mv bin/x86_64-unknown-linux-musl-lldap-bin/lldap target/lldap && \
|
||||
mv bin/x86_64-unknown-linux-musl-lldap_migration_tool-bin/lldap_migration_tool target/lldap_migration_tool && \
|
||||
mv bin/x86_64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
|
||||
chmod +x target/lldap && \
|
||||
chmod +x target/lldap_migration_tool && \
|
||||
chmod +x target/lldap_set_password && \
|
||||
ls -la target/ . && \
|
||||
pwd \
|
||||
; fi
|
||||
|
||||
RUN if [ "${TARGETPLATFORM}" = "linux/arm64" ]; then \
|
||||
mv bin/aarch64-unknown-linux-musl-lldap-bin/lldap target/lldap && \
|
||||
mv bin/aarch64-unknown-linux-musl-lldap_migration_tool-bin/lldap_migration_tool target/lldap_migration_tool && \
|
||||
mv bin/aarch64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
|
||||
chmod +x target/lldap && \
|
||||
chmod +x target/lldap_migration_tool && \
|
||||
chmod +x target/lldap_set_password && \
|
||||
ls -la target/ . && \
|
||||
pwd \
|
||||
; fi
|
||||
|
||||
RUN if [ "${TARGETPLATFORM}" = "linux/arm/v7" ]; then \
|
||||
mv bin/armv7-unknown-linux-musleabihf-lldap-bin/lldap target/lldap && \
|
||||
mv bin/armv7-unknown-linux-musleabihf-lldap_migration_tool-bin/lldap_migration_tool target/lldap_migration_tool && \
|
||||
mv bin/armv7-unknown-linux-musleabihf-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
|
||||
chmod +x target/lldap && \
|
||||
chmod +x target/lldap_migration_tool && \
|
||||
chmod +x target/lldap_set_password && \
|
||||
ls -la target/ . && \
|
||||
pwd \
|
||||
; fi
|
||||
|
||||
# Web and App dir
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
COPY lldap_config.docker_template.toml /lldap/
|
||||
COPY web/index_local.html web/index.html
|
||||
RUN cp target/lldap /lldap/ && \
|
||||
cp target/lldap_migration_tool /lldap/ && \
|
||||
cp target/lldap_set_password /lldap/ && \
|
||||
cp -R web/index.html \
|
||||
web/pkg \
|
||||
web/static \
|
||||
/lldap/app/
|
||||
|
||||
WORKDIR /lldap
|
||||
RUN set -x \
|
||||
&& for file in $(cat /lldap/app/static/libraries.txt); do wget -P app/static "$file"; done \
|
||||
&& for file in $(cat /lldap/app/static/fonts/fonts.txt); do wget -P app/static/fonts "$file"; done \
|
||||
&& chmod a+r -R .
|
||||
|
||||
FROM alpine:3.16
|
||||
WORKDIR /app
|
||||
ENV UID=1000
|
||||
ENV GID=1000
|
||||
ENV USER=lldap
|
||||
ENV GOSU_VERSION 1.14
|
||||
# Fetch gosu from git
|
||||
FROM localhost:5000/lldap/lldap:alpine-base
|
||||
# Taken directly from https://github.com/tianon/gosu/blob/master/INSTALL.md
|
||||
ENV GOSU_VERSION 1.17
|
||||
RUN set -eux; \
|
||||
\
|
||||
apk add --no-cache --virtual .gosu-deps \
|
||||
@@ -83,7 +17,7 @@ RUN set -eux; \
|
||||
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 || :; \
|
||||
gpgconf --kill all; \
|
||||
rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \
|
||||
\
|
||||
# clean up fetch dependencies
|
||||
@@ -93,22 +27,4 @@ RUN set -eux; \
|
||||
# verify that the binary works
|
||||
gosu --version; \
|
||||
gosu nobody true
|
||||
RUN apk add --no-cache tini ca-certificates bash tzdata && \
|
||||
addgroup -g $GID $USER && \
|
||||
adduser \
|
||||
--disabled-password \
|
||||
--gecos "" \
|
||||
--home "$(pwd)" \
|
||||
--ingroup "$USER" \
|
||||
--no-create-home \
|
||||
--uid "$UID" \
|
||||
"$USER" && \
|
||||
mkdir -p /data && \
|
||||
chown $USER:$USER /data
|
||||
COPY --from=lldap --chown=$USER:$USER /lldap /app
|
||||
COPY --from=lldap --chown=$USER:$USER /docker-entrypoint.sh /docker-entrypoint.sh
|
||||
VOLUME ["/data"]
|
||||
WORKDIR /app
|
||||
ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"]
|
||||
CMD ["run", "--config-file", "/data/lldap_config.toml"]
|
||||
HEALTHCHECK CMD ["/app/lldap", "healthcheck", "--config-file", "/data/lldap_config.toml"]
|
||||
COPY --chown=$USER:$USER docker-entrypoint.sh /docker-entrypoint.sh
|
||||
|
||||
84
.github/workflows/Dockerfile.ci.alpine-base
vendored
Normal file
84
.github/workflows/Dockerfile.ci.alpine-base
vendored
Normal file
@@ -0,0 +1,84 @@
|
||||
FROM debian:bullseye AS lldap
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
ARG TARGETPLATFORM
|
||||
RUN apt update && apt install -y wget
|
||||
WORKDIR /dim
|
||||
COPY bin/ bin/
|
||||
COPY web/ web/
|
||||
|
||||
RUN mkdir -p target/
|
||||
RUN mkdir -p /lldap/app
|
||||
|
||||
RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \
|
||||
mv bin/x86_64-unknown-linux-musl-lldap-bin/lldap target/lldap && \
|
||||
mv bin/x86_64-unknown-linux-musl-lldap_migration_tool-bin/lldap_migration_tool target/lldap_migration_tool && \
|
||||
mv bin/x86_64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
|
||||
chmod +x target/lldap && \
|
||||
chmod +x target/lldap_migration_tool && \
|
||||
chmod +x target/lldap_set_password && \
|
||||
ls -la target/ . && \
|
||||
pwd \
|
||||
; fi
|
||||
|
||||
RUN if [ "${TARGETPLATFORM}" = "linux/arm64" ]; then \
|
||||
mv bin/aarch64-unknown-linux-musl-lldap-bin/lldap target/lldap && \
|
||||
mv bin/aarch64-unknown-linux-musl-lldap_migration_tool-bin/lldap_migration_tool target/lldap_migration_tool && \
|
||||
mv bin/aarch64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
|
||||
chmod +x target/lldap && \
|
||||
chmod +x target/lldap_migration_tool && \
|
||||
chmod +x target/lldap_set_password && \
|
||||
ls -la target/ . && \
|
||||
pwd \
|
||||
; fi
|
||||
|
||||
RUN if [ "${TARGETPLATFORM}" = "linux/arm/v7" ]; then \
|
||||
mv bin/armv7-unknown-linux-musleabihf-lldap-bin/lldap target/lldap && \
|
||||
mv bin/armv7-unknown-linux-musleabihf-lldap_migration_tool-bin/lldap_migration_tool target/lldap_migration_tool && \
|
||||
mv bin/armv7-unknown-linux-musleabihf-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
|
||||
chmod +x target/lldap && \
|
||||
chmod +x target/lldap_migration_tool && \
|
||||
chmod +x target/lldap_set_password && \
|
||||
ls -la target/ . && \
|
||||
pwd \
|
||||
; fi
|
||||
|
||||
# Web and App dir
|
||||
COPY lldap_config.docker_template.toml /lldap/
|
||||
COPY web/index_local.html web/index.html
|
||||
RUN cp target/lldap /lldap/ && \
|
||||
cp target/lldap_migration_tool /lldap/ && \
|
||||
cp target/lldap_set_password /lldap/ && \
|
||||
cp -R web/index.html \
|
||||
web/pkg \
|
||||
web/static \
|
||||
/lldap/app/
|
||||
|
||||
WORKDIR /lldap
|
||||
RUN set -x \
|
||||
&& for file in $(cat /lldap/app/static/libraries.txt); do wget -P app/static "$file"; done \
|
||||
&& for file in $(cat /lldap/app/static/fonts/fonts.txt); do wget -P app/static/fonts "$file"; done \
|
||||
&& chmod a+r -R .
|
||||
|
||||
FROM alpine:3.16
|
||||
WORKDIR /app
|
||||
ENV UID=1000
|
||||
ENV GID=1000
|
||||
ENV USER=lldap
|
||||
RUN apk add --no-cache tini ca-certificates bash tzdata && \
|
||||
addgroup -g $GID $USER && \
|
||||
adduser \
|
||||
--disabled-password \
|
||||
--gecos "" \
|
||||
--home "$(pwd)" \
|
||||
--ingroup "$USER" \
|
||||
--no-create-home \
|
||||
--uid "$UID" \
|
||||
"$USER" && \
|
||||
mkdir -p /data && \
|
||||
chown $USER:$USER /data
|
||||
COPY --from=lldap --chown=$USER:$USER /lldap /app
|
||||
VOLUME ["/data"]
|
||||
HEALTHCHECK CMD ["/app/lldap", "healthcheck", "--config-file", "/data/lldap_config.toml"]
|
||||
WORKDIR /app
|
||||
ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"]
|
||||
CMD ["run", "--config-file", "/data/lldap_config.toml"]
|
||||
3
.github/workflows/Dockerfile.ci.alpine-rootless
vendored
Normal file
3
.github/workflows/Dockerfile.ci.alpine-rootless
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
FROM localhost:5000/lldap/lldap:alpine-base
|
||||
COPY --chown=$USER:$USER docker-entrypoint-rootless.sh /docker-entrypoint.sh
|
||||
USER $USER
|
||||
110
.github/workflows/Dockerfile.ci.debian
vendored
110
.github/workflows/Dockerfile.ci.debian
vendored
@@ -1,79 +1,31 @@
|
||||
FROM debian:bullseye AS lldap
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
ARG TARGETPLATFORM
|
||||
RUN apt update && apt install -y wget
|
||||
WORKDIR /dim
|
||||
COPY bin/ bin/
|
||||
COPY web/ web/
|
||||
|
||||
RUN mkdir -p target/
|
||||
RUN mkdir -p /lldap/app
|
||||
|
||||
RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \
|
||||
mv bin/x86_64-unknown-linux-musl-lldap-bin/lldap target/lldap && \
|
||||
mv bin/x86_64-unknown-linux-musl-lldap_migration_tool-bin/lldap_migration_tool target/lldap_migration_tool && \
|
||||
mv bin/x86_64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
|
||||
chmod +x target/lldap && \
|
||||
chmod +x target/lldap_migration_tool && \
|
||||
chmod +x target/lldap_set_password && \
|
||||
ls -la target/ . && \
|
||||
pwd \
|
||||
; fi
|
||||
|
||||
RUN if [ "${TARGETPLATFORM}" = "linux/arm64" ]; then \
|
||||
mv bin/aarch64-unknown-linux-musl-lldap-bin/lldap target/lldap && \
|
||||
mv bin/aarch64-unknown-linux-musl-lldap_migration_tool-bin/lldap_migration_tool target/lldap_migration_tool && \
|
||||
mv bin/aarch64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
|
||||
chmod +x target/lldap && \
|
||||
chmod +x target/lldap_migration_tool && \
|
||||
chmod +x target/lldap_set_password && \
|
||||
ls -la target/ . && \
|
||||
pwd \
|
||||
; fi
|
||||
|
||||
RUN if [ "${TARGETPLATFORM}" = "linux/arm/v7" ]; then \
|
||||
mv bin/armv7-unknown-linux-musleabihf-lldap-bin/lldap target/lldap && \
|
||||
mv bin/armv7-unknown-linux-musleabihf-lldap_migration_tool-bin/lldap_migration_tool target/lldap_migration_tool && \
|
||||
mv bin/armv7-unknown-linux-musleabihf-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
|
||||
chmod +x target/lldap && \
|
||||
chmod +x target/lldap_migration_tool && \
|
||||
chmod +x target/lldap_set_password && \
|
||||
ls -la target/ . && \
|
||||
pwd \
|
||||
; fi
|
||||
|
||||
# Web and App dir
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
COPY lldap_config.docker_template.toml /lldap/
|
||||
COPY web/index_local.html web/index.html
|
||||
RUN cp target/lldap /lldap/ && \
|
||||
cp target/lldap_migration_tool /lldap/ && \
|
||||
cp target/lldap_set_password /lldap/ && \
|
||||
cp -R web/index.html \
|
||||
web/pkg \
|
||||
web/static \
|
||||
/lldap/app/
|
||||
|
||||
WORKDIR /lldap
|
||||
RUN set -x \
|
||||
&& for file in $(cat /lldap/app/static/libraries.txt); do wget -P app/static "$file"; done \
|
||||
&& for file in $(cat /lldap/app/static/fonts/fonts.txt); do wget -P app/static/fonts "$file"; done \
|
||||
&& chmod a+r -R .
|
||||
|
||||
FROM debian:bullseye-slim
|
||||
ENV UID=1000
|
||||
ENV GID=1000
|
||||
ENV USER=lldap
|
||||
RUN apt update && \
|
||||
apt install -y --no-install-recommends tini openssl ca-certificates gosu tzdata && \
|
||||
apt clean && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
groupadd -g $GID $USER && useradd --system -m -g $USER --uid $UID $USER && \
|
||||
mkdir -p /data && chown $USER:$USER /data
|
||||
COPY --from=lldap --chown=$USER:$USER /lldap /app
|
||||
COPY --from=lldap --chown=$USER:$USER /docker-entrypoint.sh /docker-entrypoint.sh
|
||||
VOLUME ["/data"]
|
||||
WORKDIR /app
|
||||
ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"]
|
||||
CMD ["run", "--config-file", "/data/lldap_config.toml"]
|
||||
HEALTHCHECK CMD ["/app/lldap", "healthcheck", "--config-file", "/data/lldap_config.toml"]
|
||||
FROM localhost:5000/lldap/lldap:debian-base
|
||||
# Taken directly from https://github.com/tianon/gosu/blob/master/INSTALL.md
|
||||
ENV GOSU_VERSION 1.17
|
||||
RUN set -eux; \
|
||||
# save list of currently installed packages for later so we can clean up
|
||||
savedAptMark="$(apt-mark showmanual)"; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends ca-certificates gnupg wget; \
|
||||
rm -rf /var/lib/apt/lists/*; \
|
||||
\
|
||||
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; \
|
||||
gpgconf --kill all; \
|
||||
rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \
|
||||
\
|
||||
# clean up fetch dependencies
|
||||
apt-mark auto '.*' > /dev/null; \
|
||||
[ -z "$savedAptMark" ] || apt-mark manual $savedAptMark; \
|
||||
apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false; \
|
||||
\
|
||||
chmod +x /usr/local/bin/gosu; \
|
||||
# verify that the binary works
|
||||
gosu --version; \
|
||||
gosu nobody true
|
||||
COPY --chown=$USER:$USER docker-entrypoint.sh /docker-entrypoint.sh
|
||||
|
||||
79
.github/workflows/Dockerfile.ci.debian-base
vendored
Normal file
79
.github/workflows/Dockerfile.ci.debian-base
vendored
Normal file
@@ -0,0 +1,79 @@
|
||||
FROM debian:bullseye AS lldap
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
ARG TARGETPLATFORM
|
||||
RUN apt update && apt install -y wget
|
||||
WORKDIR /dim
|
||||
COPY bin/ bin/
|
||||
COPY web/ web/
|
||||
|
||||
RUN mkdir -p target/
|
||||
RUN mkdir -p /lldap/app
|
||||
|
||||
RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \
|
||||
mv bin/x86_64-unknown-linux-musl-lldap-bin/lldap target/lldap && \
|
||||
mv bin/x86_64-unknown-linux-musl-lldap_migration_tool-bin/lldap_migration_tool target/lldap_migration_tool && \
|
||||
mv bin/x86_64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
|
||||
chmod +x target/lldap && \
|
||||
chmod +x target/lldap_migration_tool && \
|
||||
chmod +x target/lldap_set_password && \
|
||||
ls -la target/ . && \
|
||||
pwd \
|
||||
; fi
|
||||
|
||||
RUN if [ "${TARGETPLATFORM}" = "linux/arm64" ]; then \
|
||||
mv bin/aarch64-unknown-linux-musl-lldap-bin/lldap target/lldap && \
|
||||
mv bin/aarch64-unknown-linux-musl-lldap_migration_tool-bin/lldap_migration_tool target/lldap_migration_tool && \
|
||||
mv bin/aarch64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
|
||||
chmod +x target/lldap && \
|
||||
chmod +x target/lldap_migration_tool && \
|
||||
chmod +x target/lldap_set_password && \
|
||||
ls -la target/ . && \
|
||||
pwd \
|
||||
; fi
|
||||
|
||||
RUN if [ "${TARGETPLATFORM}" = "linux/arm/v7" ]; then \
|
||||
mv bin/armv7-unknown-linux-musleabihf-lldap-bin/lldap target/lldap && \
|
||||
mv bin/armv7-unknown-linux-musleabihf-lldap_migration_tool-bin/lldap_migration_tool target/lldap_migration_tool && \
|
||||
mv bin/armv7-unknown-linux-musleabihf-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
|
||||
chmod +x target/lldap && \
|
||||
chmod +x target/lldap_migration_tool && \
|
||||
chmod +x target/lldap_set_password && \
|
||||
ls -la target/ . && \
|
||||
pwd \
|
||||
; fi
|
||||
|
||||
# Web and App dir
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
COPY lldap_config.docker_template.toml /lldap/
|
||||
COPY web/index_local.html web/index.html
|
||||
RUN cp target/lldap /lldap/ && \
|
||||
cp target/lldap_migration_tool /lldap/ && \
|
||||
cp target/lldap_set_password /lldap/ && \
|
||||
cp -R web/index.html \
|
||||
web/pkg \
|
||||
web/static \
|
||||
/lldap/app/
|
||||
|
||||
WORKDIR /lldap
|
||||
RUN set -x \
|
||||
&& for file in $(cat /lldap/app/static/libraries.txt); do wget -P app/static "$file"; done \
|
||||
&& for file in $(cat /lldap/app/static/fonts/fonts.txt); do wget -P app/static/fonts "$file"; done \
|
||||
&& chmod a+r -R .
|
||||
|
||||
FROM debian:bullseye-slim
|
||||
ENV UID=1000
|
||||
ENV GID=1000
|
||||
ENV USER=lldap
|
||||
RUN apt update && \
|
||||
apt install -y --no-install-recommends tini openssl ca-certificates tzdata && \
|
||||
apt clean && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
groupadd -g $GID $USER && useradd --system -m -g $USER --uid $UID $USER && \
|
||||
mkdir -p /data && chown $USER:$USER /data
|
||||
COPY --from=lldap --chown=$USER:$USER /lldap /app
|
||||
COPY --from=lldap --chown=$USER:$USER /docker-entrypoint.sh /docker-entrypoint.sh
|
||||
VOLUME ["/data"]
|
||||
WORKDIR /app
|
||||
ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"]
|
||||
CMD ["run", "--config-file", "/data/lldap_config.toml"]
|
||||
HEALTHCHECK CMD ["/app/lldap", "healthcheck", "--config-file", "/data/lldap_config.toml"]
|
||||
3
.github/workflows/Dockerfile.ci.debian-rootless
vendored
Normal file
3
.github/workflows/Dockerfile.ci.debian-rootless
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
FROM localhost:5000/lldap/lldap:debian-base
|
||||
COPY --chown=$USER:$USER docker-entrypoint-rootless.sh /docker-entrypoint.sh
|
||||
USER $USER
|
||||
2
.github/workflows/Dockerfile.dev
vendored
2
.github/workflows/Dockerfile.dev
vendored
@@ -1,5 +1,5 @@
|
||||
# Keep tracking base image
|
||||
FROM rust:1.71-slim-bookworm
|
||||
FROM rust:1.74-slim-bookworm
|
||||
|
||||
# Set needed env path
|
||||
ENV PATH="/opt/armv7l-linux-musleabihf-cross/:/opt/armv7l-linux-musleabihf-cross/bin/:/opt/aarch64-linux-musl-cross/:/opt/aarch64-linux-musl-cross/bin/:/opt/x86_64-linux-musl-cross/:/opt/x86_64-linux-musl-cross/bin/:$PATH"
|
||||
|
||||
159
.github/workflows/docker-build-static.yml
vendored
159
.github/workflows/docker-build-static.yml
vendored
@@ -87,8 +87,8 @@ jobs:
|
||||
image: lldap/rust-dev:latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4.0.0
|
||||
- uses: actions/cache@v3
|
||||
uses: actions/checkout@v4.1.1
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
/usr/local/cargo/bin
|
||||
@@ -110,7 +110,7 @@ jobs:
|
||||
- name: Check build path
|
||||
run: ls -al app/
|
||||
- name: Upload ui artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ui
|
||||
path: app/
|
||||
@@ -132,8 +132,8 @@ jobs:
|
||||
CARGO_HOME: ${GITHUB_WORKSPACE}/.cargo
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4.0.0
|
||||
- uses: actions/cache@v3
|
||||
uses: actions/checkout@v4.1.1
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
.cargo/bin
|
||||
@@ -149,17 +149,17 @@ jobs:
|
||||
- name: Check path
|
||||
run: ls -al target/release
|
||||
- name: Upload ${{ matrix.target}} lldap artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.target}}-lldap-bin
|
||||
path: target/${{ matrix.target }}/release/lldap
|
||||
- name: Upload ${{ matrix.target }} migration tool artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.target }}-lldap_migration_tool-bin
|
||||
path: target/${{ matrix.target }}/release/lldap_migration_tool
|
||||
- name: Upload ${{ matrix.target }} password tool artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.target }}-lldap_set_password-bin
|
||||
path: target/${{ matrix.target }}/release/lldap_set_password
|
||||
@@ -199,7 +199,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: x86_64-unknown-linux-musl-lldap-bin
|
||||
path: bin/
|
||||
@@ -294,18 +294,18 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout scripts
|
||||
uses: actions/checkout@v4.0.0
|
||||
uses: actions/checkout@v4.1.1
|
||||
with:
|
||||
sparse-checkout: 'scripts'
|
||||
|
||||
- name: Download LLDAP artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: x86_64-unknown-linux-musl-lldap-bin
|
||||
path: bin/
|
||||
|
||||
- name: Download LLDAP set password
|
||||
uses: actions/download-artifact@v3
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: x86_64-unknown-linux-musl-lldap_set_password-bin
|
||||
path: bin/
|
||||
@@ -347,7 +347,7 @@ jobs:
|
||||
- name: Export and Converting to Postgress
|
||||
run: |
|
||||
bash ./scripts/sqlite_dump_commands.sh | sqlite3 ./users.db > ./dump.sql
|
||||
sed -i -r -e "s/X'([[:xdigit:]]+'[^'])/'\\\x\\1/g" -e ":a; s/(INSERT INTO user_attribute_schema\(.*\) VALUES\(.*),1([^']*\);)$/\1,true\2/; s/(INSERT INTO user_attribute_schema\(.*\) VALUES\(.*),0([^']*\);)$/\1,false\2/; ta" -e '1s/^/BEGIN;\n/' -e '$aCOMMIT;' ./dump.sql
|
||||
sed -i -r -e "s/X'([[:xdigit:]]+'[^'])/'\\\x\\1/g" -e ":a; s/(INSERT INTO (user_attribute_schema|jwt_storage)\(.*\) VALUES\(.*),1([^']*\);)$/\1,true\3/; s/(INSERT INTO (user_attribute_schema|jwt_storage)\(.*\) VALUES\(.*),0([^']*\);)$/\1,false\3/; ta" -e '1s/^/BEGIN;\n/' -e '$aCOMMIT;' ./dump.sql
|
||||
|
||||
- name: Create schema on postgres
|
||||
run: |
|
||||
@@ -434,6 +434,9 @@ jobs:
|
||||
- name: Test Dummy User MySQL
|
||||
run: ldapsearch -H ldap://localhost:3893 -LLL -D "uid=dummyuser,ou=people,dc=example,dc=com" -w 'dummypassword' -s "One" -b "ou=people,dc=example,dc=com"
|
||||
|
||||
########################################
|
||||
#### BUILD BASE IMAGE ##################
|
||||
########################################
|
||||
build-docker-image:
|
||||
needs: [build-ui, build-bin]
|
||||
name: Build Docker image
|
||||
@@ -443,7 +446,7 @@ jobs:
|
||||
container: ["debian","alpine"]
|
||||
include:
|
||||
- container: alpine
|
||||
platforms: linux/amd64,linux/arm64
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
tags: |
|
||||
type=ref,event=pr
|
||||
type=semver,pattern=v{{version}}
|
||||
@@ -456,6 +459,8 @@ jobs:
|
||||
type=raw,value=stable,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
type=raw,value=stable,enable=${{ startsWith(github.ref, 'refs/tags/v') }},suffix=
|
||||
type=raw,value=latest,enable={{ is_default_branch }},suffix=
|
||||
type=raw,value={{ date 'YYYY-MM-DD' }},enable={{ is_default_branch }}
|
||||
type=raw,value={{ date 'YYYY-MM-DD' }},enable={{ is_default_branch }},suffix=
|
||||
- container: debian
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
tags: |
|
||||
@@ -465,31 +470,69 @@ jobs:
|
||||
type=semver,pattern=v{{major}}.{{minor}}
|
||||
type=raw,value=latest,enable={{ is_default_branch }}
|
||||
type=raw,value=stable,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
|
||||
type=raw,value={{ date 'YYYY-MM-DD' }},enable={{ is_default_branch }}
|
||||
services:
|
||||
registry:
|
||||
image: registry:2
|
||||
ports:
|
||||
- 5000:5000
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4.0.0
|
||||
uses: actions/checkout@v4.1.1
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: bin
|
||||
|
||||
- name: Download llap ui artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ui
|
||||
path: web
|
||||
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
- uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Setup buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver-opts: network=host
|
||||
|
||||
- name: Docker ${{ matrix.container }} meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
- name: Docker ${{ matrix.container }} Base meta
|
||||
id: meta-base
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
# list of Docker images to use as base name for tags
|
||||
images: |
|
||||
localhost:5000/lldap/lldap
|
||||
tags: ${{ matrix.container }}-base
|
||||
|
||||
- name: Build ${{ matrix.container }} Base Docker Image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
# On PR will fail, force fully uncomment push: true, or docker image will fail for next steps
|
||||
#push: ${{ github.event_name != 'pull_request' }}
|
||||
push: true
|
||||
platforms: ${{ matrix.platforms }}
|
||||
file: ./.github/workflows/Dockerfile.ci.${{ matrix.container }}-base
|
||||
tags: |
|
||||
${{ steps.meta-base.outputs.tags }}
|
||||
labels: ${{ steps.meta-base.outputs.labels }}
|
||||
cache-from: type=gha,mode=max
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
#####################################
|
||||
#### build variants docker image ####
|
||||
#####################################
|
||||
|
||||
- name: Docker ${{ matrix.container }}-rootless meta
|
||||
id: meta-rootless
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
# list of Docker images to use as base name for tags
|
||||
images: |
|
||||
@@ -504,12 +547,48 @@ jobs:
|
||||
# latest-alpine
|
||||
# stable
|
||||
# stable-alpine
|
||||
# YYYY-MM-DD
|
||||
# YYYY-MM-DD-alpine
|
||||
#################
|
||||
# vX-debian
|
||||
# vX.Y-debian
|
||||
# vX.Y.Z-debian
|
||||
# latest-debian
|
||||
# stable-debian
|
||||
# YYYY-MM-DD-debian
|
||||
#################
|
||||
# Check matrix for tag list definition
|
||||
flavor: |
|
||||
latest=false
|
||||
suffix=-${{ matrix.container }}-rootless
|
||||
tags: ${{ matrix.tags }}
|
||||
|
||||
- name: Docker ${{ matrix.container }} meta
|
||||
id: meta-standard
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
# list of Docker images to use as base name for tags
|
||||
images: |
|
||||
nitnelave/lldap
|
||||
lldap/lldap
|
||||
ghcr.io/lldap/lldap
|
||||
# Wanted Docker tags
|
||||
# vX-alpine
|
||||
# vX.Y-alpine
|
||||
# vX.Y.Z-alpine
|
||||
# latest
|
||||
# latest-alpine
|
||||
# stable
|
||||
# stable-alpine
|
||||
# YYYY-MM-DD
|
||||
# YYYY-MM-DD-alpine
|
||||
#################
|
||||
# vX-debian
|
||||
# vX.Y-debian
|
||||
# vX.Y.Z-debian
|
||||
# latest-debian
|
||||
# stable-debian
|
||||
# YYYY-MM-DD-debian
|
||||
#################
|
||||
# Check matrix for tag list definition
|
||||
flavor: |
|
||||
@@ -520,39 +599,49 @@ jobs:
|
||||
# Docker login to nitnelave/lldap and lldap/lldap
|
||||
- name: Login to Nitnelave/LLDAP Docker Hub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: nitnelave
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build ${{ matrix.container }}-rootless Docker Image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
platforms: ${{ matrix.platforms }}
|
||||
file: ./.github/workflows/Dockerfile.ci.${{ matrix.container }}-rootless
|
||||
tags: |
|
||||
${{ steps.meta-rootless.outputs.tags }}
|
||||
labels: ${{ steps.meta-rootless.outputs.labels }}
|
||||
cache-from: type=gha,mode=max
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
########################################
|
||||
#### docker image build ####
|
||||
########################################
|
||||
### This docker build always the last, due :latest tag pushed multiple times, for whatever variants may added in future add docker build above this
|
||||
- name: Build ${{ matrix.container }} Docker Image
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
platforms: ${{ matrix.platforms }}
|
||||
file: ./.github/workflows/Dockerfile.ci.${{ matrix.container }}
|
||||
tags: |
|
||||
${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
${{ steps.meta-standard.outputs.tags }}
|
||||
labels: ${{ steps.meta-standard.outputs.labels }}
|
||||
cache-from: type=gha,mode=max
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Update repo description
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: peter-evans/dockerhub-description@v3
|
||||
uses: peter-evans/dockerhub-description@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
@@ -560,7 +649,7 @@ jobs:
|
||||
|
||||
- name: Update lldap repo description
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: peter-evans/dockerhub-description@v3
|
||||
uses: peter-evans/dockerhub-description@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
@@ -578,7 +667,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: bin/
|
||||
- name: Check file
|
||||
@@ -599,7 +688,7 @@ jobs:
|
||||
chmod +x bin/*-lldap_set_password
|
||||
|
||||
- name: Download llap ui artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ui
|
||||
path: web
|
||||
|
||||
8
.github/workflows/rust.yml
vendored
8
.github/workflows/rust.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v4.0.0
|
||||
uses: actions/checkout@v4.1.1
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Build
|
||||
run: cargo build --verbose --workspace
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v4.0.0
|
||||
uses: actions/checkout@v4.1.1
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v4.0.0
|
||||
uses: actions/checkout@v4.1.1
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v4.0.0
|
||||
uses: actions/checkout@v4.1.1
|
||||
|
||||
- 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
|
||||
|
||||
97
CONTRIBUTING.md
Normal file
97
CONTRIBUTING.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# How to contribute to LLDAP
|
||||
|
||||
## Did you find a bug?
|
||||
|
||||
- Make sure there isn't already an [issue](https://github.com/lldap/lldap/issues?q=is%3Aissue+is%3Aopen) for it.
|
||||
- Check if the bug still happens with the `latest` docker image, or the `main` branch if you compile it yourself.
|
||||
- [Create an issue](https://github.com/lldap/lldap/issues/new) on GitHub. What makes a great issue:
|
||||
- A quick summary of the bug.
|
||||
- Steps to reproduce.
|
||||
- LLDAP _verbose_ logs when reproducing the bug. Verbose mode can be set through environment variables (`LLDAP_VERBOSE=true`) or in the config (`verbose = true`).
|
||||
- What you expected to happen.
|
||||
- What actually happened.
|
||||
- Other notes (what you tried, why you think it's happening, ...).
|
||||
|
||||
## Are you requesting integration with a new service?
|
||||
|
||||
- Check if there is already an [example config](https://github.com/lldap/lldap/tree/main/example_configs) for it.
|
||||
- Try to figure out the configuration values for the new service yourself.
|
||||
- You can use other example configs for inspiration.
|
||||
- If you're having trouble, you can ask on [Discord](https://discord.gg/h5PEdRMNyP)
|
||||
- If you succeed, make sure to contribute an example configuration, or a configuration guide.
|
||||
- If you hit a block because of an unimplemented feature, go to the next section.
|
||||
|
||||
## Are you asking for a new feature?
|
||||
|
||||
- Make sure there isn't already an [issue](https://github.com/lldap/lldap/issues?q=is%3Aissue+is%3Aopen) for it.
|
||||
- [Create an issue](https://github.com/lldap/lldap/issues/new) on GitHub. What makes a great feature request:
|
||||
- A quick summary of the feature.
|
||||
- Motivation: what problem does the feature solve?
|
||||
- Workarounds: what are the currently possible solutions to the problem, however bad?
|
||||
|
||||
## Do you want to work on a PR?
|
||||
|
||||
That's great! There are 2 main ways to contribute to the project: documentation and code.
|
||||
|
||||
### Documentation
|
||||
|
||||
The simplest way to contribute is to submit a configuration guide for a new
|
||||
service: it can be an example configuration file, or a markdown guide
|
||||
explaining the steps necessary to configure the service.
|
||||
|
||||
We also have some
|
||||
[documentation](https://github.com/lldap/lldap/tree/main/docs) with more
|
||||
advanced guides (scripting, migrations, ...) you can contribute to.
|
||||
|
||||
### Code
|
||||
|
||||
If you don't know what to start with, check out the
|
||||
[good first issues](https://github.com/lldap/lldap/labels/good%20first%20issue).
|
||||
|
||||
Otherwise, if you want to fix a specific bug or implement a feature, make sure
|
||||
to start by creating an issue for it (if it doesn't already exist). There, we
|
||||
can discuss whether it would be likely to be accepted and consider design
|
||||
issues. That will save you from going down a wrong path, creating an entire PR
|
||||
before getting told that it doesn't align with the project or the design is
|
||||
flawed!
|
||||
|
||||
Once we agree on what to do in the issue, you can start working on the PR. A good quality PR has:
|
||||
- A description of the change.
|
||||
- The format we use for both commit titles and PRs is:
|
||||
`tag: Do the thing`
|
||||
The tag can be: server, app, docker, example_configs, ... It's a broad category.
|
||||
The rest of the title should be an imperative sentence (see for instance [Commit Message
|
||||
Guidelines](https://gist.github.com/robertpainsi/b632364184e70900af4ab688decf6f53)).
|
||||
- The PR should refer to the issue it's addressing (e.g. "Fix #123").
|
||||
- Explain the _why_ of the change.
|
||||
- But also the _how_.
|
||||
- Highlight any potential flaw or limitation.
|
||||
- The code change should be as small as possible while solving the problem.
|
||||
- Don't try to code-golf to change fewer characters, but keep logically separate changes in
|
||||
different PRs.
|
||||
- Add tests if possible.
|
||||
- The tests should highlight the original issue in case of a bug.
|
||||
- Ideally, we can apply the tests without the rest of the change and they would fail. With the
|
||||
change, they pass.
|
||||
- In some areas, there is no test infrastructure in place (e.g. for frontend changes). In that
|
||||
case, do some manual testing and include the results (logs for backend changes, screenshot of a
|
||||
successful service integration, screenshot of the frontend change).
|
||||
- For backend changes, the tests should cover a significant portion of the new code paths, or
|
||||
everything if possible. You can also add more tests to cover existing code.
|
||||
- Of course, make sure all the existing tests pass. This will be checked anyway in the GitHub CI.
|
||||
|
||||
### Workflow
|
||||
|
||||
We use [GitHub Flow](https://docs.github.com/en/get-started/quickstart/github-flow):
|
||||
- Fork the repository.
|
||||
- (Optional) Create a new branch, or just use `main` in your fork.
|
||||
- Make your change.
|
||||
- Create a PR.
|
||||
- Address the comments by adding more commits to your branch (or to `main`).
|
||||
- The PR gets merged (the commits get squashed to a single one).
|
||||
- (Optional) You can delete your branch/fork.
|
||||
|
||||
## Reminder
|
||||
|
||||
We're all volunteers, so be kind to each other! And since we're doing that in our free time, some
|
||||
things can take a longer than expected.
|
||||
16
Cargo.lock
generated
16
Cargo.lock
generated
@@ -1351,8 +1351,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e56602b469b2201400dec66a66aec5a9b8761ee97cd1b8c96ab2483fcc16cc9"
|
||||
dependencies = [
|
||||
"atomic",
|
||||
"parking_lot",
|
||||
"pear",
|
||||
"serde",
|
||||
"tempfile",
|
||||
"toml",
|
||||
"uncased",
|
||||
"version_check",
|
||||
@@ -2364,9 +2366,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ldap3_proto"
|
||||
version = "0.4.0"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db993ebb4a1acda7ac25fa7e8609cff225a65f1f4a668e378eb252a1a6de433a"
|
||||
checksum = "a29eca0a9fef365d6d376a1b262e269a17b1c8c6de2cee76618642cd3c923506"
|
||||
dependencies = [
|
||||
"base64 0.21.0",
|
||||
"bytes",
|
||||
@@ -2453,7 +2455,7 @@ checksum = "f051f77a7c8e6957c0696eac88f26b0117e54f52d3fc682ab19397a8812846a4"
|
||||
|
||||
[[package]]
|
||||
name = "lldap"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1-alpha"
|
||||
dependencies = [
|
||||
"actix",
|
||||
"actix-files",
|
||||
@@ -2473,6 +2475,7 @@ dependencies = [
|
||||
"clap",
|
||||
"cron",
|
||||
"derive_builder",
|
||||
"derive_more",
|
||||
"figment",
|
||||
"figment_file_provider_adapter",
|
||||
"futures",
|
||||
@@ -2528,7 +2531,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lldap_app"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.13.1",
|
||||
@@ -2569,6 +2572,7 @@ dependencies = [
|
||||
"opaque-ke",
|
||||
"rand 0.8.5",
|
||||
"rust-argon2",
|
||||
"sea-orm",
|
||||
"serde",
|
||||
"sha2 0.9.9",
|
||||
"thiserror",
|
||||
@@ -4893,9 +4897,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "webpki"
|
||||
version = "0.22.1"
|
||||
version = "0.22.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0e74f82d49d545ad128049b7e88f6576df2da6b02e9ce565c6f533be576957e"
|
||||
checksum = "07ecc0cd7cac091bf682ec5efa18b1cff79d617b84181f38b3951dbe135f607f"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"untrusted",
|
||||
|
||||
149
README.md
149
README.md
@@ -5,9 +5,9 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/nitnelave/lldap/actions/workflows/rust.yml?query=branch%3Amain">
|
||||
<a href="https://github.com/lldap/lldap/actions/workflows/rust.yml?query=branch%3Amain">
|
||||
<img
|
||||
src="https://github.com/nitnelave/lldap/actions/workflows/rust.yml/badge.svg"
|
||||
src="https://github.com/lldap/lldap/actions/workflows/rust.yml/badge.svg"
|
||||
alt="Build"/>
|
||||
</a>
|
||||
<a href="https://discord.gg/h5PEdRMNyP">
|
||||
@@ -37,14 +37,18 @@
|
||||
- [Installation](#installation)
|
||||
- [With Docker](#with-docker)
|
||||
- [With Kubernetes](#with-kubernetes)
|
||||
- [From a package repository](#from-a-package-repository)
|
||||
- [From source](#from-source)
|
||||
- [Backend](#backend)
|
||||
- [Frontend](#frontend)
|
||||
- [Cross-compilation](#cross-compilation)
|
||||
- [Usage](#usage)
|
||||
- [Recommended architecture](#recommended-architecture)
|
||||
- [Client configuration](#client-configuration)
|
||||
- [Compatible services](#compatible-services)
|
||||
- [General configuration guide](#general-configuration-guide)
|
||||
- [Sample client configurations](#sample-client-configurations)
|
||||
- [Incompatible services](#incompatible-services)
|
||||
- [Migrating from SQLite](#migrating-from-sqlite)
|
||||
- [Comparisons with other services](#comparisons-with-other-services)
|
||||
- [vs OpenLDAP](#vs-openldap)
|
||||
@@ -61,7 +65,7 @@ many backends, from KeyCloak to Authelia to Nextcloud and
|
||||
[more](#compatible-services)!
|
||||
|
||||
<img
|
||||
src="https://raw.githubusercontent.com/nitnelave/lldap/master/screenshot.png"
|
||||
src="https://raw.githubusercontent.com/lldap/lldap/master/screenshot.png"
|
||||
alt="Screenshot of the user list page"
|
||||
width="50%"
|
||||
align="right"
|
||||
@@ -94,9 +98,10 @@ MySQL/MariaDB or PostgreSQL.
|
||||
|
||||
### 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.
|
||||
The image is available at `lldap/lldap`. You should persist the `/data`
|
||||
folder, which contains your configuration and the SQLite database (you can
|
||||
remove this step if you use a different DB and configure with environment
|
||||
variables only).
|
||||
|
||||
Configure the server by copying the `lldap_config.docker_template.toml` to
|
||||
`/data/lldap_config.toml` and updating the configuration values (especially the
|
||||
@@ -104,10 +109,12 @@ Configure the server by copying the `lldap_config.docker_template.toml` to
|
||||
Environment variables should be prefixed with `LLDAP_` to override the
|
||||
configuration.
|
||||
|
||||
If the `lldap_config.toml` doesn't exist when starting up, LLDAP will use default one. The default admin password is `password`, you can change the password later using the web interface.
|
||||
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
|
||||
variables `LLDAP_JWT_SECRET_FILE` or `LLDAP_KEY_SEED_FILE`, and the file
|
||||
contents are loaded into the respective configuration parameters. Note that
|
||||
`_FILE` variables take precedence.
|
||||
|
||||
@@ -117,6 +124,7 @@ Example for docker compose:
|
||||
- `:latest` tag image contains recently pushed code or feature tests, in which some instability can be expected.
|
||||
- If `UID` and `GID` no defined LLDAP will use default `UID` and `GID` number `1000`.
|
||||
- If no `TZ` is set, default `UTC` timezone will be used.
|
||||
- You can generate the secrets by running `./generate_secrets.sh`
|
||||
|
||||
```yaml
|
||||
version: "3"
|
||||
@@ -127,10 +135,10 @@ volumes:
|
||||
|
||||
services:
|
||||
lldap:
|
||||
image: nitnelave/lldap:stable
|
||||
image: lldap/lldap:stable
|
||||
ports:
|
||||
# For LDAP
|
||||
- "3890:3890"
|
||||
# For LDAP, not recommended to expose, see Usage section.
|
||||
#- "3890:3890"
|
||||
# For LDAPS (LDAP Over SSL), enable port if LLDAP_LDAPS_OPTIONS__ENABLED set true, look env below
|
||||
#- "6360:6360"
|
||||
# For the web front-end
|
||||
@@ -144,7 +152,7 @@ services:
|
||||
- GID=####
|
||||
- TZ=####/####
|
||||
- LLDAP_JWT_SECRET=REPLACE_WITH_RANDOM
|
||||
- LLDAP_LDAP_USER_PASS=REPLACE_WITH_PASSWORD
|
||||
- LLDAP_KEY_SEED=REPLACE_WITH_RANDOM
|
||||
- LLDAP_LDAP_BASE_DN=dc=example,dc=com
|
||||
# If using LDAPS, set enabled true and configure cert and key path
|
||||
# - LLDAP_LDAPS_OPTIONS__ENABLED=true
|
||||
@@ -162,6 +170,44 @@ front-end.
|
||||
|
||||
See https://github.com/Evantage-WS/lldap-kubernetes for a LLDAP deployment for Kubernetes
|
||||
|
||||
You can bootstrap your lldap instance (users, groups)
|
||||
using [bootstrap.sh](example_configs/bootstrap/bootstrap.md#kubernetes-job).
|
||||
It can be run by Argo CD for managing users in git-opt way, or as a one-shot job.
|
||||
|
||||
### From a package repository
|
||||
|
||||
**Do not open issues in this repository for problems with third-party
|
||||
pre-built packages. Report issues downstream.**
|
||||
|
||||
Depending on the distribution you use, it might be possible to install lldap
|
||||
from a package repository, officially supported by the distribution or
|
||||
community contributed.
|
||||
|
||||
#### Debian, CentOS Fedora, OpenSUSE, Ubuntu
|
||||
|
||||
The package for these distributions can be found at [LLDAP OBS](https://software.opensuse.org//download.html?project=home%3AMasgalor%3ALLDAP&package=lldap).
|
||||
- When using the distributed package, the default login is `admin/password`. You can change that from the web UI after starting the service.
|
||||
|
||||
#### Arch Linux
|
||||
|
||||
Arch Linux offers unofficial support through the [Arch User Repository
|
||||
(AUR)](https://wiki.archlinux.org/title/Arch_User_Repository).
|
||||
Available package descriptions in AUR are:
|
||||
|
||||
- [lldap](https://aur.archlinux.org/packages/lldap) - Builds the latest stable version.
|
||||
- [lldap-bin](https://aur.archlinux.org/packages/lldap-bin) - Uses the latest
|
||||
pre-compiled binaries from the [releases in this repository](https://github.com/lldap/lldap/releases).
|
||||
This package is recommended if you want to run lldap on a system with
|
||||
limited resources.
|
||||
- [lldap-git](https://aur.archlinux.org/packages/lldap-git) - Builds the
|
||||
latest main branch code.
|
||||
|
||||
The package descriptions can be used
|
||||
[to create and install packages](https://wiki.archlinux.org/title/Arch_User_Repository#Getting_started).
|
||||
Each package places lldap's configuration file at `/etc/lldap.toml` and offers
|
||||
[systemd service](https://wiki.archlinux.org/title/systemd#Using_units)
|
||||
`lldap.service` to (auto-)start and stop lldap.
|
||||
|
||||
### From source
|
||||
|
||||
#### Backend
|
||||
@@ -183,15 +229,13 @@ just run `cargo run -- run` to run the server.
|
||||
#### Frontend
|
||||
|
||||
To bring up the server, you'll need to compile the frontend. In addition to
|
||||
`cargo`, you'll need:
|
||||
|
||||
- WASM-pack: `cargo install wasm-pack`
|
||||
`cargo`, you'll need WASM-pack, which can be installed by running `cargo install wasm-pack`.
|
||||
|
||||
Then you can build the frontend files with
|
||||
|
||||
```shell
|
||||
./app/build.sh
|
||||
````
|
||||
```
|
||||
|
||||
(you'll need to run this after every front-end change to update the WASM
|
||||
package served).
|
||||
@@ -226,6 +270,47 @@ You can then get the compiled server binary in
|
||||
Raspberry Pi (or other target), with the folder structure maintained (`app`
|
||||
files in an `app` folder next to the binary).
|
||||
|
||||
## Usage
|
||||
|
||||
The simplest way to use LLDAP is through the web front-end. There you can
|
||||
create users, set passwords, add them to groups and so on. Users can also
|
||||
connect to the web UI and change their information, or request a password reset
|
||||
link (if you configured the SMTP client).
|
||||
|
||||
Creating and managing custom attributes is currently in Beta. It's not
|
||||
supported in the Web UI. The recommended way is to use
|
||||
[Zepmann/lldap-cli](https://github.com/Zepmann/lldap-cli), a
|
||||
community-contributed CLI frontend.
|
||||
|
||||
LLDAP is also very scriptable, through its GraphQL API. See the
|
||||
[Scripting](docs/scripting.md) docs for more info.
|
||||
|
||||
### Recommended architecture
|
||||
|
||||
If you are using containers, a sample architecture could look like this:
|
||||
|
||||
- A reverse proxy (e.g. nginx or Traefik)
|
||||
- An authentication service (e.g. Authelia, Authentik or KeyCloak) connected to
|
||||
LLDAP to provide authentication for non-authenticated services, or to provide
|
||||
SSO with compatible ones.
|
||||
- The LLDAP service, with the web port exposed to Traefik.
|
||||
- The LDAP port doesn't need to be exposed, since only the other containers
|
||||
will access it.
|
||||
- You can also set up LDAPS if you want to expose the LDAP port to the
|
||||
internet (not recommended) or for an extra layer of security in the
|
||||
inter-container communication (though it's very much optional).
|
||||
- The default LLDAP container starts up as root to fix up some files'
|
||||
permissions before downgrading the privilege to the given user. However,
|
||||
you can (should?) use the `*-rootless` version of the images to be able to
|
||||
start directly as that user, once you got the permissions right. Just don't
|
||||
forget to change from the `UID/GID` env vars to the `uid` docker-compose
|
||||
field.
|
||||
- Any other service that needs to connect to LLDAP for authentication (e.g.
|
||||
NextCloud) can be added to a shared network with LLDAP. The finest
|
||||
granularity is a network for each pair of LLDAP-service, but there are often
|
||||
coarser granularities that make sense (e.g. a network for the \*arr stack and
|
||||
LLDAP).
|
||||
|
||||
## Client configuration
|
||||
|
||||
### Compatible services
|
||||
@@ -265,6 +350,7 @@ folder for help with:
|
||||
|
||||
- [Airsonic Advanced](example_configs/airsonic-advanced.md)
|
||||
- [Apache Guacamole](example_configs/apacheguacamole.md)
|
||||
- [Apereo CAS Server](example_configs/apereo_cas_server.md)
|
||||
- [Authelia](example_configs/authelia_config.yml)
|
||||
- [Authentik](example_configs/authentik.md)
|
||||
- [Bookstack](example_configs/bookstack.env.example)
|
||||
@@ -277,12 +363,19 @@ folder for help with:
|
||||
- [Emby](example_configs/emby.md)
|
||||
- [Ergo IRCd](example_configs/ergo.md)
|
||||
- [Gitea](example_configs/gitea.md)
|
||||
- [GitLab](example_configs/gitlab.md)
|
||||
- [Grafana](example_configs/grafana_ldap_config.toml)
|
||||
- [Grocy](example_configs/grocy.md)
|
||||
- [Hedgedoc](example_configs/hedgedoc.md)
|
||||
- [Home Assistant](example_configs/home-assistant.md)
|
||||
- [Jellyfin](example_configs/jellyfin.md)
|
||||
- [Jenkins](example_configs/jenkins.md)
|
||||
- [Jitsi Meet](example_configs/jitsi_meet.conf)
|
||||
- [Kasm](example_configs/kasm.md)
|
||||
- [KeyCloak](example_configs/keycloak.md)
|
||||
- [LibreNMS](example_configs/librenms.md)
|
||||
- [Maddy](example_configs/maddy.md)
|
||||
- [Mastodon](example_configs/mastodon.env.example)
|
||||
- [Matrix](example_configs/matrix_synapse.yml)
|
||||
- [Mealie](example_configs/mealie.md)
|
||||
- [MinIO](example_configs/minio.md)
|
||||
@@ -292,20 +385,44 @@ folder for help with:
|
||||
- [Portainer](example_configs/portainer.md)
|
||||
- [PowerDNS Admin](example_configs/powerdns_admin.md)
|
||||
- [Proxmox VE](example_configs/proxmox.md)
|
||||
- [Radicale](example_configs/radicale.md)
|
||||
- [Rancher](example_configs/rancher.md)
|
||||
- [Seafile](example_configs/seafile.md)
|
||||
- [Shaarli](example_configs/shaarli.md)
|
||||
- [Squid](example_configs/squid.md)
|
||||
- [Syncthing](example_configs/syncthing.md)
|
||||
- [TheLounge](example_configs/thelounge.md)
|
||||
- [Traccar](example_configs/traccar.xml)
|
||||
- [Vaultwarden](example_configs/vaultwarden.md)
|
||||
- [WeKan](example_configs/wekan.md)
|
||||
- [WG Portal](example_configs/wg_portal.env.example)
|
||||
- [WikiJS](example_configs/wikijs.md)
|
||||
- [XBackBone](example_configs/xbackbone_config.php)
|
||||
- [Zendto](example_configs/zendto.md)
|
||||
- [Zitadel](example_configs/zitadel.md)
|
||||
- [Zulip](example_configs/zulip.md)
|
||||
|
||||
### Incompatible services
|
||||
|
||||
Though we try to be maximally compatible, not every feature is supported; LLDAP
|
||||
is not a fully-featured LDAP server, intentionally so.
|
||||
|
||||
LDAP browsing tools are generally not supported, though they could be. If you
|
||||
need to use one but it behaves weirdly, please file a bug.
|
||||
|
||||
Some services use features that are not implemented, or require specific
|
||||
attributes. You can try to create those attributes (see custom attributes in
|
||||
the [Usage](#usage) section).
|
||||
|
||||
Finally, some services require password hashes so they can validate themselves
|
||||
the user's password without contacting LLDAP. This is not and will not be
|
||||
supported, it's incompatible with our password hashing scheme (a zero-knowledge
|
||||
proof). Furthermore, it's generally not recommended in terms of security, since
|
||||
it duplicates the places from which a password hash could leak.
|
||||
|
||||
In that category, the most prominent is Synology. It is, to date, the only
|
||||
service that seems definitely incompatible with LLDAP.
|
||||
|
||||
## Migrating from SQLite
|
||||
|
||||
If you started with an SQLite database and would like to migrate to
|
||||
|
||||
@@ -6,7 +6,7 @@ homepage = "https://github.com/lldap/lldap"
|
||||
license = "GPL-3.0-only"
|
||||
name = "lldap_app"
|
||||
repository = "https://github.com/lldap/lldap"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1-alpha"
|
||||
include = ["src/**/*", "queries/**/*", "Cargo.toml", "../schema.graphql"]
|
||||
|
||||
[dependencies]
|
||||
@@ -38,11 +38,13 @@ features = [
|
||||
"Document",
|
||||
"Element",
|
||||
"FileReader",
|
||||
"FormData",
|
||||
"HtmlDocument",
|
||||
"HtmlInputElement",
|
||||
"HtmlOptionElement",
|
||||
"HtmlOptionsCollection",
|
||||
"HtmlSelectElement",
|
||||
"SubmitEvent",
|
||||
"console",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>LLDAP Administration</title>
|
||||
<script src="/static/main.js" type="module" defer></script>
|
||||
<base href="/">
|
||||
<script src="static/main.js" type="module" defer></script>
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap-dark-5@1.1.3/dist/css/bootstrap-nightshade.min.css"
|
||||
rel="preload stylesheet"
|
||||
@@ -33,7 +34,7 @@
|
||||
href="https://fonts.googleapis.com/css2?family=Bebas+Neue&display=swap" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="/static/style.css" />
|
||||
href="static/style.css" />
|
||||
<script>
|
||||
function inDarkMode(){
|
||||
return darkmode.inDarkMode;
|
||||
|
||||
5
app/queries/create_group_attribute.graphql
Normal file
5
app/queries/create_group_attribute.graphql
Normal file
@@ -0,0 +1,5 @@
|
||||
mutation CreateGroupAttribute($name: String!, $attributeType: AttributeType!, $isList: Boolean!, $isVisible: Boolean!) {
|
||||
addGroupAttribute(name: $name, attributeType: $attributeType, isList: $isList, isVisible: $isVisible, isEditable: false) {
|
||||
ok
|
||||
}
|
||||
}
|
||||
5
app/queries/create_user_attribute.graphql
Normal file
5
app/queries/create_user_attribute.graphql
Normal file
@@ -0,0 +1,5 @@
|
||||
mutation CreateUserAttribute($name: String!, $attributeType: AttributeType!, $isList: Boolean!, $isVisible: Boolean!, $isEditable: Boolean!) {
|
||||
addUserAttribute(name: $name, attributeType: $attributeType, isList: $isList, isVisible: $isVisible, isEditable: $isEditable) {
|
||||
ok
|
||||
}
|
||||
}
|
||||
5
app/queries/delete_group_attribute.graphql
Normal file
5
app/queries/delete_group_attribute.graphql
Normal file
@@ -0,0 +1,5 @@
|
||||
mutation DeleteGroupAttributeQuery($name: String!) {
|
||||
deleteGroupAttribute(name: $name) {
|
||||
ok
|
||||
}
|
||||
}
|
||||
5
app/queries/delete_user_attribute.graphql
Normal file
5
app/queries/delete_user_attribute.graphql
Normal file
@@ -0,0 +1,5 @@
|
||||
mutation DeleteUserAttributeQuery($name: String!) {
|
||||
deleteUserAttribute(name: $name) {
|
||||
ok
|
||||
}
|
||||
}
|
||||
13
app/queries/get_group_attributes_schema.graphql
Normal file
13
app/queries/get_group_attributes_schema.graphql
Normal file
@@ -0,0 +1,13 @@
|
||||
query GetGroupAttributesSchema {
|
||||
schema {
|
||||
groupSchema {
|
||||
attributes {
|
||||
name
|
||||
attributeType
|
||||
isList
|
||||
isVisible
|
||||
isHardcoded
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
14
app/queries/get_user_attributes_schema.graphql
Normal file
14
app/queries/get_user_attributes_schema.graphql
Normal file
@@ -0,0 +1,14 @@
|
||||
query GetUserAttributesSchema {
|
||||
schema {
|
||||
userSchema {
|
||||
attributes {
|
||||
name
|
||||
attributeType
|
||||
isList
|
||||
isVisible
|
||||
isEditable
|
||||
isHardcoded
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,20 @@ query GetUserDetails($id: String!) {
|
||||
groups {
|
||||
id
|
||||
displayName
|
||||
}
|
||||
attributes {
|
||||
name
|
||||
value
|
||||
}
|
||||
}
|
||||
schema {
|
||||
user_attrubutes {
|
||||
attributes {
|
||||
name
|
||||
attributeType
|
||||
isEditable
|
||||
isHardcoded
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
use crate::{
|
||||
components::{
|
||||
banner::Banner,
|
||||
change_password::ChangePasswordForm,
|
||||
create_group::CreateGroupForm,
|
||||
create_group_attribute::CreateGroupAttributeForm,
|
||||
create_user::CreateUserForm,
|
||||
create_user_attribute::CreateUserAttributeForm,
|
||||
group_details::GroupDetails,
|
||||
group_schema_table::ListGroupSchema,
|
||||
group_table::GroupTable,
|
||||
login::LoginForm,
|
||||
logout::LogoutButton,
|
||||
reset_password_step1::ResetPasswordStep1Form,
|
||||
reset_password_step2::ResetPasswordStep2Form,
|
||||
router::{AppRoute, Link, Redirect},
|
||||
user_details::UserDetails,
|
||||
user_schema_table::ListUserSchema,
|
||||
user_table::UserTable,
|
||||
},
|
||||
infra::{api::HostService, cookies::get_cookie},
|
||||
};
|
||||
|
||||
use gloo_console::error;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use yew::{
|
||||
function_component,
|
||||
html::Scope,
|
||||
@@ -30,25 +33,6 @@ use yew_router::{
|
||||
BrowserRouter, Switch,
|
||||
};
|
||||
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_namespace = darkmode)]
|
||||
fn toggleDarkMode(doSave: bool);
|
||||
|
||||
#[wasm_bindgen]
|
||||
fn inDarkMode() -> bool;
|
||||
}
|
||||
|
||||
#[function_component(DarkModeToggle)]
|
||||
pub fn dark_mode_toggle() -> Html {
|
||||
html! {
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" onclick={|_| toggleDarkMode(true)} type="checkbox" id="darkModeToggle" checked={inDarkMode()}/>
|
||||
<label class="form-check-label" for="darkModeToggle">{"Dark mode"}</label>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[function_component(AppContainer)]
|
||||
pub fn app_container() -> Html {
|
||||
html! {
|
||||
@@ -135,10 +119,11 @@ impl Component for App {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link().clone();
|
||||
let is_admin = self.is_admin();
|
||||
let username = self.user_info.clone().map(|(username, _)| username);
|
||||
let password_reset_enabled = self.password_reset_enabled;
|
||||
html! {
|
||||
<div>
|
||||
{self.view_banner(ctx)}
|
||||
<Banner is_admin={is_admin} username={username} on_logged_out={link.callback(|_| Msg::Logout)} />
|
||||
<div class="container py-3 bg-kug">
|
||||
<div class="row justify-content-center" style="padding-bottom: 80px;">
|
||||
<main class="py-3" style="max-width: 1000px">
|
||||
@@ -227,6 +212,12 @@ impl App {
|
||||
AppRoute::CreateGroup => html! {
|
||||
<CreateGroupForm/>
|
||||
},
|
||||
AppRoute::CreateUserAttribute => html! {
|
||||
<CreateUserAttributeForm/>
|
||||
},
|
||||
AppRoute::CreateGroupAttribute => html! {
|
||||
<CreateGroupAttributeForm/>
|
||||
},
|
||||
AppRoute::ListGroups => html! {
|
||||
<div>
|
||||
<GroupTable />
|
||||
@@ -236,6 +227,12 @@ impl App {
|
||||
</Link>
|
||||
</div>
|
||||
},
|
||||
AppRoute::ListUserSchema => html! {
|
||||
<ListUserSchema />
|
||||
},
|
||||
AppRoute::ListGroupSchema => html! {
|
||||
<ListGroupSchema />
|
||||
},
|
||||
AppRoute::GroupDetails { group_id } => html! {
|
||||
<GroupDetails group_id={*group_id} />
|
||||
},
|
||||
@@ -263,91 +260,6 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn view_banner(&self, ctx: &Context<Self>) -> Html {
|
||||
html! {
|
||||
<header class="p-2 mb-3 border-bottom">
|
||||
<div class="container">
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-center justify-content-lg-start">
|
||||
<a href="/" class="d-flex align-items-center mt-2 mb-lg-0 me-md-5 text-decoration-none">
|
||||
<h2>{"LLDAP"}</h2>
|
||||
</a>
|
||||
|
||||
<ul class="nav col-12 col-lg-auto me-lg-auto mb-2 justify-content-center mb-md-0">
|
||||
{if self.is_admin() { html! {
|
||||
<>
|
||||
<li>
|
||||
<Link
|
||||
classes="nav-link px-2 h6"
|
||||
to={AppRoute::ListUsers}>
|
||||
<i class="bi-people me-2"></i>
|
||||
{"Users"}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
classes="nav-link px-2 h6"
|
||||
to={AppRoute::ListGroups}>
|
||||
<i class="bi-collection me-2"></i>
|
||||
{"Groups"}
|
||||
</Link>
|
||||
</li>
|
||||
</>
|
||||
} } else { html!{} } }
|
||||
</ul>
|
||||
{ self.view_user_menu(ctx) }
|
||||
<DarkModeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
}
|
||||
}
|
||||
|
||||
fn view_user_menu(&self, ctx: &Context<Self>) -> Html {
|
||||
if let Some((user_id, _)) = &self.user_info {
|
||||
let link = ctx.link();
|
||||
html! {
|
||||
<div class="dropdown text-end">
|
||||
<a href="#"
|
||||
class="d-block nav-link text-decoration-none dropdown-toggle"
|
||||
id="dropdownUser"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false">
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
fill="currentColor"
|
||||
class="bi bi-person-circle"
|
||||
viewBox="0 0 16 16">
|
||||
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
|
||||
<path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1z"/>
|
||||
</svg>
|
||||
<span class="ms-2">
|
||||
{user_id}
|
||||
</span>
|
||||
</a>
|
||||
<ul
|
||||
class="dropdown-menu text-small dropdown-menu-lg-end"
|
||||
aria-labelledby="dropdownUser1"
|
||||
style="">
|
||||
<li>
|
||||
<Link
|
||||
classes="dropdown-item"
|
||||
to={AppRoute::UserDetails{ user_id: user_id.clone() }}>
|
||||
{"View details"}
|
||||
</Link>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider" /></li>
|
||||
<li>
|
||||
<LogoutButton on_logged_out={link.callback(|_| Msg::Logout)} />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
|
||||
fn view_footer(&self) -> Html {
|
||||
html! {
|
||||
<footer class="text-center fixed-bottom text-muted bg-light py-2">
|
||||
@@ -355,7 +267,7 @@ impl App {
|
||||
<span>{format!("LLDAP version {}", env!("CARGO_PKG_VERSION"))}</span>
|
||||
</div>
|
||||
<div>
|
||||
<a href="https://github.com/nitnelave/lldap" class="me-4 text-reset">
|
||||
<a href="https://github.com/lldap/lldap" class="me-4 text-reset">
|
||||
<i class="bi-github"></i>
|
||||
</a>
|
||||
<a href="https://discord.gg/h5PEdRMNyP" class="me-4 text-reset">
|
||||
@@ -366,7 +278,7 @@ impl App {
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<span>{"License "}<a href="https://github.com/nitnelave/lldap/blob/main/LICENSE" class="link-secondary">{"GNU GPL"}</a></span>
|
||||
<span>{"License "}<a href="https://github.com/lldap/lldap/blob/main/LICENSE" class="link-secondary">{"GNU GPL"}</a></span>
|
||||
</div>
|
||||
</footer>
|
||||
}
|
||||
|
||||
87
app/src/components/avatar.rs
Normal file
87
app/src/components/avatar.rs
Normal file
@@ -0,0 +1,87 @@
|
||||
use crate::infra::functional::{use_graphql_call, LoadableResult};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use yew::{function_component, html, virtual_dom::AttrValue, Properties};
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "../schema.graphql",
|
||||
query_path = "queries/get_user_details.graphql",
|
||||
response_derives = "Debug, Hash, PartialEq, Eq, Clone",
|
||||
custom_scalars_module = "crate::infra::graphql"
|
||||
)]
|
||||
pub struct GetUserDetails;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct Props {
|
||||
pub user: AttrValue,
|
||||
#[prop_or(32)]
|
||||
pub width: i32,
|
||||
#[prop_or(32)]
|
||||
pub height: i32,
|
||||
}
|
||||
|
||||
#[function_component(Avatar)]
|
||||
pub fn avatar(props: &Props) -> Html {
|
||||
let user_details = use_graphql_call::<GetUserDetails>(get_user_details::Variables {
|
||||
id: props.user.to_string(),
|
||||
});
|
||||
|
||||
match &(*user_details) {
|
||||
LoadableResult::Loaded(Ok(response)) => {
|
||||
let avatar = response.user.avatar.clone();
|
||||
match &avatar {
|
||||
Some(data) => html! {
|
||||
<img
|
||||
id="avatarDisplay"
|
||||
src={format!("data:image/jpeg;base64, {}", data)}
|
||||
style={format!("max-height:{}px;max-width:{}px;height:auto;width:auto;", props.height, props.width)}
|
||||
alt="Avatar" />
|
||||
},
|
||||
None => html! {
|
||||
<BlankAvatarDisplay
|
||||
width={props.width}
|
||||
height={props.height} />
|
||||
},
|
||||
}
|
||||
}
|
||||
LoadableResult::Loaded(Err(error)) => html! {
|
||||
<BlankAvatarDisplay
|
||||
error={error.to_string()}
|
||||
width={props.width}
|
||||
height={props.height} />
|
||||
},
|
||||
LoadableResult::Loading => html! {
|
||||
<BlankAvatarDisplay
|
||||
width={props.width}
|
||||
height={props.height} />
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
struct BlankAvatarDisplayProps {
|
||||
#[prop_or(None)]
|
||||
pub error: Option<AttrValue>,
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
}
|
||||
|
||||
#[function_component(BlankAvatarDisplay)]
|
||||
fn blank_avatar_display(props: &BlankAvatarDisplayProps) -> Html {
|
||||
let fill = match &props.error {
|
||||
Some(_) => "red",
|
||||
None => "currentColor",
|
||||
};
|
||||
html! {
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
width={props.width.to_string()}
|
||||
height={props.height.to_string()}
|
||||
fill={fill}
|
||||
class="bi bi-person-circle"
|
||||
viewBox="0 0 16 16">
|
||||
<title>{props.error.clone().unwrap_or(AttrValue::Static("Avatar"))}</title>
|
||||
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
|
||||
<path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1z"/>
|
||||
</svg>
|
||||
}
|
||||
}
|
||||
132
app/src/components/banner.rs
Normal file
132
app/src/components/banner.rs
Normal file
@@ -0,0 +1,132 @@
|
||||
use crate::components::{
|
||||
avatar::Avatar,
|
||||
logout::LogoutButton,
|
||||
router::{AppRoute, Link},
|
||||
};
|
||||
use wasm_bindgen::prelude::wasm_bindgen;
|
||||
use yew::{function_component, html, Callback, Properties};
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct Props {
|
||||
pub is_admin: bool,
|
||||
pub username: Option<String>,
|
||||
pub on_logged_out: Callback<()>,
|
||||
}
|
||||
|
||||
#[function_component(Banner)]
|
||||
pub fn banner(props: &Props) -> Html {
|
||||
html! {
|
||||
<header class="p-2 mb-3 border-bottom">
|
||||
<div class="container">
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-center justify-content-lg-start">
|
||||
<a href={yew_router::utils::base_url().unwrap_or("/".to_string())} class="d-flex align-items-center mt-2 mb-lg-0 me-md-5 text-decoration-none">
|
||||
<h2>{"LLDAP"}</h2>
|
||||
</a>
|
||||
|
||||
<ul class="nav col-12 col-lg-auto me-lg-auto mb-2 justify-content-center mb-md-0">
|
||||
{if props.is_admin { html! {
|
||||
<>
|
||||
<li>
|
||||
<Link
|
||||
classes="nav-link px-2 h6"
|
||||
to={AppRoute::ListUsers}>
|
||||
<i class="bi-people me-2"></i>
|
||||
{"Users"}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
classes="nav-link px-2 h6"
|
||||
to={AppRoute::ListGroups}>
|
||||
<i class="bi-collection me-2"></i>
|
||||
{"Groups"}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
classes="nav-link px-2 h6"
|
||||
to={AppRoute::ListUserSchema}>
|
||||
<i class="bi-list-ul me-2"></i>
|
||||
{"User schema"}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
classes="nav-link px-2 h6"
|
||||
to={AppRoute::ListGroupSchema}>
|
||||
<i class="bi-list-ul me-2"></i>
|
||||
{"Group schema"}
|
||||
</Link>
|
||||
</li>
|
||||
</>
|
||||
} } else { html!{} } }
|
||||
</ul>
|
||||
<UserMenu username={props.username.clone()} on_logged_out={props.on_logged_out.clone()}/>
|
||||
<DarkModeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
struct UserMenuProps {
|
||||
pub username: Option<String>,
|
||||
pub on_logged_out: Callback<()>,
|
||||
}
|
||||
|
||||
#[function_component(UserMenu)]
|
||||
fn user_menu(props: &UserMenuProps) -> Html {
|
||||
match &props.username {
|
||||
Some(username) => html! {
|
||||
<div class="dropdown text-end">
|
||||
<a href="#"
|
||||
class="d-block nav-link text-decoration-none dropdown-toggle"
|
||||
id="dropdownUser"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false">
|
||||
<Avatar user={username.clone()} />
|
||||
<span class="ms-2">
|
||||
{username}
|
||||
</span>
|
||||
</a>
|
||||
<ul
|
||||
class="dropdown-menu text-small dropdown-menu-lg-end"
|
||||
aria-labelledby="dropdownUser1"
|
||||
style="">
|
||||
<li>
|
||||
<Link
|
||||
classes="dropdown-item"
|
||||
to={AppRoute::UserDetails{ user_id: username.to_string() }}>
|
||||
{"View details"}
|
||||
</Link>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider" /></li>
|
||||
<li>
|
||||
<LogoutButton on_logged_out={props.on_logged_out.clone()} />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
},
|
||||
_ => html! {},
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_namespace = darkmode)]
|
||||
fn toggleDarkMode(doSave: bool);
|
||||
|
||||
#[wasm_bindgen]
|
||||
fn inDarkMode() -> bool;
|
||||
}
|
||||
|
||||
#[function_component(DarkModeToggle)]
|
||||
fn dark_mode_toggle() -> Html {
|
||||
html! {
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" onclick={|_| toggleDarkMode(true)} type="checkbox" id="darkModeToggle" checked={inDarkMode()}/>
|
||||
<label class="form-check-label" for="darkModeToggle">{"Dark mode"}</label>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
use crate::{
|
||||
components::router::{AppRoute, Link},
|
||||
components::{
|
||||
form::{field::Field, submit::Submit},
|
||||
router::{AppRoute, Link},
|
||||
},
|
||||
infra::{
|
||||
api::HostService,
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
@@ -97,7 +100,7 @@ impl CommonComponent<ChangePasswordForm> for ChangePasswordForm {
|
||||
.context("Could not initialize login")?;
|
||||
self.opaque_data = OpaqueData::Login(login_start_request.state);
|
||||
let req = login::ClientLoginStartRequest {
|
||||
username: ctx.props().username.clone(),
|
||||
username: ctx.props().username.clone().into(),
|
||||
login_start_request: login_start_request.message,
|
||||
};
|
||||
self.common.call_backend(
|
||||
@@ -134,7 +137,7 @@ impl CommonComponent<ChangePasswordForm> for ChangePasswordForm {
|
||||
)
|
||||
.context("Could not initiate password change")?;
|
||||
let req = registration::ClientRegistrationStartRequest {
|
||||
username: ctx.props().username.clone(),
|
||||
username: ctx.props().username.clone().into(),
|
||||
registration_start_request: registration_start_request.message,
|
||||
};
|
||||
self.opaque_data = OpaqueData::Registration(registration_start_request.state);
|
||||
@@ -207,7 +210,6 @@ impl Component for ChangePasswordForm {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let is_admin = ctx.props().is_admin;
|
||||
let link = ctx.link();
|
||||
type Field = yew_form::Field<FormModel>;
|
||||
html! {
|
||||
<>
|
||||
<div class="mb-2 mt-2">
|
||||
@@ -224,90 +226,44 @@ impl Component for ChangePasswordForm {
|
||||
}
|
||||
} else { html! {} }
|
||||
}
|
||||
<form
|
||||
class="form">
|
||||
<form class="form">
|
||||
{if !is_admin { html! {
|
||||
<div class="form-group row">
|
||||
<label for="old_password"
|
||||
class="form-label col-sm-2 col-form-label">
|
||||
{"Current password*:"}
|
||||
</label>
|
||||
<div class="col-sm-10">
|
||||
<Field
|
||||
form={&self.form}
|
||||
field_name="old_password"
|
||||
input_type="password"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="current-password"
|
||||
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("old_password")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Field<FormModel>
|
||||
form={&self.form}
|
||||
required=true
|
||||
label="Current password"
|
||||
field_name="old_password"
|
||||
input_type="password"
|
||||
autocomplete="current-password"
|
||||
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||
}} else { html! {} }}
|
||||
<div class="form-group row mb-3">
|
||||
<label for="new_password"
|
||||
class="form-label col-sm-2 col-form-label">
|
||||
{"New Password"}
|
||||
<span class="text-danger">{"*"}</span>
|
||||
{":"}
|
||||
</label>
|
||||
<div class="col-sm-10">
|
||||
<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={link.callback(|_| Msg::FormUpdate)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("password")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="confirm_password"
|
||||
class="form-label col-sm-2 col-form-label">
|
||||
{"Confirm Password"}
|
||||
<span class="text-danger">{"*"}</span>
|
||||
{":"}
|
||||
</label>
|
||||
<div class="col-sm-10">
|
||||
<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={link.callback(|_| Msg::FormUpdate)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("confirm_password")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row justify-content-center">
|
||||
<button
|
||||
class="btn btn-primary col-auto col-form-label"
|
||||
type="submit"
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}>
|
||||
<i class="bi-save me-2"></i>
|
||||
{"Save changes"}
|
||||
</button>
|
||||
<Field<FormModel>
|
||||
form={&self.form}
|
||||
required=true
|
||||
label="New password"
|
||||
field_name="password"
|
||||
input_type="password"
|
||||
autocomplete="new-password"
|
||||
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||
<Field<FormModel>
|
||||
form={&self.form}
|
||||
required=true
|
||||
label="Confirm password"
|
||||
field_name="confirm_password"
|
||||
input_type="password"
|
||||
autocomplete="new-password"
|
||||
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||
<Submit
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}
|
||||
text="Save changes" >
|
||||
<Link
|
||||
classes="btn btn-secondary ms-2 col-auto col-form-label"
|
||||
to={AppRoute::UserDetails{user_id: ctx.props().username.clone()}}>
|
||||
<i class="bi-arrow-return-left me-2"></i>
|
||||
{"Back"}
|
||||
</Link>
|
||||
</div>
|
||||
</Submit>
|
||||
</form>
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use crate::{
|
||||
components::router::AppRoute,
|
||||
components::{
|
||||
form::{field::Field, submit::Submit},
|
||||
router::AppRoute,
|
||||
},
|
||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||
};
|
||||
use anyhow::{bail, Result};
|
||||
@@ -93,44 +96,21 @@ impl Component for CreateGroupForm {
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
type Field = yew_form::Field<CreateGroupModel>;
|
||||
html! {
|
||||
<div class="row justify-content-center">
|
||||
<form class="form py-3" style="max-width: 636px">
|
||||
<div class="row mb-3">
|
||||
<h5 class="fw-bold">{"Create a group"}</h5>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="groupname"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Group name"}
|
||||
<span class="text-danger">{"*"}</span>
|
||||
{":"}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
form={&self.form}
|
||||
field_name="groupname"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="groupname"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("groupname")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row justify-content-center">
|
||||
<button
|
||||
class="btn btn-primary col-auto col-form-label"
|
||||
type="submit"
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})}>
|
||||
<i class="bi-save me-2"></i>
|
||||
{"Submit"}
|
||||
</button>
|
||||
</div>
|
||||
<Field<CreateGroupModel>
|
||||
form={&self.form}
|
||||
required=true
|
||||
label="Group name"
|
||||
field_name="groupname"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<Submit
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})} />
|
||||
</form>
|
||||
{ if let Some(e) = &self.common.error {
|
||||
html! {
|
||||
|
||||
168
app/src/components/create_group_attribute.rs
Normal file
168
app/src/components/create_group_attribute.rs
Normal file
@@ -0,0 +1,168 @@
|
||||
use crate::{
|
||||
components::{
|
||||
form::{checkbox::CheckBox, field::Field, select::Select, submit::Submit},
|
||||
router::AppRoute,
|
||||
},
|
||||
convert_attribute_type,
|
||||
infra::{
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
schema::{validate_attribute_type, AttributeType},
|
||||
},
|
||||
};
|
||||
use anyhow::{bail, Result};
|
||||
use gloo_console::log;
|
||||
use graphql_client::GraphQLQuery;
|
||||
use validator_derive::Validate;
|
||||
use yew::prelude::*;
|
||||
use yew_form_derive::Model;
|
||||
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "../schema.graphql",
|
||||
query_path = "queries/create_group_attribute.graphql",
|
||||
response_derives = "Debug",
|
||||
custom_scalars_module = "crate::infra::graphql"
|
||||
)]
|
||||
pub struct CreateGroupAttribute;
|
||||
|
||||
convert_attribute_type!(create_group_attribute::AttributeType);
|
||||
|
||||
pub struct CreateGroupAttributeForm {
|
||||
common: CommonComponentParts<Self>,
|
||||
form: yew_form::Form<CreateGroupAttributeModel>,
|
||||
}
|
||||
|
||||
#[derive(Model, Validate, PartialEq, Eq, Clone, Default, Debug)]
|
||||
pub struct CreateGroupAttributeModel {
|
||||
#[validate(length(min = 1, message = "attribute_name is required"))]
|
||||
attribute_name: String,
|
||||
#[validate(custom = "validate_attribute_type")]
|
||||
attribute_type: String,
|
||||
is_list: bool,
|
||||
is_visible: bool, // remove when backend doesn't return group attributes for normal users
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
Update,
|
||||
SubmitForm,
|
||||
CreateGroupAttributeResponse(Result<create_group_attribute::ResponseData>),
|
||||
}
|
||||
|
||||
impl CommonComponent<CreateGroupAttributeForm> for CreateGroupAttributeForm {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::Update => Ok(true),
|
||||
Msg::SubmitForm => {
|
||||
if !self.form.validate() {
|
||||
bail!("Check the form for errors");
|
||||
}
|
||||
let model = self.form.model();
|
||||
let attribute_type = model.attribute_type.parse::<AttributeType>().unwrap();
|
||||
let req = create_group_attribute::Variables {
|
||||
name: model.attribute_name,
|
||||
attribute_type: create_group_attribute::AttributeType::from(attribute_type),
|
||||
is_list: model.is_list,
|
||||
is_visible: model.is_visible,
|
||||
};
|
||||
self.common.call_graphql::<CreateGroupAttribute, _>(
|
||||
ctx,
|
||||
req,
|
||||
Msg::CreateGroupAttributeResponse,
|
||||
"Error trying to create group attribute",
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
Msg::CreateGroupAttributeResponse(response) => {
|
||||
response?;
|
||||
let model = self.form.model();
|
||||
log!(&format!(
|
||||
"Created group attribute '{}'",
|
||||
model.attribute_name
|
||||
));
|
||||
ctx.link()
|
||||
.history()
|
||||
.unwrap()
|
||||
.push(AppRoute::ListGroupSchema);
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for CreateGroupAttributeForm {
|
||||
type Message = Msg;
|
||||
type Properties = ();
|
||||
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
let model = CreateGroupAttributeModel {
|
||||
attribute_type: AttributeType::String.to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
Self {
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
form: yew_form::Form::<CreateGroupAttributeModel>::new(model),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
html! {
|
||||
<div class="row justify-content-center">
|
||||
<form class="form py-3" style="max-width: 636px">
|
||||
<h5 class="fw-bold">{"Create a group attribute"}</h5>
|
||||
<Field<CreateGroupAttributeModel>
|
||||
label="Name"
|
||||
required={true}
|
||||
form={&self.form}
|
||||
field_name="attribute_name"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<Select<CreateGroupAttributeModel>
|
||||
label="Type"
|
||||
required={true}
|
||||
form={&self.form}
|
||||
field_name="attribute_type"
|
||||
oninput={link.callback(|_| Msg::Update)}>
|
||||
<option selected=true value="String">{"String"}</option>
|
||||
<option value="Integer">{"Integer"}</option>
|
||||
<option value="Jpeg">{"Jpeg"}</option>
|
||||
<option value="DateTime">{"DateTime"}</option>
|
||||
</Select<CreateGroupAttributeModel>>
|
||||
<CheckBox<CreateGroupAttributeModel>
|
||||
label="Multiple values"
|
||||
form={&self.form}
|
||||
field_name="is_list"
|
||||
ontoggle={link.callback(|_| Msg::Update)} />
|
||||
<CheckBox<CreateGroupAttributeModel>
|
||||
label="Visible to users"
|
||||
form={&self.form}
|
||||
field_name="is_visible"
|
||||
ontoggle={link.callback(|_| Msg::Update)} />
|
||||
<Submit
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})}/>
|
||||
</form>
|
||||
{ if let Some(e) = &self.common.error {
|
||||
html! {
|
||||
<div class="alert alert-danger">
|
||||
{e.to_string() }
|
||||
</div>
|
||||
}
|
||||
} else { html! {} }
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
use crate::{
|
||||
components::router::AppRoute,
|
||||
components::{
|
||||
form::{field::Field, submit::Submit},
|
||||
router::AppRoute,
|
||||
},
|
||||
infra::{
|
||||
api::HostService,
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
@@ -90,6 +93,7 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
|
||||
firstName: to_option(model.first_name),
|
||||
lastName: to_option(model.last_name),
|
||||
avatar: None,
|
||||
attributes: None,
|
||||
},
|
||||
};
|
||||
self.common.call_graphql::<CreateUser, _>(
|
||||
@@ -122,7 +126,7 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
|
||||
&mut rng,
|
||||
)?;
|
||||
let req = registration::ClientRegistrationStartRequest {
|
||||
username: user_id,
|
||||
username: user_id.into(),
|
||||
registration_start_request: message,
|
||||
};
|
||||
self.common
|
||||
@@ -186,163 +190,57 @@ impl Component for CreateUserForm {
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
type Field = yew_form::Field<CreateUserModel>;
|
||||
html! {
|
||||
<div class="row justify-content-center">
|
||||
<form class="form py-3" style="max-width: 636px">
|
||||
<div class="row mb-3">
|
||||
<h5 class="fw-bold">{"Create a user"}</h5>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="username"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"User name"}
|
||||
<span class="text-danger">{"*"}</span>
|
||||
{":"}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
form={&self.form}
|
||||
field_name="username"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="username"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("username")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="email"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Email"}
|
||||
<span class="text-danger">{"*"}</span>
|
||||
{":"}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
form={&self.form}
|
||||
input_type="email"
|
||||
field_name="email"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="email"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("email")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="display_name"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Display name:"}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
form={&self.form}
|
||||
autocomplete="name"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
field_name="display_name"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("display_name")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="first_name"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"First name:"}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
form={&self.form}
|
||||
autocomplete="given-name"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
field_name="first_name"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("first_name")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="last_name"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Last name:"}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
form={&self.form}
|
||||
autocomplete="family-name"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
field_name="last_name"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("last_name")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="password"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Password:"}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
form={&self.form}
|
||||
input_type="password"
|
||||
field_name="password"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="new-password"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("password")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="confirm_password"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Confirm password:"}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
form={&self.form}
|
||||
input_type="password"
|
||||
field_name="confirm_password"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="new-password"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("confirm_password")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row justify-content-center">
|
||||
<button
|
||||
class="btn btn-primary col-auto col-form-label mt-4"
|
||||
disabled={self.common.is_task_running()}
|
||||
type="submit"
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})}>
|
||||
<i class="bi-save me-2"></i>
|
||||
{"Submit"}
|
||||
</button>
|
||||
</div>
|
||||
<Field<CreateUserModel>
|
||||
form={&self.form}
|
||||
required=true
|
||||
label="User name"
|
||||
field_name="username"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<Field<CreateUserModel>
|
||||
form={&self.form}
|
||||
required=true
|
||||
label="Email"
|
||||
field_name="email"
|
||||
input_type="email"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<Field<CreateUserModel>
|
||||
form={&self.form}
|
||||
label="Display name"
|
||||
field_name="display_name"
|
||||
autocomplete="name"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<Field<CreateUserModel>
|
||||
form={&self.form}
|
||||
label="First name"
|
||||
field_name="first_name"
|
||||
autocomplete="given-name"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<Field<CreateUserModel>
|
||||
form={&self.form}
|
||||
label="Last name"
|
||||
field_name="last_name"
|
||||
autocomplete="family-name"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<Field<CreateUserModel>
|
||||
form={&self.form}
|
||||
label="Password"
|
||||
field_name="password"
|
||||
input_type="password"
|
||||
autocomplete="new-password"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<Field<CreateUserModel>
|
||||
form={&self.form}
|
||||
label="Confirm password"
|
||||
field_name="confirm_password"
|
||||
input_type="password"
|
||||
autocomplete="new-password"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<Submit
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})} />
|
||||
</form>
|
||||
{
|
||||
if let Some(e) = &self.common.error {
|
||||
|
||||
175
app/src/components/create_user_attribute.rs
Normal file
175
app/src/components/create_user_attribute.rs
Normal file
@@ -0,0 +1,175 @@
|
||||
use crate::{
|
||||
components::{
|
||||
form::{checkbox::CheckBox, field::Field, select::Select, submit::Submit},
|
||||
router::AppRoute,
|
||||
},
|
||||
convert_attribute_type,
|
||||
infra::{
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
schema::{validate_attribute_type, AttributeType},
|
||||
},
|
||||
};
|
||||
use anyhow::{bail, Result};
|
||||
use gloo_console::log;
|
||||
use graphql_client::GraphQLQuery;
|
||||
use validator_derive::Validate;
|
||||
use yew::prelude::*;
|
||||
use yew_form_derive::Model;
|
||||
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "../schema.graphql",
|
||||
query_path = "queries/create_user_attribute.graphql",
|
||||
response_derives = "Debug",
|
||||
custom_scalars_module = "crate::infra::graphql"
|
||||
)]
|
||||
pub struct CreateUserAttribute;
|
||||
|
||||
convert_attribute_type!(create_user_attribute::AttributeType);
|
||||
|
||||
pub struct CreateUserAttributeForm {
|
||||
common: CommonComponentParts<Self>,
|
||||
form: yew_form::Form<CreateUserAttributeModel>,
|
||||
}
|
||||
|
||||
#[derive(Model, Validate, PartialEq, Eq, Clone, Default, Debug)]
|
||||
pub struct CreateUserAttributeModel {
|
||||
#[validate(length(min = 1, message = "attribute_name is required"))]
|
||||
attribute_name: String,
|
||||
#[validate(custom = "validate_attribute_type")]
|
||||
attribute_type: String,
|
||||
is_editable: bool,
|
||||
is_list: bool,
|
||||
is_visible: bool,
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
Update,
|
||||
SubmitForm,
|
||||
CreateUserAttributeResponse(Result<create_user_attribute::ResponseData>),
|
||||
}
|
||||
|
||||
impl CommonComponent<CreateUserAttributeForm> for CreateUserAttributeForm {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::Update => Ok(true),
|
||||
Msg::SubmitForm => {
|
||||
if !self.form.validate() {
|
||||
bail!("Check the form for errors");
|
||||
}
|
||||
let model = self.form.model();
|
||||
if model.is_editable && !model.is_visible {
|
||||
bail!("Editable attributes must also be visible");
|
||||
}
|
||||
let attribute_type = model.attribute_type.parse::<AttributeType>().unwrap();
|
||||
let req = create_user_attribute::Variables {
|
||||
name: model.attribute_name,
|
||||
attribute_type: create_user_attribute::AttributeType::from(attribute_type),
|
||||
is_editable: model.is_editable,
|
||||
is_list: model.is_list,
|
||||
is_visible: model.is_visible,
|
||||
};
|
||||
self.common.call_graphql::<CreateUserAttribute, _>(
|
||||
ctx,
|
||||
req,
|
||||
Msg::CreateUserAttributeResponse,
|
||||
"Error trying to create user attribute",
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
Msg::CreateUserAttributeResponse(response) => {
|
||||
response?;
|
||||
let model = self.form.model();
|
||||
log!(&format!(
|
||||
"Created user attribute '{}'",
|
||||
model.attribute_name
|
||||
));
|
||||
ctx.link().history().unwrap().push(AppRoute::ListUserSchema);
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for CreateUserAttributeForm {
|
||||
type Message = Msg;
|
||||
type Properties = ();
|
||||
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
let model = CreateUserAttributeModel {
|
||||
attribute_type: AttributeType::String.to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
Self {
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
form: yew_form::Form::<CreateUserAttributeModel>::new(model),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
html! {
|
||||
<div class="row justify-content-center">
|
||||
<form class="form py-3" style="max-width: 636px">
|
||||
<h5 class="fw-bold">{"Create a user attribute"}</h5>
|
||||
<Field<CreateUserAttributeModel>
|
||||
label="Name"
|
||||
required={true}
|
||||
form={&self.form}
|
||||
field_name="attribute_name"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<Select<CreateUserAttributeModel>
|
||||
label="Type"
|
||||
required={true}
|
||||
form={&self.form}
|
||||
field_name="attribute_type"
|
||||
oninput={link.callback(|_| Msg::Update)}>
|
||||
<option selected=true value="String">{"String"}</option>
|
||||
<option value="Integer">{"Integer"}</option>
|
||||
<option value="Jpeg">{"Jpeg"}</option>
|
||||
<option value="DateTime">{"DateTime"}</option>
|
||||
</Select<CreateUserAttributeModel>>
|
||||
<CheckBox<CreateUserAttributeModel>
|
||||
label="Multiple values"
|
||||
form={&self.form}
|
||||
field_name="is_list"
|
||||
ontoggle={link.callback(|_| Msg::Update)} />
|
||||
<CheckBox<CreateUserAttributeModel>
|
||||
label="Visible to users"
|
||||
form={&self.form}
|
||||
field_name="is_visible"
|
||||
ontoggle={link.callback(|_| Msg::Update)} />
|
||||
<CheckBox<CreateUserAttributeModel>
|
||||
label="Editable by users"
|
||||
form={&self.form}
|
||||
field_name="is_editable"
|
||||
ontoggle={link.callback(|_| Msg::Update)} />
|
||||
<Submit
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})}/>
|
||||
</form>
|
||||
{ if let Some(e) = &self.common.error {
|
||||
html! {
|
||||
<div class="alert alert-danger">
|
||||
{e.to_string() }
|
||||
</div>
|
||||
}
|
||||
} else { html! {} }
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
172
app/src/components/delete_group_attribute.rs
Normal file
172
app/src/components/delete_group_attribute.rs
Normal file
@@ -0,0 +1,172 @@
|
||||
use crate::infra::{
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
modal::Modal,
|
||||
};
|
||||
use anyhow::{Error, Result};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "../schema.graphql",
|
||||
query_path = "queries/delete_group_attribute.graphql",
|
||||
response_derives = "Debug",
|
||||
custom_scalars_module = "crate::infra::graphql"
|
||||
)]
|
||||
pub struct DeleteGroupAttributeQuery;
|
||||
|
||||
pub struct DeleteGroupAttribute {
|
||||
common: CommonComponentParts<Self>,
|
||||
node_ref: NodeRef,
|
||||
modal: Option<Modal>,
|
||||
}
|
||||
|
||||
#[derive(yew::Properties, Clone, PartialEq, Debug)]
|
||||
pub struct DeleteGroupAttributeProps {
|
||||
pub attribute_name: String,
|
||||
pub on_attribute_deleted: Callback<String>,
|
||||
pub on_error: Callback<Error>,
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
ClickedDeleteGroupAttribute,
|
||||
ConfirmDeleteGroupAttribute,
|
||||
DismissModal,
|
||||
DeleteGroupAttributeResponse(Result<delete_group_attribute_query::ResponseData>),
|
||||
}
|
||||
|
||||
impl CommonComponent<DeleteGroupAttribute> for DeleteGroupAttribute {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::ClickedDeleteGroupAttribute => {
|
||||
self.modal.as_ref().expect("modal not initialized").show();
|
||||
}
|
||||
Msg::ConfirmDeleteGroupAttribute => {
|
||||
self.update(ctx, Msg::DismissModal);
|
||||
self.common.call_graphql::<DeleteGroupAttributeQuery, _>(
|
||||
ctx,
|
||||
delete_group_attribute_query::Variables {
|
||||
name: ctx.props().attribute_name.clone(),
|
||||
},
|
||||
Msg::DeleteGroupAttributeResponse,
|
||||
"Error trying to delete group attribute",
|
||||
);
|
||||
}
|
||||
Msg::DismissModal => {
|
||||
self.modal.as_ref().expect("modal not initialized").hide();
|
||||
}
|
||||
Msg::DeleteGroupAttributeResponse(response) => {
|
||||
response?;
|
||||
ctx.props()
|
||||
.on_attribute_deleted
|
||||
.emit(ctx.props().attribute_name.clone());
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for DeleteGroupAttribute {
|
||||
type Message = Msg;
|
||||
type Properties = DeleteGroupAttributeProps;
|
||||
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
Self {
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
node_ref: NodeRef::default(),
|
||||
modal: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn rendered(&mut self, _: &Context<Self>, first_render: bool) {
|
||||
if first_render {
|
||||
self.modal = Some(Modal::new(
|
||||
self.node_ref
|
||||
.cast::<web_sys::Element>()
|
||||
.expect("Modal node is not an element"),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update_and_report_error(
|
||||
self,
|
||||
ctx,
|
||||
msg,
|
||||
ctx.props().on_error.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
html! {
|
||||
<>
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|_| Msg::ClickedDeleteGroupAttribute)}>
|
||||
<i class="bi-x-circle-fill" aria-label="Delete attribute" />
|
||||
</button>
|
||||
{self.show_modal(ctx)}
|
||||
</>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DeleteGroupAttribute {
|
||||
fn show_modal(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
html! {
|
||||
<div
|
||||
class="modal fade"
|
||||
id={"deleteGroupAttributeModal".to_string() + &ctx.props().attribute_name}
|
||||
tabindex="-1"
|
||||
aria-labelledby="deleteGroupAttributeModalLabel"
|
||||
aria-hidden="true"
|
||||
ref={self.node_ref.clone()}>
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="deleteGroupAttributeModalLabel">{"Delete group attribute?"}</h5>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-close"
|
||||
aria-label="Close"
|
||||
onclick={link.callback(|_| Msg::DismissModal)} />
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<span>
|
||||
{"Are you sure you want to delete group attribute "}
|
||||
<b>{&ctx.props().attribute_name}</b>{"?"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
onclick={link.callback(|_| Msg::DismissModal)}>
|
||||
<i class="bi-x-circle me-2"></i>
|
||||
{"Cancel"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={link.callback(|_| Msg::ConfirmDeleteGroupAttribute)}
|
||||
class="btn btn-danger">
|
||||
<i class="bi-check-circle me-2"></i>
|
||||
{"Yes, I'm sure"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
172
app/src/components/delete_user_attribute.rs
Normal file
172
app/src/components/delete_user_attribute.rs
Normal file
@@ -0,0 +1,172 @@
|
||||
use crate::infra::{
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
modal::Modal,
|
||||
};
|
||||
use anyhow::{Error, Result};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "../schema.graphql",
|
||||
query_path = "queries/delete_user_attribute.graphql",
|
||||
response_derives = "Debug",
|
||||
custom_scalars_module = "crate::infra::graphql"
|
||||
)]
|
||||
pub struct DeleteUserAttributeQuery;
|
||||
|
||||
pub struct DeleteUserAttribute {
|
||||
common: CommonComponentParts<Self>,
|
||||
node_ref: NodeRef,
|
||||
modal: Option<Modal>,
|
||||
}
|
||||
|
||||
#[derive(yew::Properties, Clone, PartialEq, Debug)]
|
||||
pub struct DeleteUserAttributeProps {
|
||||
pub attribute_name: String,
|
||||
pub on_attribute_deleted: Callback<String>,
|
||||
pub on_error: Callback<Error>,
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
ClickedDeleteUserAttribute,
|
||||
ConfirmDeleteUserAttribute,
|
||||
DismissModal,
|
||||
DeleteUserAttributeResponse(Result<delete_user_attribute_query::ResponseData>),
|
||||
}
|
||||
|
||||
impl CommonComponent<DeleteUserAttribute> for DeleteUserAttribute {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::ClickedDeleteUserAttribute => {
|
||||
self.modal.as_ref().expect("modal not initialized").show();
|
||||
}
|
||||
Msg::ConfirmDeleteUserAttribute => {
|
||||
self.update(ctx, Msg::DismissModal);
|
||||
self.common.call_graphql::<DeleteUserAttributeQuery, _>(
|
||||
ctx,
|
||||
delete_user_attribute_query::Variables {
|
||||
name: ctx.props().attribute_name.clone(),
|
||||
},
|
||||
Msg::DeleteUserAttributeResponse,
|
||||
"Error trying to delete user attribute",
|
||||
);
|
||||
}
|
||||
Msg::DismissModal => {
|
||||
self.modal.as_ref().expect("modal not initialized").hide();
|
||||
}
|
||||
Msg::DeleteUserAttributeResponse(response) => {
|
||||
response?;
|
||||
ctx.props()
|
||||
.on_attribute_deleted
|
||||
.emit(ctx.props().attribute_name.clone());
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for DeleteUserAttribute {
|
||||
type Message = Msg;
|
||||
type Properties = DeleteUserAttributeProps;
|
||||
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
Self {
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
node_ref: NodeRef::default(),
|
||||
modal: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn rendered(&mut self, _: &Context<Self>, first_render: bool) {
|
||||
if first_render {
|
||||
self.modal = Some(Modal::new(
|
||||
self.node_ref
|
||||
.cast::<web_sys::Element>()
|
||||
.expect("Modal node is not an element"),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update_and_report_error(
|
||||
self,
|
||||
ctx,
|
||||
msg,
|
||||
ctx.props().on_error.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
html! {
|
||||
<>
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|_| Msg::ClickedDeleteUserAttribute)}>
|
||||
<i class="bi-x-circle-fill" aria-label="Delete attribute" />
|
||||
</button>
|
||||
{self.show_modal(ctx)}
|
||||
</>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DeleteUserAttribute {
|
||||
fn show_modal(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
html! {
|
||||
<div
|
||||
class="modal fade"
|
||||
id={"deleteUserAttributeModal".to_string() + &ctx.props().attribute_name}
|
||||
tabindex="-1"
|
||||
aria-labelledby="deleteUserAttributeModalLabel"
|
||||
aria-hidden="true"
|
||||
ref={self.node_ref.clone()}>
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="deleteUserAttributeModalLabel">{"Delete user attribute?"}</h5>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-close"
|
||||
aria-label="Close"
|
||||
onclick={link.callback(|_| Msg::DismissModal)} />
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<span>
|
||||
{"Are you sure you want to delete user attribute "}
|
||||
<b>{&ctx.props().attribute_name}</b>{"?"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
onclick={link.callback(|_| Msg::DismissModal)}>
|
||||
<i class="bi-x-circle me-2"></i>
|
||||
{"Cancel"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={link.callback(|_| Msg::ConfirmDeleteUserAttribute)}
|
||||
class="btn btn-danger">
|
||||
<i class="bi-check-circle me-2"></i>
|
||||
{"Yes, I'm sure"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
68
app/src/components/form/attribute_input.rs
Normal file
68
app/src/components/form/attribute_input.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
use yew::{function_component, html, virtual_dom::AttrValue, Callback, InputEvent, Properties, NodeRef};
|
||||
use crate::infra::schema::AttributeType;
|
||||
|
||||
/*
|
||||
<input
|
||||
ref={&ctx.props().input_ref}
|
||||
type="text"
|
||||
class="input-component"
|
||||
placeholder={placeholder}
|
||||
onmouseover={ctx.link().callback(|_| Msg::Hover)}
|
||||
/>
|
||||
*/
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
struct AttributeInputProps {
|
||||
name: AttrValue,
|
||||
attribute_type: AttributeType,
|
||||
#[prop_or(None)]
|
||||
value: Option<String>,
|
||||
}
|
||||
|
||||
#[function_component(AttributeInput)]
|
||||
fn attribute_input(props: &AttributeInputProps) -> Html {
|
||||
let input_type = match props.attribute_type {
|
||||
AttributeType::String => "text",
|
||||
AttributeType::Integer => "number",
|
||||
AttributeType::DateTime => "datetime-local",
|
||||
AttributeType::Jpeg => "file",
|
||||
};
|
||||
let accept = match props.attribute_type {
|
||||
AttributeType::Jpeg => Some("image/jpeg"),
|
||||
_ => None,
|
||||
};
|
||||
html! {
|
||||
<input
|
||||
type={input_type}
|
||||
accept={accept}
|
||||
name={props.name.clone()}
|
||||
class="form-control"
|
||||
value={props.value.clone()} />
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct SingleAttributeInputProps {
|
||||
pub name: AttrValue,
|
||||
pub attribute_type: AttributeType,
|
||||
#[prop_or(None)]
|
||||
pub value: Option<String>,
|
||||
}
|
||||
|
||||
#[function_component(SingleAttributeInput)]
|
||||
pub fn single_attribute_input(props: &SingleAttributeInputProps) -> Html {
|
||||
html! {
|
||||
<div class="row mb-3">
|
||||
<label for={props.name.clone()}
|
||||
class="form-label col-4 col-form-label">
|
||||
{&props.name}{":"}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<AttributeInput
|
||||
attribute_type={props.attribute_type}
|
||||
name={props.name}
|
||||
value={props.value} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
35
app/src/components/form/checkbox.rs
Normal file
35
app/src/components/form/checkbox.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
use yew::{function_component, html, virtual_dom::AttrValue, Callback, Properties};
|
||||
use yew_form::{Form, Model};
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct Props<T: Model> {
|
||||
pub label: AttrValue,
|
||||
pub field_name: String,
|
||||
pub form: Form<T>,
|
||||
#[prop_or(false)]
|
||||
pub required: bool,
|
||||
#[prop_or_else(Callback::noop)]
|
||||
pub ontoggle: Callback<bool>,
|
||||
}
|
||||
|
||||
#[function_component(CheckBox)]
|
||||
pub fn checkbox<T: Model>(props: &Props<T>) -> Html {
|
||||
html! {
|
||||
<div class="form-group row mb-3">
|
||||
<label for={props.field_name.clone()}
|
||||
class="form-label col-4 col-form-label">
|
||||
{&props.label}
|
||||
{if props.required {
|
||||
html!{<span class="text-danger">{"*"}</span>}
|
||||
} else {html!{}}}
|
||||
{":"}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<yew_form::CheckBox<T>
|
||||
form={&props.form}
|
||||
field_name={props.field_name.clone()}
|
||||
ontoggle={props.ontoggle.clone()} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
48
app/src/components/form/field.rs
Normal file
48
app/src/components/form/field.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use yew::{function_component, html, virtual_dom::AttrValue, Callback, InputEvent, Properties};
|
||||
use yew_form::{Form, Model};
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct Props<T: Model> {
|
||||
pub label: AttrValue,
|
||||
pub field_name: String,
|
||||
pub form: Form<T>,
|
||||
#[prop_or(false)]
|
||||
pub required: bool,
|
||||
#[prop_or(String::from("text"))]
|
||||
pub input_type: String,
|
||||
// If not present, will default to field_name
|
||||
#[prop_or(None)]
|
||||
pub autocomplete: Option<String>,
|
||||
#[prop_or_else(Callback::noop)]
|
||||
pub oninput: Callback<InputEvent>,
|
||||
}
|
||||
|
||||
#[function_component(Field)]
|
||||
pub fn field<T: Model>(props: &Props<T>) -> Html {
|
||||
html! {
|
||||
<div class="row mb-3">
|
||||
<label for={props.field_name.clone()}
|
||||
class="form-label col-4 col-form-label">
|
||||
{&props.label}
|
||||
{if props.required {
|
||||
html!{<span class="text-danger">{"*"}</span>}
|
||||
} else {html!{}}}
|
||||
{":"}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<yew_form::Field<T>
|
||||
form={&props.form}
|
||||
field_name={props.field_name.clone()}
|
||||
input_type={props.input_type.clone()}
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete={props.autocomplete.clone().unwrap_or(props.field_name.clone())}
|
||||
oninput={&props.oninput} />
|
||||
<div class="invalid-feedback">
|
||||
{&props.form.field_message(&props.field_name)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
6
app/src/components/form/mod.rs
Normal file
6
app/src/components/form/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod attribute_input;
|
||||
pub mod checkbox;
|
||||
pub mod field;
|
||||
pub mod select;
|
||||
pub mod static_value;
|
||||
pub mod submit;
|
||||
46
app/src/components/form/select.rs
Normal file
46
app/src/components/form/select.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
use yew::{
|
||||
function_component, html, virtual_dom::AttrValue, Callback, Children, InputEvent, Properties,
|
||||
};
|
||||
use yew_form::{Form, Model};
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct Props<T: Model> {
|
||||
pub label: AttrValue,
|
||||
pub field_name: String,
|
||||
pub form: Form<T>,
|
||||
#[prop_or(false)]
|
||||
pub required: bool,
|
||||
#[prop_or_else(Callback::noop)]
|
||||
pub oninput: Callback<InputEvent>,
|
||||
pub children: Children,
|
||||
}
|
||||
|
||||
#[function_component(Select)]
|
||||
pub fn select<T: Model>(props: &Props<T>) -> Html {
|
||||
html! {
|
||||
<div class="row mb-3">
|
||||
<label for={props.field_name.clone()}
|
||||
class="form-label col-4 col-form-label">
|
||||
{&props.label}
|
||||
{if props.required {
|
||||
html!{<span class="text-danger">{"*"}</span>}
|
||||
} else {html!{}}}
|
||||
{":"}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<yew_form::Select<T>
|
||||
form={&props.form}
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
field_name={props.field_name.clone()}
|
||||
oninput={&props.oninput} >
|
||||
{for props.children.iter()}
|
||||
</yew_form::Select<T>>
|
||||
<div class="invalid-feedback">
|
||||
{&props.form.field_message(&props.field_name)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
26
app/src/components/form/static_value.rs
Normal file
26
app/src/components/form/static_value.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
use yew::{function_component, html, virtual_dom::AttrValue, Children, Properties};
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct Props {
|
||||
pub label: AttrValue,
|
||||
pub id: AttrValue,
|
||||
pub children: Children,
|
||||
}
|
||||
|
||||
#[function_component(StaticValue)]
|
||||
pub fn static_value(props: &Props) -> Html {
|
||||
html! {
|
||||
<div class="row mb-3">
|
||||
<label for={props.id.clone()}
|
||||
class="form-label col-4 col-form-label">
|
||||
{&props.label}
|
||||
{":"}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<span id={props.id.clone()} class="form-control-static">
|
||||
{for props.children.iter()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
30
app/src/components/form/submit.rs
Normal file
30
app/src/components/form/submit.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use web_sys::MouseEvent;
|
||||
use yew::{function_component, html, virtual_dom::AttrValue, Callback, Children, Properties};
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct Props {
|
||||
pub disabled: bool,
|
||||
pub onclick: Callback<MouseEvent>,
|
||||
// Additional elements to insert after the button, in the same div
|
||||
#[prop_or_default]
|
||||
pub children: Children,
|
||||
#[prop_or(AttrValue::from("Submit"))]
|
||||
pub text: AttrValue,
|
||||
}
|
||||
|
||||
#[function_component(Submit)]
|
||||
pub fn submit(props: &Props) -> Html {
|
||||
html! {
|
||||
<div class="form-group row justify-content-center">
|
||||
<button
|
||||
class="btn btn-primary col-auto col-form-label"
|
||||
type="submit"
|
||||
disabled={props.disabled}
|
||||
onclick={&props.onclick}>
|
||||
<i class="bi-save me-2"></i>
|
||||
{props.text.clone()}
|
||||
</button>
|
||||
{for props.children.iter()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
198
app/src/components/group_schema_table.rs
Normal file
198
app/src/components/group_schema_table.rs
Normal file
@@ -0,0 +1,198 @@
|
||||
use crate::{
|
||||
components::{
|
||||
delete_group_attribute::DeleteGroupAttribute,
|
||||
router::{AppRoute, Link},
|
||||
},
|
||||
convert_attribute_type,
|
||||
infra::{
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
schema::AttributeType,
|
||||
},
|
||||
};
|
||||
use anyhow::{anyhow, Error, Result};
|
||||
use gloo_console::log;
|
||||
use graphql_client::GraphQLQuery;
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "../schema.graphql",
|
||||
query_path = "queries/get_group_attributes_schema.graphql",
|
||||
response_derives = "Debug,Clone,PartialEq,Eq",
|
||||
custom_scalars_module = "crate::infra::graphql"
|
||||
)]
|
||||
pub struct GetGroupAttributesSchema;
|
||||
|
||||
use get_group_attributes_schema::ResponseData;
|
||||
|
||||
pub type Attribute =
|
||||
get_group_attributes_schema::GetGroupAttributesSchemaSchemaGroupSchemaAttributes;
|
||||
|
||||
convert_attribute_type!(get_group_attributes_schema::AttributeType);
|
||||
|
||||
#[derive(yew::Properties, Clone, PartialEq, Eq)]
|
||||
pub struct Props {
|
||||
pub hardcoded: bool,
|
||||
}
|
||||
|
||||
pub struct GroupSchemaTable {
|
||||
common: CommonComponentParts<Self>,
|
||||
attributes: Option<Vec<Attribute>>,
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
ListAttributesResponse(Result<ResponseData>),
|
||||
OnAttributeDeleted(String),
|
||||
OnError(Error),
|
||||
}
|
||||
|
||||
impl CommonComponent<GroupSchemaTable> for GroupSchemaTable {
|
||||
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::ListAttributesResponse(schema) => {
|
||||
self.attributes =
|
||||
Some(schema?.schema.group_schema.attributes.into_iter().collect());
|
||||
Ok(true)
|
||||
}
|
||||
Msg::OnError(e) => Err(e),
|
||||
Msg::OnAttributeDeleted(attribute_name) => {
|
||||
match self.attributes {
|
||||
None => {
|
||||
log!(format!("Attribute {attribute_name} was deleted but component has no attributes"));
|
||||
Err(anyhow!("invalid state"))
|
||||
}
|
||||
Some(_) => {
|
||||
self.attributes
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.retain(|a| a.name != attribute_name);
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for GroupSchemaTable {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut table = GroupSchemaTable {
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
attributes: None,
|
||||
};
|
||||
table.common.call_graphql::<GetGroupAttributesSchema, _>(
|
||||
ctx,
|
||||
get_group_attributes_schema::Variables {},
|
||||
Msg::ListAttributesResponse,
|
||||
"Error trying to fetch group schema",
|
||||
);
|
||||
table
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
html! {
|
||||
<div>
|
||||
{self.view_attributes(ctx)}
|
||||
{self.view_errors()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GroupSchemaTable {
|
||||
fn view_attributes(&self, ctx: &Context<Self>) -> Html {
|
||||
let hardcoded = ctx.props().hardcoded;
|
||||
let make_table = |attributes: &Vec<Attribute>| {
|
||||
html! {
|
||||
<div class="table-responsive">
|
||||
<h3>{if hardcoded {"Hardcoded"} else {"User-defined"}}{" attributes"}</h3>
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{"Attribute name"}</th>
|
||||
<th>{"Type"}</th>
|
||||
<th>{"Visible"}</th>
|
||||
{if hardcoded {html!{}} else {html!{<th>{"Delete"}</th>}}}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{attributes.iter().map(|u| self.view_attribute(ctx, u)).collect::<Vec<_>>()}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
};
|
||||
match &self.attributes {
|
||||
None => html! {{"Loading..."}},
|
||||
Some(attributes) => {
|
||||
let mut attributes = attributes.clone();
|
||||
attributes.retain(|attribute| attribute.is_hardcoded == ctx.props().hardcoded);
|
||||
make_table(&attributes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn view_attribute(&self, ctx: &Context<Self>, attribute: &Attribute) -> Html {
|
||||
let link = ctx.link();
|
||||
let attribute_type = AttributeType::from(attribute.attribute_type.clone());
|
||||
let checkmark = html! {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check" viewBox="0 0 16 16">
|
||||
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425z"></path>
|
||||
</svg>
|
||||
};
|
||||
let hardcoded = ctx.props().hardcoded;
|
||||
html! {
|
||||
<tr key={attribute.name.clone()}>
|
||||
<td>{&attribute.name}</td>
|
||||
<td>{if attribute.is_list { format!("List<{attribute_type}>")} else {attribute_type.to_string()}}</td>
|
||||
<td>{if attribute.is_visible {checkmark.clone()} else {html!{}}}</td>
|
||||
{
|
||||
if hardcoded {
|
||||
html!{}
|
||||
} else {
|
||||
html!{
|
||||
<td>
|
||||
<DeleteGroupAttribute
|
||||
attribute_name={attribute.name.clone()}
|
||||
on_attribute_deleted={link.callback(Msg::OnAttributeDeleted)}
|
||||
on_error={link.callback(Msg::OnError)}/>
|
||||
</td>
|
||||
}
|
||||
}
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
|
||||
fn view_errors(&self) -> Html {
|
||||
match &self.common.error {
|
||||
None => html! {},
|
||||
Some(e) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[function_component(ListGroupSchema)]
|
||||
pub fn list_group_schema() -> Html {
|
||||
html! {
|
||||
<div>
|
||||
<GroupSchemaTable hardcoded={true} />
|
||||
<GroupSchemaTable hardcoded={false} />
|
||||
<Link classes="btn btn-primary" to={AppRoute::CreateGroupAttribute}>
|
||||
<i class="bi-plus-circle me-2"></i>
|
||||
{"Create an attribute"}
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
use crate::{
|
||||
components::router::{AppRoute, Link},
|
||||
components::{
|
||||
form::submit::Submit,
|
||||
router::{AppRoute, Link},
|
||||
},
|
||||
infra::{
|
||||
api::HostService,
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
@@ -66,7 +69,7 @@ impl CommonComponent<LoginForm> for LoginForm {
|
||||
opaque::client::login::start_login(&password, &mut rng)
|
||||
.context("Could not initialize login")?;
|
||||
let req = login::ClientLoginStartRequest {
|
||||
username,
|
||||
username: username.into(),
|
||||
login_start_request: message,
|
||||
};
|
||||
self.common
|
||||
@@ -155,68 +158,62 @@ impl Component for LoginForm {
|
||||
}
|
||||
} 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={link.callback(|_| Msg::Update)} />
|
||||
<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>
|
||||
<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>
|
||||
<div class="form-group mt-3">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}>
|
||||
<i class="bi-box-arrow-in-right me-2"/>
|
||||
{"Login"}
|
||||
</button>
|
||||
{ if password_reset_enabled {
|
||||
html! {
|
||||
<Link
|
||||
classes="btn-link btn"
|
||||
disabled={self.common.is_task_running()}
|
||||
to={AppRoute::StartResetPassword}>
|
||||
{"Forgot your password?"}
|
||||
</Link>
|
||||
}
|
||||
} else {
|
||||
html!{}
|
||||
}}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
{ if let Some(e) = &self.common.error {
|
||||
html! { e.to_string() }
|
||||
} else { html! {} }
|
||||
}
|
||||
<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={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>
|
||||
<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>
|
||||
<Submit
|
||||
text="Login"
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}>
|
||||
{ if password_reset_enabled {
|
||||
html! {
|
||||
<Link
|
||||
classes="btn-link btn"
|
||||
disabled={self.common.is_task_running()}
|
||||
to={AppRoute::StartResetPassword}>
|
||||
{"Forgot your password?"}
|
||||
</Link>
|
||||
}
|
||||
} else {
|
||||
html!{}
|
||||
}}
|
||||
</Submit>
|
||||
<div class="form-group">
|
||||
{ if let Some(e) = &self.common.error {
|
||||
html! { e.to_string() }
|
||||
} else { html! {} }
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
pub mod add_group_member;
|
||||
pub mod add_user_to_group;
|
||||
pub mod app;
|
||||
pub mod avatar;
|
||||
pub mod banner;
|
||||
pub mod change_password;
|
||||
pub mod create_group;
|
||||
pub mod create_group_attribute;
|
||||
pub mod create_user;
|
||||
pub mod create_user_attribute;
|
||||
pub mod delete_group;
|
||||
pub mod delete_group_attribute;
|
||||
pub mod delete_user;
|
||||
pub mod delete_user_attribute;
|
||||
pub mod form;
|
||||
pub mod group_details;
|
||||
pub mod group_schema_table;
|
||||
pub mod group_table;
|
||||
pub mod login;
|
||||
pub mod logout;
|
||||
@@ -17,4 +25,5 @@ pub mod router;
|
||||
pub mod select;
|
||||
pub mod user_details;
|
||||
pub mod user_details_form;
|
||||
pub mod user_schema_table;
|
||||
pub mod user_table;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use crate::{
|
||||
components::router::{AppRoute, Link},
|
||||
components::{
|
||||
form::{field::Field, submit::Submit},
|
||||
router::{AppRoute, Link},
|
||||
},
|
||||
infra::{
|
||||
api::HostService,
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
@@ -68,7 +71,7 @@ impl CommonComponent<ResetPasswordStep2Form> for ResetPasswordStep2Form {
|
||||
opaque_registration::start_registration(new_password.as_bytes(), &mut rng)
|
||||
.context("Could not initiate password change")?;
|
||||
let req = registration::ClientRegistrationStartRequest {
|
||||
username: self.username.clone().unwrap(),
|
||||
username: self.username.as_ref().unwrap().into(),
|
||||
registration_start_request: registration_start_request.message,
|
||||
};
|
||||
self.opaque_data = Some(registration_start_request.state);
|
||||
@@ -164,61 +167,29 @@ impl Component for ResetPasswordStep2Form {
|
||||
}
|
||||
_ => (),
|
||||
};
|
||||
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={link.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={link.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={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}>
|
||||
{"Submit"}
|
||||
</button>
|
||||
</div>
|
||||
<form class="form">
|
||||
<Field<FormModel>
|
||||
label="New password"
|
||||
required=true
|
||||
form={&self.form}
|
||||
field_name="password"
|
||||
autocomplete="new-password"
|
||||
input_type="password"
|
||||
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||
<Field<FormModel>
|
||||
label="Confirm password"
|
||||
required=true
|
||||
form={&self.form}
|
||||
field_name="confirm_password"
|
||||
autocomplete="new-password"
|
||||
input_type="password"
|
||||
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||
<Submit
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})} />
|
||||
</form>
|
||||
{ if let Some(e) = &self.common.error {
|
||||
html! {
|
||||
|
||||
@@ -22,6 +22,14 @@ pub enum AppRoute {
|
||||
ListGroups,
|
||||
#[at("/group/:group_id")]
|
||||
GroupDetails { group_id: i64 },
|
||||
#[at("/user-attributes")]
|
||||
ListUserSchema,
|
||||
#[at("/user-attributes/create")]
|
||||
CreateUserAttribute,
|
||||
#[at("/group-attributes")]
|
||||
ListGroupSchema,
|
||||
#[at("/group-attributes/create")]
|
||||
CreateGroupAttribute,
|
||||
#[at("/")]
|
||||
Index,
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use crate::{
|
||||
components::user_details::User,
|
||||
components::{
|
||||
form::{field::Field, static_value::StaticValue, submit::Submit},
|
||||
user_details::User,
|
||||
},
|
||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||
};
|
||||
use anyhow::{bail, Error, Result};
|
||||
@@ -11,9 +14,10 @@ use gloo_file::{
|
||||
};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use validator_derive::Validate;
|
||||
use web_sys::{FileList, HtmlInputElement, InputEvent};
|
||||
use web_sys::{FileList, HtmlInputElement, InputEvent, SubmitEvent};
|
||||
use yew::prelude::*;
|
||||
use yew_form_derive::Model;
|
||||
use gloo_console::log;
|
||||
|
||||
#[derive(Default)]
|
||||
struct JsFile {
|
||||
@@ -23,10 +27,7 @@ struct JsFile {
|
||||
|
||||
impl ToString for JsFile {
|
||||
fn to_string(&self) -> String {
|
||||
self.file
|
||||
.as_ref()
|
||||
.map(File::name)
|
||||
.unwrap_or_else(String::new)
|
||||
self.file.as_ref().map(File::name).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +89,8 @@ pub enum Msg {
|
||||
FileLoaded(String, Result<Vec<u8>>),
|
||||
/// We got the response from the server about our update message.
|
||||
UserUpdated(Result<update_user::ResponseData>),
|
||||
/// The "Submit" button was clicked.
|
||||
OnSubmit(SubmitEvent),
|
||||
}
|
||||
|
||||
#[derive(yew::Properties, Clone, PartialEq, Eq)]
|
||||
@@ -151,6 +154,10 @@ impl CommonComponent<UserDetailsForm> for UserDetailsForm {
|
||||
self.reader = None;
|
||||
Ok(false)
|
||||
}
|
||||
Msg::OnSubmit(e) => {
|
||||
log!(format!("{:#?}", e));
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,7 +193,6 @@ impl Component for UserDetailsForm {
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
type Field = yew_form::Field<UserModel>;
|
||||
let link = &ctx.link();
|
||||
|
||||
let avatar_string = match &self.avatar {
|
||||
@@ -198,108 +204,41 @@ impl Component for UserDetailsForm {
|
||||
};
|
||||
html! {
|
||||
<div class="py-3">
|
||||
<form class="form">
|
||||
<div class="form-group row mb-3">
|
||||
<label for="userId"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"User ID: "}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<span id="userId" class="form-control-static"><i>{&self.user.id}</i></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="creationDate"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Creation date: "}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<span id="creationDate" class="form-control-static">{&self.user.creation_date.naive_local().date()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="uuid"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"UUID: "}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<span id="creationDate" class="form-control-static">{&self.user.uuid}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="email"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Email"}
|
||||
<span class="text-danger">{"*"}</span>
|
||||
{":"}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
form={&self.form}
|
||||
field_name="email"
|
||||
autocomplete="email"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("email")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="display_name"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Display Name: "}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
form={&self.form}
|
||||
field_name="display_name"
|
||||
autocomplete="name"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("display_name")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="first_name"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"First Name: "}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
class="form-control"
|
||||
form={&self.form}
|
||||
field_name="first_name"
|
||||
autocomplete="given-name"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("first_name")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="last_name"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Last Name: "}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
class="form-control"
|
||||
form={&self.form}
|
||||
field_name="last_name"
|
||||
autocomplete="family-name"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("last_name")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form class="form" onsubmit={link.callback(|e: SubmitEvent| {e.prevent_default(); Msg::OnSubmit(e)})}>
|
||||
<StaticValue label="User ID" id="userId">
|
||||
<i>{&self.user.id}</i>
|
||||
</StaticValue>
|
||||
<StaticValue label="Creation date" id="creationDate">
|
||||
{&self.user.creation_date.naive_local().date()}
|
||||
</StaticValue>
|
||||
<StaticValue label="UUID" id="uuid">
|
||||
{&self.user.uuid}
|
||||
</StaticValue>
|
||||
<Field<UserModel>
|
||||
form={&self.form}
|
||||
required=true
|
||||
label="Email"
|
||||
field_name="email"
|
||||
input_type="email"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<Field<UserModel>
|
||||
form={&self.form}
|
||||
label="Display name"
|
||||
field_name="display_name"
|
||||
autocomplete="name"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<Field<UserModel>
|
||||
form={&self.form}
|
||||
label="First name"
|
||||
field_name="first_name"
|
||||
autocomplete="given-name"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<Field<UserModel>
|
||||
form={&self.form}
|
||||
label="Last name"
|
||||
field_name="last_name"
|
||||
autocomplete="family-name"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<div class="form-group row align-items-center mb-3">
|
||||
<label for="avatar"
|
||||
class="form-label col-4 col-form-label">
|
||||
@@ -343,16 +282,10 @@ impl Component for UserDetailsForm {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row justify-content-center mt-3">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary col-auto col-form-label"
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitClicked})}>
|
||||
<i class="bi-save me-2"></i>
|
||||
{"Save changes"}
|
||||
</button>
|
||||
</div>
|
||||
<Submit
|
||||
text="Save changes"
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {Msg::SubmitClicked})} />
|
||||
</form>
|
||||
{
|
||||
if let Some(e) = &self.common.error {
|
||||
@@ -391,6 +324,8 @@ impl UserDetailsForm {
|
||||
firstName: None,
|
||||
lastName: None,
|
||||
avatar: None,
|
||||
removeAttributes: None,
|
||||
insertAttributes: None,
|
||||
};
|
||||
let default_user_input = user_input.clone();
|
||||
let model = self.form.model();
|
||||
|
||||
198
app/src/components/user_schema_table.rs
Normal file
198
app/src/components/user_schema_table.rs
Normal file
@@ -0,0 +1,198 @@
|
||||
use crate::{
|
||||
components::{
|
||||
delete_user_attribute::DeleteUserAttribute,
|
||||
router::{AppRoute, Link},
|
||||
},
|
||||
convert_attribute_type,
|
||||
infra::{
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
schema::AttributeType,
|
||||
},
|
||||
};
|
||||
use anyhow::{anyhow, Error, Result};
|
||||
use gloo_console::log;
|
||||
use graphql_client::GraphQLQuery;
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "../schema.graphql",
|
||||
query_path = "queries/get_user_attributes_schema.graphql",
|
||||
response_derives = "Debug,Clone,PartialEq,Eq",
|
||||
custom_scalars_module = "crate::infra::graphql"
|
||||
)]
|
||||
pub struct GetUserAttributesSchema;
|
||||
|
||||
use get_user_attributes_schema::ResponseData;
|
||||
|
||||
pub type Attribute = get_user_attributes_schema::GetUserAttributesSchemaSchemaUserSchemaAttributes;
|
||||
|
||||
convert_attribute_type!(get_user_attributes_schema::AttributeType);
|
||||
|
||||
#[derive(yew::Properties, Clone, PartialEq, Eq)]
|
||||
pub struct Props {
|
||||
pub hardcoded: bool,
|
||||
}
|
||||
|
||||
pub struct UserSchemaTable {
|
||||
common: CommonComponentParts<Self>,
|
||||
attributes: Option<Vec<Attribute>>,
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
ListAttributesResponse(Result<ResponseData>),
|
||||
OnAttributeDeleted(String),
|
||||
OnError(Error),
|
||||
}
|
||||
|
||||
impl CommonComponent<UserSchemaTable> for UserSchemaTable {
|
||||
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::ListAttributesResponse(schema) => {
|
||||
self.attributes = Some(schema?.schema.user_schema.attributes.into_iter().collect());
|
||||
Ok(true)
|
||||
}
|
||||
Msg::OnError(e) => Err(e),
|
||||
Msg::OnAttributeDeleted(attribute_name) => {
|
||||
match self.attributes {
|
||||
None => {
|
||||
log!(format!("Attribute {attribute_name} was deleted but component has no attributes"));
|
||||
Err(anyhow!("invalid state"))
|
||||
}
|
||||
Some(_) => {
|
||||
self.attributes
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.retain(|a| a.name != attribute_name);
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for UserSchemaTable {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut table = UserSchemaTable {
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
attributes: None,
|
||||
};
|
||||
table.common.call_graphql::<GetUserAttributesSchema, _>(
|
||||
ctx,
|
||||
get_user_attributes_schema::Variables {},
|
||||
Msg::ListAttributesResponse,
|
||||
"Error trying to fetch user schema",
|
||||
);
|
||||
table
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
html! {
|
||||
<div>
|
||||
{self.view_attributes(ctx)}
|
||||
{self.view_errors()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UserSchemaTable {
|
||||
fn view_attributes(&self, ctx: &Context<Self>) -> Html {
|
||||
let hardcoded = ctx.props().hardcoded;
|
||||
let make_table = |attributes: &Vec<Attribute>| {
|
||||
html! {
|
||||
<div class="table-responsive">
|
||||
<h3>{if hardcoded {"Hardcoded"} else {"User-defined"}}{" attributes"}</h3>
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{"Attribute name"}</th>
|
||||
<th>{"Type"}</th>
|
||||
<th>{"Editable"}</th>
|
||||
<th>{"Visible"}</th>
|
||||
{if hardcoded {html!{}} else {html!{<th>{"Delete"}</th>}}}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{attributes.iter().map(|u| self.view_attribute(ctx, u)).collect::<Vec<_>>()}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
};
|
||||
match &self.attributes {
|
||||
None => html! {{"Loading..."}},
|
||||
Some(attributes) => {
|
||||
let mut attributes = attributes.clone();
|
||||
attributes.retain(|attribute| attribute.is_hardcoded == ctx.props().hardcoded);
|
||||
make_table(&attributes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn view_attribute(&self, ctx: &Context<Self>, attribute: &Attribute) -> Html {
|
||||
let link = ctx.link();
|
||||
let attribute_type = AttributeType::from(attribute.attribute_type.clone());
|
||||
let checkmark = html! {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check" viewBox="0 0 16 16">
|
||||
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425z"></path>
|
||||
</svg>
|
||||
};
|
||||
let hardcoded = ctx.props().hardcoded;
|
||||
html! {
|
||||
<tr key={attribute.name.clone()}>
|
||||
<td>{&attribute.name}</td>
|
||||
<td>{if attribute.is_list { format!("List<{attribute_type}>")} else {attribute_type.to_string()}}</td>
|
||||
<td>{if attribute.is_editable {checkmark.clone()} else {html!{}}}</td>
|
||||
<td>{if attribute.is_visible {checkmark.clone()} else {html!{}}}</td>
|
||||
{
|
||||
if hardcoded {
|
||||
html!{}
|
||||
} else {
|
||||
html!{
|
||||
<td>
|
||||
<DeleteUserAttribute
|
||||
attribute_name={attribute.name.clone()}
|
||||
on_attribute_deleted={link.callback(Msg::OnAttributeDeleted)}
|
||||
on_error={link.callback(Msg::OnError)}/>
|
||||
</td>
|
||||
}
|
||||
}
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
|
||||
fn view_errors(&self) -> Html {
|
||||
match &self.common.error {
|
||||
None => html! {},
|
||||
Some(e) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[function_component(ListUserSchema)]
|
||||
pub fn list_user_schema() -> Html {
|
||||
html! {
|
||||
<div>
|
||||
<UserSchemaTable hardcoded={true} />
|
||||
<UserSchemaTable hardcoded={false} />
|
||||
<Link classes="btn btn-primary" to={AppRoute::CreateUserAttribute}>
|
||||
<i class="bi-plus-circle me-2"></i>
|
||||
{"Create an attribute"}
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,10 @@ fn get_claims_from_jwt(jwt: &str) -> Result<JWTClaims> {
|
||||
|
||||
const NO_BODY: Option<()> = None;
|
||||
|
||||
fn base_url() -> String {
|
||||
yew_router::utils::base_url().unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn call_server(
|
||||
url: &str,
|
||||
body: Option<impl Serialize>,
|
||||
@@ -97,7 +101,7 @@ impl HostService {
|
||||
};
|
||||
let request_body = QueryType::build_query(variables);
|
||||
call_server_json_with_error_message::<graphql_client::Response<_>, _>(
|
||||
"/api/graphql",
|
||||
&(base_url() + "/api/graphql"),
|
||||
Some(request_body),
|
||||
error_message,
|
||||
)
|
||||
@@ -109,7 +113,7 @@ impl HostService {
|
||||
request: login::ClientLoginStartRequest,
|
||||
) -> Result<Box<login::ServerLoginStartResponse>> {
|
||||
call_server_json_with_error_message(
|
||||
"/auth/opaque/login/start",
|
||||
&(base_url() + "/auth/opaque/login/start"),
|
||||
Some(request),
|
||||
"Could not start authentication: ",
|
||||
)
|
||||
@@ -118,7 +122,7 @@ impl HostService {
|
||||
|
||||
pub async fn login_finish(request: login::ClientLoginFinishRequest) -> Result<(String, bool)> {
|
||||
call_server_json_with_error_message::<login::ServerLoginResponse, _>(
|
||||
"/auth/opaque/login/finish",
|
||||
&(base_url() + "/auth/opaque/login/finish"),
|
||||
Some(request),
|
||||
"Could not finish authentication",
|
||||
)
|
||||
@@ -130,7 +134,7 @@ impl HostService {
|
||||
request: registration::ClientRegistrationStartRequest,
|
||||
) -> Result<Box<registration::ServerRegistrationStartResponse>> {
|
||||
call_server_json_with_error_message(
|
||||
"/auth/opaque/register/start",
|
||||
&(base_url() + "/auth/opaque/register/start"),
|
||||
Some(request),
|
||||
"Could not start registration: ",
|
||||
)
|
||||
@@ -141,7 +145,7 @@ impl HostService {
|
||||
request: registration::ClientRegistrationFinishRequest,
|
||||
) -> Result<()> {
|
||||
call_server_empty_response_with_error_message(
|
||||
"/auth/opaque/register/finish",
|
||||
&(base_url() + "/auth/opaque/register/finish"),
|
||||
Some(request),
|
||||
"Could not finish registration",
|
||||
)
|
||||
@@ -150,7 +154,7 @@ impl HostService {
|
||||
|
||||
pub async fn refresh() -> Result<(String, bool)> {
|
||||
call_server_json_with_error_message::<login::ServerLoginResponse, _>(
|
||||
"/auth/refresh",
|
||||
&(base_url() + "/auth/refresh"),
|
||||
NO_BODY,
|
||||
"Could not start authentication: ",
|
||||
)
|
||||
@@ -160,13 +164,21 @@ impl HostService {
|
||||
|
||||
// The `_request` parameter is to make it the same shape as the other functions.
|
||||
pub async fn logout() -> Result<()> {
|
||||
call_server_empty_response_with_error_message("/auth/logout", NO_BODY, "Could not logout")
|
||||
.await
|
||||
call_server_empty_response_with_error_message(
|
||||
&(base_url() + "/auth/logout"),
|
||||
NO_BODY,
|
||||
"Could not logout",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn reset_password_step1(username: String) -> Result<()> {
|
||||
call_server_empty_response_with_error_message(
|
||||
&format!("/auth/reset/step1/{}", url_escape::encode_query(&username)),
|
||||
&format!(
|
||||
"{}/auth/reset/step1/{}",
|
||||
base_url(),
|
||||
url_escape::encode_query(&username)
|
||||
),
|
||||
NO_BODY,
|
||||
"Could not initiate password reset",
|
||||
)
|
||||
@@ -177,7 +189,7 @@ impl HostService {
|
||||
token: String,
|
||||
) -> Result<lldap_auth::password_reset::ServerPasswordResetResponse> {
|
||||
call_server_json_with_error_message(
|
||||
&format!("/auth/reset/step2/{}", token),
|
||||
&format!("{}/auth/reset/step2/{}", base_url(), token),
|
||||
NO_BODY,
|
||||
"Could not validate token",
|
||||
)
|
||||
@@ -185,13 +197,13 @@ impl HostService {
|
||||
}
|
||||
|
||||
pub async fn probe_password_reset() -> Result<bool> {
|
||||
Ok(
|
||||
gloo_net::http::Request::get("/auth/reset/step1/lldap_unlikely_very_long_user_name")
|
||||
.header("Content-Type", "application/json")
|
||||
.send()
|
||||
.await?
|
||||
.status()
|
||||
!= http::StatusCode::NOT_FOUND,
|
||||
Ok(gloo_net::http::Request::get(
|
||||
&(base_url() + "/auth/reset/step1/lldap_unlikely_very_long_user_name"),
|
||||
)
|
||||
.header("Content-Type", "application/json")
|
||||
.send()
|
||||
.await?
|
||||
.status()
|
||||
!= http::StatusCode::NOT_FOUND)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,10 +22,11 @@ pub fn set_cookie(cookie_name: &str, value: &str, expiration: &DateTime<Utc>) ->
|
||||
.map_err(|_| anyhow!("Document is not an HTMLDocument"))
|
||||
})?;
|
||||
let cookie_string = format!(
|
||||
"{}={}; expires={}; sameSite=Strict; path=/",
|
||||
"{}={}; expires={}; sameSite=Strict; path={}/",
|
||||
cookie_name,
|
||||
value,
|
||||
expiration.to_rfc2822()
|
||||
expiration.to_rfc2822(),
|
||||
yew_router::utils::base_url().unwrap_or_default()
|
||||
);
|
||||
doc.set_cookie(&cookie_string)
|
||||
.map_err(|_| anyhow!("Could not set cookie"))
|
||||
|
||||
38
app/src/infra/functional.rs
Normal file
38
app/src/infra/functional.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use crate::infra::api::HostService;
|
||||
use anyhow::Result;
|
||||
use graphql_client::GraphQLQuery;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use yew::{use_effect, use_state, UseStateHandle};
|
||||
|
||||
// Enum to represent a result that is fetched asynchronously.
|
||||
#[derive(Debug)]
|
||||
pub enum LoadableResult<T> {
|
||||
// The result is still being fetched
|
||||
Loading,
|
||||
// The async call is completed
|
||||
Loaded(Result<T>),
|
||||
}
|
||||
|
||||
pub fn use_graphql_call<QueryType>(
|
||||
variables: QueryType::Variables,
|
||||
) -> UseStateHandle<LoadableResult<QueryType::ResponseData>>
|
||||
where
|
||||
QueryType: GraphQLQuery + 'static,
|
||||
{
|
||||
let loadable_result: UseStateHandle<LoadableResult<QueryType::ResponseData>> =
|
||||
use_state(|| LoadableResult::Loading);
|
||||
{
|
||||
let loadable_result = loadable_result.clone();
|
||||
use_effect(move || {
|
||||
let task = HostService::graphql_query::<QueryType>(variables, "Failed graphql query");
|
||||
|
||||
spawn_local(async move {
|
||||
let response = task.await;
|
||||
loadable_result.set(LoadableResult::Loaded(response));
|
||||
});
|
||||
|
||||
|| ()
|
||||
})
|
||||
}
|
||||
loadable_result.clone()
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
pub mod api;
|
||||
pub mod common_component;
|
||||
pub mod cookies;
|
||||
pub mod functional;
|
||||
pub mod graphql;
|
||||
pub mod modal;
|
||||
pub mod schema;
|
||||
|
||||
96
app/src/infra/schema.rs
Normal file
96
app/src/infra/schema.rs
Normal file
@@ -0,0 +1,96 @@
|
||||
use anyhow::Result;
|
||||
use std::{fmt::Display, str::FromStr};
|
||||
use validator::ValidationError;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AttributeType {
|
||||
String,
|
||||
Integer,
|
||||
DateTime,
|
||||
Jpeg,
|
||||
}
|
||||
|
||||
impl Display for AttributeType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{:?}", self)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for AttributeType {
|
||||
type Err = ();
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
match value {
|
||||
"String" => Ok(AttributeType::String),
|
||||
"Integer" => Ok(AttributeType::Integer),
|
||||
"DateTime" => Ok(AttributeType::DateTime),
|
||||
"Jpeg" => Ok(AttributeType::Jpeg),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Macro to generate traits for converting between AttributeType and the
|
||||
// graphql generated equivalents.
|
||||
#[macro_export]
|
||||
macro_rules! convert_attribute_type {
|
||||
($source_type:ty) => {
|
||||
impl From<$source_type> for AttributeType {
|
||||
fn from(value: $source_type) -> Self {
|
||||
match value {
|
||||
<$source_type>::STRING => AttributeType::String,
|
||||
<$source_type>::INTEGER => AttributeType::Integer,
|
||||
<$source_type>::DATE_TIME => AttributeType::DateTime,
|
||||
<$source_type>::JPEG_PHOTO => AttributeType::Jpeg,
|
||||
_ => panic!("Unknown attribute type"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AttributeType> for $source_type {
|
||||
fn from(value: AttributeType) -> Self {
|
||||
match value {
|
||||
AttributeType::String => <$source_type>::STRING,
|
||||
AttributeType::Integer => <$source_type>::INTEGER,
|
||||
AttributeType::DateTime => <$source_type>::DATE_TIME,
|
||||
AttributeType::Jpeg => <$source_type>::JPEG_PHOTO,
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct Attribute {
|
||||
pub name: String,
|
||||
pub value: Vec<String>,
|
||||
pub attribute_type: AttributeType,
|
||||
pub is_list: bool,
|
||||
pub is_editable: bool,
|
||||
pub is_hardcoded: bool,
|
||||
}
|
||||
|
||||
// Macro to generate traits for converting between AttributeType and the
|
||||
// graphql generated equivalents.
|
||||
#[macro_export]
|
||||
macro_rules! combine_schema_and_values {
|
||||
($schema_list:ident, $value_list:ident, $output_list:ident) => {
|
||||
let set_attributes = value_list.clone();
|
||||
let mut attribute_schema = schema_list.clone();
|
||||
attribute_schema.retain(|schema| !schema.is_hardcoded);
|
||||
let $output_list = attribute_schema.into_iter().map(|schema| {
|
||||
Attribute {
|
||||
name: schema.name.clone(),
|
||||
value: set_attributes.iter().find(|attribute_value| attribute_value.name == schema.name).unwrap().value.clone(),
|
||||
attribute_type: AttributeType::from(schema.attribute_type),
|
||||
is_list: schema.is_list,
|
||||
}
|
||||
}).collect();
|
||||
};
|
||||
=======
|
||||
pub fn validate_attribute_type(attribute_type: &str) -> Result<(), ValidationError> {
|
||||
AttributeType::from_str(attribute_type)
|
||||
.map_err(|_| ValidationError::new("Invalid attribute type"))?;
|
||||
Ok(())
|
||||
>>>>>>> 8f2391a (app: create group attribute schema page (#825))
|
||||
}
|
||||
@@ -13,12 +13,13 @@ default = ["opaque_server", "opaque_client"]
|
||||
opaque_server = []
|
||||
opaque_client = []
|
||||
js = []
|
||||
sea_orm = ["dep:sea-orm"]
|
||||
|
||||
[dependencies]
|
||||
rust-argon2 = "0.8"
|
||||
curve25519-dalek = "3"
|
||||
digest = "0.9"
|
||||
generic-array = "*"
|
||||
generic-array = "0.14"
|
||||
rand = "0.8"
|
||||
serde = "*"
|
||||
sha2 = "0.9"
|
||||
@@ -31,6 +32,12 @@ version = "0.6"
|
||||
version = "*"
|
||||
features = [ "serde" ]
|
||||
|
||||
[dependencies.sea-orm]
|
||||
version= "0.12"
|
||||
default-features = false
|
||||
features = ["macros"]
|
||||
optional = true
|
||||
|
||||
# For WASM targets, use the JS getrandom.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies.getrandom]
|
||||
version = "0.2"
|
||||
|
||||
110
auth/src/lib.rs
110
auth/src/lib.rs
@@ -9,17 +9,17 @@ pub mod opaque;
|
||||
|
||||
/// The messages for the 3-step OPAQUE and simple login process.
|
||||
pub mod login {
|
||||
use super::*;
|
||||
use super::{types::UserId, *};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct ServerData {
|
||||
pub username: String,
|
||||
pub username: UserId,
|
||||
pub server_login: opaque::server::login::ServerLogin,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct ClientLoginStartRequest {
|
||||
pub username: String,
|
||||
pub username: UserId,
|
||||
pub login_start_request: opaque::server::login::CredentialRequest,
|
||||
}
|
||||
|
||||
@@ -39,14 +39,14 @@ pub mod login {
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct ClientSimpleLoginRequest {
|
||||
pub username: String,
|
||||
pub username: UserId,
|
||||
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("username", &self.username.as_str())
|
||||
.field("password", &"***********")
|
||||
.finish()
|
||||
}
|
||||
@@ -63,16 +63,16 @@ pub mod login {
|
||||
/// The messages for the 3-step OPAQUE registration process.
|
||||
/// It is used to reset a user's password.
|
||||
pub mod registration {
|
||||
use super::*;
|
||||
use super::{types::UserId, *};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct ServerData {
|
||||
pub username: String,
|
||||
pub username: UserId,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct ClientRegistrationStartRequest {
|
||||
pub username: String,
|
||||
pub username: UserId,
|
||||
pub registration_start_request: opaque::server::registration::RegistrationRequest,
|
||||
}
|
||||
|
||||
@@ -104,6 +104,100 @@ pub mod password_reset {
|
||||
}
|
||||
}
|
||||
|
||||
pub mod types {
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[cfg(feature = "sea_orm")]
|
||||
use sea_orm::{DbErr, DeriveValueType, QueryResult, TryFromU64, Value};
|
||||
|
||||
#[derive(
|
||||
PartialEq, Eq, PartialOrd, Ord, Clone, Debug, Default, Hash, Serialize, Deserialize,
|
||||
)]
|
||||
#[cfg_attr(feature = "sea_orm", derive(DeriveValueType))]
|
||||
#[serde(from = "String")]
|
||||
pub struct CaseInsensitiveString(String);
|
||||
|
||||
impl CaseInsensitiveString {
|
||||
pub fn new(s: &str) -> Self {
|
||||
Self(s.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
self.0.as_str()
|
||||
}
|
||||
|
||||
pub fn into_string(self) -> String {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for CaseInsensitiveString {
|
||||
fn from(mut s: String) -> Self {
|
||||
s.make_ascii_lowercase();
|
||||
Self(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&String> for CaseInsensitiveString {
|
||||
fn from(s: &String) -> Self {
|
||||
Self::new(s.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for CaseInsensitiveString {
|
||||
fn from(s: &str) -> Self {
|
||||
Self::new(s)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(
|
||||
PartialEq, Eq, PartialOrd, Ord, Clone, Debug, Default, Hash, Serialize, Deserialize,
|
||||
)]
|
||||
#[cfg_attr(feature = "sea_orm", derive(DeriveValueType))]
|
||||
#[serde(from = "CaseInsensitiveString")]
|
||||
pub struct UserId(CaseInsensitiveString);
|
||||
|
||||
impl UserId {
|
||||
pub fn new(s: &str) -> Self {
|
||||
s.into()
|
||||
}
|
||||
pub fn as_str(&self) -> &str {
|
||||
self.0.as_str()
|
||||
}
|
||||
pub fn into_string(self) -> String {
|
||||
self.0.into_string()
|
||||
}
|
||||
}
|
||||
impl<T> From<T> for UserId
|
||||
where
|
||||
T: Into<CaseInsensitiveString>,
|
||||
{
|
||||
fn from(s: T) -> Self {
|
||||
Self(s.into())
|
||||
}
|
||||
}
|
||||
impl std::fmt::Display for UserId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "sea_orm")]
|
||||
impl From<&UserId> for Value {
|
||||
fn from(user_id: &UserId) -> Self {
|
||||
user_id.as_str().into()
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "sea_orm")]
|
||||
impl TryFromU64 for UserId {
|
||||
fn try_from_u64(_n: u64) -> Result<Self, DbErr> {
|
||||
Err(DbErr::ConvertFromU64(
|
||||
"UserId cannot be constructed from u64",
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct JWTClaims {
|
||||
pub exp: DateTime<Utc>,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::types::UserId;
|
||||
use opaque_ke::ciphersuite::CipherSuite;
|
||||
use rand::{CryptoRng, RngCore};
|
||||
|
||||
@@ -145,12 +146,12 @@ pub mod server {
|
||||
pub fn start_registration(
|
||||
server_setup: &ServerSetup,
|
||||
registration_request: RegistrationRequest,
|
||||
username: &str,
|
||||
username: &UserId,
|
||||
) -> AuthenticationResult<ServerRegistrationStartResult> {
|
||||
Ok(ServerRegistration::start(
|
||||
server_setup,
|
||||
registration_request,
|
||||
username.as_bytes(),
|
||||
username.as_str().as_bytes(),
|
||||
)?)
|
||||
}
|
||||
|
||||
@@ -178,14 +179,14 @@ pub mod server {
|
||||
server_setup: &ServerSetup,
|
||||
password_file: Option<ServerRegistration>,
|
||||
credential_request: CredentialRequest,
|
||||
username: &str,
|
||||
username: &UserId,
|
||||
) -> AuthenticationResult<ServerLoginStartResult> {
|
||||
Ok(ServerLogin::start(
|
||||
rng,
|
||||
server_setup,
|
||||
password_file,
|
||||
credential_request,
|
||||
username.as_bytes(),
|
||||
username.as_str().as_bytes(),
|
||||
ServerLoginStartParameters::default(),
|
||||
)?)
|
||||
}
|
||||
|
||||
20
docker-entrypoint-rootless.sh
Executable file
20
docker-entrypoint-rootless.sh
Executable file
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
CONFIG_FILE=/data/lldap_config.toml
|
||||
|
||||
if [ ! -f "$CONFIG_FILE" ]; then
|
||||
echo "[entrypoint] Copying the default config to $CONFIG_FILE"
|
||||
echo "[entrypoint] Edit this $CONFIG_FILE to configure LLDAP."
|
||||
if cp /app/lldap_config.docker_template.toml $CONFIG_FILE; then
|
||||
echo "Configuration copied successfully."
|
||||
else
|
||||
echo "Fail to copy configuration, check permission on /data or manually create one by copying from LLDAP repository"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "> Starting lldap.."
|
||||
echo ""
|
||||
exec /app/lldap "$@"
|
||||
exec "$@"
|
||||
@@ -51,8 +51,9 @@ format to PostgreSQL format, and wrap it all in a transaction:
|
||||
|
||||
```sh
|
||||
sed -i -r -e "s/X'([[:xdigit:]]+'[^'])/'\\\x\\1/g" \
|
||||
-e ":a; s/(INSERT INTO user_attribute_schema\(.*\) VALUES\(.*),1([^']*\);)$/\1,true\2/; s/(INSERT INTO user_attribute_schema\(.*\) VALUES\(.*),0([^']*\);)$/\1,false\2/; ta" \
|
||||
-e ":a; s/(INSERT INTO (user_attribute_schema|jwt_storage)\(.*\) VALUES\(.*),1([^']*\);)$/\1,true\3/; s/(INSERT INTO (user_attribute_schema|jwt_storage)\(.*\) VALUES\(.*),0([^']*\);)$/\1,false\3/; ta" \
|
||||
-e '1s/^/BEGIN;\n/' \
|
||||
-e '$aSELECT setval(pg_get_serial_sequence('\''groups'\'', '\''group_id'\''), COALESCE((SELECT MAX(group_id) FROM groups), 1));' \
|
||||
-e '$aCOMMIT;' /path/to/dump.sql
|
||||
```
|
||||
|
||||
@@ -107,4 +108,4 @@ Modify your `database_url` in `lldap_config.toml` (or `LLDAP_DATABASE_URL` in th
|
||||
to point to your new database (the same value used when generating schema). Restart
|
||||
LLDAP and check the logs to ensure there were no errors.
|
||||
|
||||
#### More details/examples can be seen in the CI process [here](https://raw.githubusercontent.com/nitnelave/lldap/main/.github/workflows/docker-build-static.yml), look for the job `lldap-database-migration-test`
|
||||
#### More details/examples can be seen in the CI process [here](https://raw.githubusercontent.com/lldap/lldap/main/.github/workflows/docker-build-static.yml), look for the job `lldap-database-migration-test`
|
||||
|
||||
@@ -18,6 +18,15 @@ still supports basic RootDSE queries.
|
||||
|
||||
Anonymous bind is not supported.
|
||||
|
||||
## `lldap-cli`
|
||||
|
||||
There is a community-built CLI frontend,
|
||||
[Zepmann/lldap-cli](https://github.com/Zepmann/lldap-cli), that supports all
|
||||
(as of this writing) the operations possible. Getting information from the
|
||||
server, creating users, adding them to groups, creating new custom attributes
|
||||
and populating them, all of that is supported. It is currently the easiest way
|
||||
to script the interaction with LLDAP.
|
||||
|
||||
## GraphQL
|
||||
|
||||
The best way to interact with LLDAP programmatically is via the GraphQL
|
||||
|
||||
18
example_configs/apereo_cas_server.md
Normal file
18
example_configs/apereo_cas_server.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# Configuration for Apereo CAS Server
|
||||
|
||||
Replace `dc=example,dc=com` with your LLDAP configured domain, and hostname for your LLDAP server.
|
||||
|
||||
The `search-filter` provided here requires users to be members of the `cas_auth` group in LLDAP.
|
||||
|
||||
Configuration to use LDAP in e.g. `/etc/cas/config/standalone.yml`
|
||||
```
|
||||
cas:
|
||||
authn:
|
||||
ldap:
|
||||
- base-dn: dc=example,dc=com
|
||||
bind-credential: password
|
||||
bind-dn: uid=admin,ou=people,dc=example,dc=com
|
||||
ldap-url: ldap://ldap.example.com:3890
|
||||
search-filter: (&(objectClass=person)(memberOf=uid=cas_auth,ou=groups,dc=example,dc=com))
|
||||
```
|
||||
|
||||
@@ -33,7 +33,7 @@ authentication_backend:
|
||||
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
|
||||
# The groups are not displayed in the UI, but this filter works.
|
||||
groups_filter: "(member={dn})"
|
||||
# The attribute holding the name of the group.
|
||||
group_name_attribute: cn
|
||||
|
||||
BIN
example_configs/bootstrap/bootstrap-example-log-1.jpeg
Normal file
BIN
example_configs/bootstrap/bootstrap-example-log-1.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
254
example_configs/bootstrap/bootstrap.md
Normal file
254
example_configs/bootstrap/bootstrap.md
Normal file
@@ -0,0 +1,254 @@
|
||||
# Bootstrapping lldap using [bootstrap.sh](bootstrap.sh) script
|
||||
|
||||
bootstrap.sh allows managing your lldap in a git-ops, declarative way using JSON config files.
|
||||
|
||||
The script can:
|
||||
|
||||
* create, update users
|
||||
* set/update all lldap built-in user attributes
|
||||
* add/remove users to/from corresponding groups
|
||||
* set/update user avatar from file, link or from gravatar by user email
|
||||
* set/update user password
|
||||
* create groups
|
||||
* delete redundant users and groups (when `DO_CLEANUP` env var is true)
|
||||
* maintain the desired state described in JSON config files
|
||||
|
||||
|
||||

|
||||
|
||||
## Required packages
|
||||
|
||||
> The script will automatically install the required packages for alpine and debian-based distributions
|
||||
> when run by root, or you can install them by yourself.
|
||||
|
||||
- curl
|
||||
- [jq](https://github.com/jqlang/jq)
|
||||
- [jo](https://github.com/jpmens/jo)
|
||||
|
||||
## Environment variables
|
||||
|
||||
- `LLDAP_URL` or `LLDAP_URL_FILE` - URL to your lldap instance or path to file that contains URL (**MANDATORY**)
|
||||
- `LLDAP_ADMIN_USERNAME` or `LLDAP_ADMIN_USERNAME_FILE` - admin username or path to file that contains username (**MANDATORY**)
|
||||
- `LLDAP_ADMIN_PASSWORD` or `LLDAP_ADMIN_PASSWORD_FILE` - admin password or path to file that contains password (**MANDATORY**)
|
||||
- `USER_CONFIGS_DIR` (default value: `/user-configs`) - directory where the user JSON configs could be found
|
||||
- `GROUP_CONFIGS_DIR` (default value: `/group-configs`) - directory where the group JSON configs could be found
|
||||
- `LLDAP_SET_PASSWORD_PATH` - path to the `lldap_set_password` utility (default value: `/app/lldap_set_password`)
|
||||
- `DO_CLEANUP` (default value: `false`) - delete groups and users not specified in config files, also remove users from groups that they do not belong to
|
||||
|
||||
## Config files
|
||||
|
||||
There are two types of config files: [group](#group-config-file-example) and [user](#user-config-file-example) configs.
|
||||
Each config file can be as one JSON file with nested JSON top-level values as several JSON files.
|
||||
|
||||
### Group config file example
|
||||
|
||||
Group configs are used to define groups that will be created by the script
|
||||
|
||||
Fields description:
|
||||
|
||||
* `name`: name of the group (**MANDATORY**)
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "group-1"
|
||||
}
|
||||
{
|
||||
"name": "group-2"
|
||||
}
|
||||
```
|
||||
|
||||
### User config file example
|
||||
|
||||
User config defines all the lldap user structures,
|
||||
if the non-mandatory field is omitted, the script will clean this field in lldap as well.
|
||||
|
||||
Fields description:
|
||||
|
||||
* `id`: it's just username (**MANDATORY**)
|
||||
* `email`: self-explanatory (**MANDATORY**)
|
||||
* `password`: would be used to set the password using `lldap_set_password` utility
|
||||
* `displayName`: self-explanatory
|
||||
* `firstName`: self-explanatory
|
||||
* `lastName`: self-explanatory
|
||||
* `avatar_file`: must be a valid path to jpeg file (ignored if `avatar_url` specified)
|
||||
* `avatar_url`: must be a valid URL to jpeg file (ignored if `gravatar_avatar` specified)
|
||||
* `gravatar_avatar` (`false` by default): the script will try to get an avatar from [gravatar](https://gravatar.com/) by previously specified `email` (has the highest priority)
|
||||
* `weserv_avatar` (`false` by default): avatar file from `avatar_url` or `gravatar_avatar` would be converted to jpeg using [wsrv.nl](https://wsrv.nl) (useful when your avatar is png)
|
||||
* `groups`: an array of groups the user would be a member of (all the groups must be specified in group config files)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "username",
|
||||
"email": "username@example.com",
|
||||
"password": "changeme",
|
||||
"displayName": "Display Name",
|
||||
"firstName": "First",
|
||||
"lastName": "Last",
|
||||
"avatar_file": "/path/to/avatar.jpg",
|
||||
"avatar_url": "https://i.imgur.com/nbCxk3z.jpg",
|
||||
"gravatar_avatar": "false",
|
||||
"weserv_avatar": "false",
|
||||
"groups": [
|
||||
"group-1",
|
||||
"group-2"
|
||||
]
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## Usage example
|
||||
|
||||
### Manually
|
||||
|
||||
The script can be run manually in the terminal for initial bootstrapping of your lldap instance.
|
||||
You should make sure that the [required packages](#required-packages) are installed
|
||||
and the [environment variables](#environment-variables) are configured properly.
|
||||
|
||||
```bash
|
||||
export LLDAP_URL=http://localhost:8080
|
||||
export LLDAP_ADMIN_USERNAME=admin
|
||||
export LLDAP_ADMIN_PASSWORD=changeme
|
||||
export USER_CONFIGS_DIR="$(realpath ./configs/user)"
|
||||
export GROUP_CONFIGS_DIR="$(realpath ./configs/group)"
|
||||
export LLDAP_SET_PASSWORD_PATH="$(realpath ./lldap_set_password)"
|
||||
export DO_CLEANUP=false
|
||||
./bootstrap.sh
|
||||
```
|
||||
|
||||
### Docker compose
|
||||
|
||||
Let's suppose you have the next file structure:
|
||||
|
||||
```text
|
||||
./
|
||||
├─ docker-compose.yaml
|
||||
└─ bootstrap
|
||||
├─ bootstrap.sh
|
||||
└─ user-configs
|
||||
│ ├─ user-1.json
|
||||
│ ├─ ...
|
||||
│ └─ user-n.json
|
||||
└─ group-configs
|
||||
├─ group-1.json
|
||||
├─ ...
|
||||
└─ group-n.json
|
||||
|
||||
```
|
||||
|
||||
You should mount `bootstrap` dir to lldap container and set the corresponding `env` variables:
|
||||
|
||||
```yaml
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
lldap:
|
||||
image: lldap/lldap:v0.5.0
|
||||
volumes:
|
||||
- ./bootstrap:/bootstrap
|
||||
ports:
|
||||
- "3890:3890" # For LDAP
|
||||
- "17170:17170" # For the web front-end
|
||||
environment:
|
||||
# envs required for lldap
|
||||
- LLDAP_LDAP_USER_EMAIL=admin@example.com
|
||||
- LLDAP_LDAP_USER_PASS=changeme
|
||||
- LLDAP_LDAP_BASE_DN=dc=example,dc=com
|
||||
|
||||
# envs required for bootstrap.sh
|
||||
- LLDAP_URL=http://localhost:17170
|
||||
- LLDAP_ADMIN_USERNAME=admin
|
||||
- LLDAP_ADMIN_PASSWORD=changeme # same as LLDAP_LDAP_USER_PASS
|
||||
- USER_CONFIGS_DIR=/bootstrap/user-configs
|
||||
- GROUP_CONFIGS_DIR=/bootstrap/group-configs
|
||||
- DO_CLEANUP=false
|
||||
```
|
||||
|
||||
Then, to bootstrap your lldap just run `docker compose exec lldap /bootstrap/bootstrap.sh`.
|
||||
If config files were changed, re-run the `bootstrap.sh` with the same command.
|
||||
|
||||
### Kubernetes job
|
||||
|
||||
```yaml
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: lldap-bootstrap
|
||||
# Next annotations are required if the job managed by Argo CD,
|
||||
# so Argo CD can relaunch the job on every app sync action
|
||||
annotations:
|
||||
argocd.argoproj.io/hook: PostSync
|
||||
argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
restartPolicy: OnFailure
|
||||
containers:
|
||||
- name: lldap-bootstrap
|
||||
image: lldap/lldap:v0.5.0
|
||||
|
||||
command:
|
||||
- /bootstrap/bootstrap.sh
|
||||
|
||||
env:
|
||||
- name: LLDAP_URL
|
||||
value: "http://lldap:8080"
|
||||
|
||||
- name: LLDAP_ADMIN_USERNAME
|
||||
valueFrom: { secretKeyRef: { name: lldap-admin-user, key: username } }
|
||||
|
||||
- name: LLDAP_ADMIN_PASSWORD
|
||||
valueFrom: { secretKeyRef: { name: lldap-admin-user, key: password } }
|
||||
|
||||
- name: DO_CLEANUP
|
||||
value: "true"
|
||||
|
||||
volumeMounts:
|
||||
- name: bootstrap
|
||||
mountPath: /bootstrap/bootstrap.sh
|
||||
subPath: bootstrap.sh
|
||||
|
||||
- name: user-configs
|
||||
mountPath: /user-configs
|
||||
readOnly: true
|
||||
|
||||
- name: group-configs
|
||||
mountPath: /group-configs
|
||||
readOnly: true
|
||||
|
||||
volumes:
|
||||
- name: bootstrap
|
||||
configMap:
|
||||
name: bootstrap
|
||||
defaultMode: 0555
|
||||
items:
|
||||
- key: bootstrap.sh
|
||||
path: bootstrap.sh
|
||||
|
||||
- name: user-configs
|
||||
projected:
|
||||
sources:
|
||||
- secret:
|
||||
name: lldap-admin-user
|
||||
items:
|
||||
- key: user-config.json
|
||||
path: admin-config.json
|
||||
- secret:
|
||||
name: lldap-password-manager-user
|
||||
items:
|
||||
- key: user-config.json
|
||||
path: password-manager-config.json
|
||||
- secret:
|
||||
name: lldap-bootstrap-configs
|
||||
items:
|
||||
- key: user-configs.json
|
||||
path: user-configs.json
|
||||
|
||||
- name: group-configs
|
||||
projected:
|
||||
sources:
|
||||
- secret:
|
||||
name: lldap-bootstrap-configs
|
||||
items:
|
||||
- key: group-configs.json
|
||||
path: group-configs.json
|
||||
```
|
||||
490
example_configs/bootstrap/bootstrap.sh
Executable file
490
example_configs/bootstrap/bootstrap.sh
Executable file
@@ -0,0 +1,490 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
set -o pipefail
|
||||
|
||||
LLDAP_URL="${LLDAP_URL}"
|
||||
LLDAP_ADMIN_USERNAME="${LLDAP_ADMIN_USERNAME}"
|
||||
LLDAP_ADMIN_PASSWORD="${LLDAP_ADMIN_PASSWORD}"
|
||||
USER_CONFIGS_DIR="${USER_CONFIGS_DIR:-/user-configs}"
|
||||
GROUP_CONFIGS_DIR="${GROUP_CONFIGS_DIR:-/group-configs}"
|
||||
LLDAP_SET_PASSWORD_PATH="${LLDAP_SET_PASSWORD_PATH:-/app/lldap_set_password}"
|
||||
DO_CLEANUP="${DO_CLEANUP:-false}"
|
||||
|
||||
check_install_dependencies() {
|
||||
local commands=('curl' 'jq' 'jo')
|
||||
local commands_not_found='false'
|
||||
|
||||
if ! hash "${commands[@]}" 2>/dev/null; then
|
||||
if hash 'apk' 2>/dev/null && [[ $EUID -eq 0 ]]; then
|
||||
apk add "${commands[@]}"
|
||||
elif hash 'apt' 2>/dev/null && [[ $EUID -eq 0 ]]; then
|
||||
apt update -yqq
|
||||
apt install -yqq "${commands[@]}"
|
||||
else
|
||||
local command=''
|
||||
for command in "${commands[@]}"; do
|
||||
if ! hash "$command" 2>/dev/null; then
|
||||
printf 'Command not found "%s"\n' "$command"
|
||||
fi
|
||||
done
|
||||
commands_not_found='true'
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$commands_not_found" == 'true' ]]; then
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
check_required_env_vars() {
|
||||
local env_var_not_specified='false'
|
||||
local dual_env_vars_list=(
|
||||
'LLDAP_URL'
|
||||
'LLDAP_ADMIN_USERNAME'
|
||||
'LLDAP_ADMIN_PASSWORD'
|
||||
)
|
||||
|
||||
local dual_env_var_name=''
|
||||
for dual_env_var_name in "${dual_env_vars_list[@]}"; do
|
||||
local dual_env_var_file_name="${dual_env_var_name}_FILE"
|
||||
|
||||
if [[ -z "${!dual_env_var_name}" ]] && [[ -z "${!dual_env_var_file_name}" ]]; then
|
||||
printf 'Please specify "%s" or "%s" variable!\n' "$dual_env_var_name" "$dual_env_var_file_name" >&2
|
||||
env_var_not_specified='true'
|
||||
else
|
||||
if [[ -n "${!dual_env_var_file_name}" ]]; then
|
||||
declare -g "$dual_env_var_name"="$(cat "${!dual_env_var_file_name}")"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$env_var_not_specified" == 'true' ]]; then
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
check_configs_validity() {
|
||||
local config_file='' config_invalid='false'
|
||||
for config_file in "$@"; do
|
||||
local error=''
|
||||
if ! error="$(jq '.' -- "$config_file" 2>&1 >/dev/null)"; then
|
||||
printf '%s: %s\n' "$config_file" "$error"
|
||||
config_invalid='true'
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$config_invalid" == 'true' ]]; then
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
auth() {
|
||||
local url="$1" admin_username="$2" admin_password="$3"
|
||||
|
||||
local response
|
||||
response="$(curl --silent --request POST \
|
||||
--url "$url/auth/simple/login" \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data "$(jo -- username="$admin_username" password="$admin_password")")"
|
||||
|
||||
TOKEN="$(printf '%s' "$response" | jq --raw-output .token)"
|
||||
}
|
||||
|
||||
make_query() {
|
||||
local query_file="$1" variables_file="$2"
|
||||
|
||||
curl --silent --request POST \
|
||||
--url "$LLDAP_URL/api/graphql" \
|
||||
--header "Authorization: Bearer $TOKEN" \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data @<(jq --slurpfile variables "$variables_file" '. + {"variables": $variables[0]}' "$query_file")
|
||||
}
|
||||
|
||||
get_group_list() {
|
||||
local query='{"query":"query GetGroupList {groups {id displayName}}","operationName":"GetGroupList"}'
|
||||
make_query <(printf '%s' "$query") <(printf '{}')
|
||||
}
|
||||
|
||||
get_group_array() {
|
||||
get_group_list | jq --raw-output '.data.groups[].displayName'
|
||||
}
|
||||
|
||||
group_exists() {
|
||||
if [[ "$(get_group_list | jq --raw-output --arg displayName "$1" '.data.groups | any(.[]; select(.displayName == $displayName))')" == 'true' ]]; then
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
get_group_id() {
|
||||
get_group_list | jq --raw-output --arg displayName "$1" '.data.groups[] | if .displayName == $displayName then .id else empty end'
|
||||
}
|
||||
|
||||
create_group() {
|
||||
local group_name="$1"
|
||||
|
||||
if group_exists "$group_name"; then
|
||||
printf 'Group "%s" (%s) already exists\n' "$group_name" "$(get_group_id "$group_name")"
|
||||
return
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC2016
|
||||
local query='{"query":"mutation CreateGroup($name: String!) {createGroup(name: $name) {id displayName}}","operationName":"CreateGroup"}'
|
||||
|
||||
local response='' error=''
|
||||
response="$(make_query <(printf '%s' "$query") <(jo -- name="$group_name"))"
|
||||
error="$(printf '%s' "$response" | jq --raw-output '.errors | if . != null then .[].message else empty end')"
|
||||
if [[ -n "$error" ]]; then
|
||||
printf '%s\n' "$error"
|
||||
else
|
||||
printf 'Group "%s" (%s) successfully created\n' "$group_name" "$(printf '%s' "$response" | jq --raw-output '.data.createGroup.id')"
|
||||
fi
|
||||
}
|
||||
|
||||
delete_group() {
|
||||
local group_name="$1" id=''
|
||||
|
||||
if ! group_exists "$group_name"; then
|
||||
printf '[WARNING] Group "%s" does not exist\n' "$group_name"
|
||||
return
|
||||
fi
|
||||
|
||||
id="$(get_group_id "$group_name")"
|
||||
|
||||
# shellcheck disable=SC2016
|
||||
local query='{"query":"mutation DeleteGroupQuery($groupId: Int!) {deleteGroup(groupId: $groupId) {ok}}","operationName":"DeleteGroupQuery"}'
|
||||
|
||||
local response='' error=''
|
||||
response="$(make_query <(printf '%s' "$query") <(jo -- groupId="$id"))"
|
||||
error="$(printf '%s' "$response" | jq --raw-output '.errors | if . != null then .[].message else empty end')"
|
||||
if [[ -n "$error" ]]; then
|
||||
printf '%s\n' "$error"
|
||||
else
|
||||
printf 'Group "%s" (%s) successfully deleted\n' "$group_name" "$id"
|
||||
fi
|
||||
}
|
||||
|
||||
get_user_details() {
|
||||
local id="$1"
|
||||
|
||||
# shellcheck disable=SC2016
|
||||
local query='{"query":"query GetUserDetails($id: String!) {user(userId: $id) {id email displayName firstName lastName creationDate uuid groups {id displayName}}}","operationName":"GetUserDetails"}'
|
||||
make_query <(printf '%s' "$query") <(jo -- id="$id")
|
||||
}
|
||||
|
||||
user_in_group() {
|
||||
local user_id="$1" group_name="$2"
|
||||
|
||||
if ! group_exists "$group_name"; then
|
||||
printf '[WARNING] Group "%s" does not exist\n' "$group_name"
|
||||
return
|
||||
fi
|
||||
|
||||
if ! user_exists "$user_id"; then
|
||||
printf 'User "%s" is not exists\n' "$user_id"
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ "$(get_user_details "$user_id" | jq --raw-output --arg displayName "$group_name" '.data.user.groups | any(.[]; select(.displayName == $displayName))')" == 'true' ]]; then
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
add_user_to_group() {
|
||||
local user_id="$1" group_name="$2" group_id=''
|
||||
|
||||
if ! group_exists "$group_name"; then
|
||||
printf '[WARNING] Group "%s" does not exist\n' "$group_name"
|
||||
return
|
||||
fi
|
||||
|
||||
group_id="$(get_group_id "$group_name")"
|
||||
|
||||
if user_in_group "$user_id" "$group_name"; then
|
||||
printf 'User "%s" already in group "%s" (%s)\n' "$user_id" "$group_name" "$group_id"
|
||||
return
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC2016
|
||||
local query='{"query":"mutation AddUserToGroup($user: String!, $group: Int!) {addUserToGroup(userId: $user, groupId: $group) {ok}}","operationName":"AddUserToGroup"}'
|
||||
|
||||
local response='' error=''
|
||||
response="$(make_query <(printf '%s' "$query") <(jo -- user="$user_id" group="$group_id"))"
|
||||
error="$(printf '%s' "$response" | jq '.errors | if . != null then .[].message else empty end')"
|
||||
if [[ -n "$error" ]]; then
|
||||
printf '%s\n' "$error"
|
||||
else
|
||||
printf 'User "%s" successfully added to the group "%s" (%s)\n' "$user_id" "$group_name" "$group_id"
|
||||
fi
|
||||
}
|
||||
|
||||
remove_user_from_group() {
|
||||
local user_id="$1" group_name="$2" group_id=''
|
||||
|
||||
if ! group_exists "$group_name"; then
|
||||
printf '[WARNING] Group "%s" does not exist\n' "$group_name"
|
||||
return
|
||||
fi
|
||||
|
||||
group_id="$(get_group_id "$group_name")"
|
||||
|
||||
# shellcheck disable=SC2016
|
||||
local query='{"operationName":"RemoveUserFromGroup","query":"mutation RemoveUserFromGroup($user: String!, $group: Int!) {removeUserFromGroup(userId: $user, groupId: $group) {ok}}"}'
|
||||
|
||||
local response='' error=''
|
||||
response="$(make_query <(printf '%s' "$query") <(jo -- user="$user_id" group="$group_id"))"
|
||||
error="$(printf '%s' "$response" | jq '.errors | if . != null then .[].message else empty end')"
|
||||
if [[ -n "$error" ]]; then
|
||||
printf '%s\n' "$error"
|
||||
else
|
||||
printf 'User "%s" successfully removed from the group "%s" (%s)\n' "$user_id" "$group_name" "$group_id"
|
||||
fi
|
||||
}
|
||||
|
||||
get_users_list() {
|
||||
# shellcheck disable=SC2016
|
||||
local query='{"query": "query ListUsersQuery($filters: RequestFilter) {users(filters: $filters) {id email displayName firstName lastName creationDate}}","operationName": "ListUsersQuery"}'
|
||||
make_query <(printf '%s' "$query") <(jo -- filters=null)
|
||||
}
|
||||
|
||||
user_exists() {
|
||||
if [[ "$(get_users_list | jq --raw-output --arg id "$1" '.data.users | any(.[]; contains({"id": $id}))')" == 'true' ]]; then
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
delete_user() {
|
||||
local id="$1"
|
||||
|
||||
if ! user_exists "$id"; then
|
||||
printf 'User "%s" is not exists\n' "$id"
|
||||
return
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC2016
|
||||
local query='{"query": "mutation DeleteUserQuery($user: String!) {deleteUser(userId: $user) {ok}}","operationName": "DeleteUserQuery"}'
|
||||
|
||||
local response='' error=''
|
||||
response="$(make_query <(printf '%s' "$query") <(jo -- user="$id"))"
|
||||
error="$(printf '%s' "$response" | jq --raw-output '.errors | if . != null then .[].message else empty end')"
|
||||
if [[ -n "$error" ]]; then
|
||||
printf '%s\n' "$error"
|
||||
else
|
||||
printf 'User "%s" successfully deleted\n' "$id"
|
||||
fi
|
||||
}
|
||||
|
||||
__common_user_mutation_query() {
|
||||
local \
|
||||
query="$1" \
|
||||
id="${2:-null}" \
|
||||
email="${3:-null}" \
|
||||
displayName="${4:-null}" \
|
||||
firstName="${5:-null}" \
|
||||
lastName="${6:-null}" \
|
||||
avatar_file="${7:-null}" \
|
||||
avatar_url="${8:-null}" \
|
||||
gravatar_avatar="${9:-false}" \
|
||||
weserv_avatar="${10:-false}"
|
||||
|
||||
local variables_arr=(
|
||||
'-s' "id=$id"
|
||||
'-s' "email=$email"
|
||||
'-s' "displayName=$displayName"
|
||||
'-s' "firstName=$firstName"
|
||||
'-s' "lastName=$lastName"
|
||||
)
|
||||
|
||||
local temp_avatar_file=''
|
||||
|
||||
if [[ "$gravatar_avatar" == 'true' ]]; then
|
||||
avatar_url="https://gravatar.com/avatar/$(printf '%s' "$email" | sha256sum | cut -d ' ' -f 1)?size=512"
|
||||
fi
|
||||
|
||||
if [[ "$avatar_url" != 'null' ]]; then
|
||||
temp_avatar_file="${TMP_AVATAR_DIR}/$(printf '%s' "$avatar_url" | md5sum | cut -d ' ' -f 1)"
|
||||
|
||||
if ! [[ -f "$temp_avatar_file" ]]; then
|
||||
if [[ "$weserv_avatar" == 'true' ]]; then
|
||||
avatar_url="https://wsrv.nl/?url=$avatar_url&output=jpg"
|
||||
fi
|
||||
curl --silent --location --output "$temp_avatar_file" "$avatar_url"
|
||||
fi
|
||||
|
||||
avatar_file="$temp_avatar_file"
|
||||
fi
|
||||
|
||||
if [[ "$avatar_file" == 'null' ]]; then
|
||||
variables_arr+=('-s' 'avatar=null')
|
||||
else
|
||||
variables_arr+=("avatar=%$avatar_file")
|
||||
fi
|
||||
|
||||
make_query <(printf '%s' "$query") <(jo -- user=:<(jo -- "${variables_arr[@]}"))
|
||||
}
|
||||
|
||||
create_user() {
|
||||
local id="$1"
|
||||
|
||||
if user_exists "$id"; then
|
||||
printf 'User "%s" already exists\n' "$id"
|
||||
return
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC2016
|
||||
local query='{"query":"mutation CreateUser($user: CreateUserInput!) {createUser(user: $user) {id creationDate}}","operationName":"CreateUser"}'
|
||||
|
||||
local response='' error=''
|
||||
response="$(__common_user_mutation_query "$query" "$@")"
|
||||
error="$(printf '%s' "$response" | jq --raw-output '.errors | if . != null then .[].message else empty end')"
|
||||
if [[ -n "$error" ]]; then
|
||||
printf '%s\n' "$error"
|
||||
else
|
||||
printf 'User "%s" successfully created\n' "$id"
|
||||
fi
|
||||
}
|
||||
|
||||
update_user() {
|
||||
local id="$1"
|
||||
|
||||
if ! user_exists "$id"; then
|
||||
printf 'User "%s" is not exists\n' "$id"
|
||||
return
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC2016
|
||||
local query='{"query":"mutation UpdateUser($user: UpdateUserInput!) {updateUser(user: $user) {ok}}","operationName":"UpdateUser"}'
|
||||
|
||||
local response='' error=''
|
||||
response="$(__common_user_mutation_query "$query" "$@")"
|
||||
error="$(printf '%s' "$response" | jq --raw-output '.errors | if . != null then .[].message else empty end')"
|
||||
if [[ -n "$error" ]]; then
|
||||
printf '%s\n' "$error"
|
||||
else
|
||||
printf 'User "%s" successfully updated\n' "$id"
|
||||
fi
|
||||
}
|
||||
|
||||
create_update_user() {
|
||||
local id="$1"
|
||||
|
||||
if user_exists "$id"; then
|
||||
update_user "$@"
|
||||
else
|
||||
create_user "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
check_install_dependencies
|
||||
check_required_env_vars
|
||||
|
||||
local user_config_files=("${USER_CONFIGS_DIR}"/*.json)
|
||||
local group_config_files=("${GROUP_CONFIGS_DIR}"/*.json)
|
||||
|
||||
if ! check_configs_validity "${group_config_files[@]}" "${user_config_files[@]}"; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
until curl --silent -o /dev/null "$LLDAP_URL"; do
|
||||
printf 'Waiting lldap to start...\n'
|
||||
sleep 10
|
||||
done
|
||||
|
||||
auth "$LLDAP_URL" "$LLDAP_ADMIN_USERNAME" "$LLDAP_ADMIN_PASSWORD"
|
||||
|
||||
local redundant_groups=''
|
||||
redundant_groups="$(get_group_list | jq '[ .data.groups[].displayName ]' | jq --compact-output '. - ["lldap_admin","lldap_password_manager","lldap_strict_readonly"]')"
|
||||
|
||||
printf -- '\n--- groups ---\n'
|
||||
local group_config=''
|
||||
while read -r group_config; do
|
||||
local group_name=''
|
||||
group_name="$(printf '%s' "$group_config" | jq --raw-output '.name')"
|
||||
create_group "$group_name"
|
||||
redundant_groups="$(printf '%s' "$redundant_groups" | jq --compact-output --arg name "$group_name" '. - [$name]')"
|
||||
done < <(jq --compact-output '.' -- "${group_config_files[@]}")
|
||||
printf -- '--- groups ---\n'
|
||||
|
||||
printf -- '\n--- redundant groups ---\n'
|
||||
if [[ "$redundant_groups" == '[]' ]]; then
|
||||
printf 'There are no redundant groups\n'
|
||||
else
|
||||
local group_name=''
|
||||
while read -r group_name; do
|
||||
if [[ "$DO_CLEANUP" == 'true' ]]; then
|
||||
delete_group "$group_name"
|
||||
else
|
||||
printf '[WARNING] Group "%s" is not declared in config files\n' "$group_name"
|
||||
fi
|
||||
done < <(printf '%s' "$redundant_groups" | jq --raw-output '.[]')
|
||||
fi
|
||||
printf -- '--- redundant groups ---\n'
|
||||
|
||||
local redundant_users=''
|
||||
redundant_users="$(get_users_list | jq '[ .data.users[].id ]' | jq --compact-output --arg admin_id "$LLDAP_ADMIN_USERNAME" '. - [$admin_id]')"
|
||||
|
||||
TMP_AVATAR_DIR="$(mktemp -d)"
|
||||
|
||||
local user_config=''
|
||||
while read -r user_config; do
|
||||
local field='' id='' email='' displayName='' firstName='' lastName='' avatar_file='' avatar_url='' gravatar_avatar='' weserv_avatar='' password=''
|
||||
for field in 'id' 'email' 'displayName' 'firstName' 'lastName' 'avatar_file' 'avatar_url' 'gravatar_avatar' 'weserv_avatar' 'password'; do
|
||||
declare "$field"="$(printf '%s' "$user_config" | jq --raw-output --arg field "$field" '.[$field]')"
|
||||
done
|
||||
printf -- '\n--- %s ---\n' "$id"
|
||||
|
||||
create_update_user "$id" "$email" "$displayName" "$firstName" "$lastName" "$avatar_file" "$avatar_url" "$gravatar_avatar" "$weserv_avatar"
|
||||
redundant_users="$(printf '%s' "$redundant_users" | jq --compact-output --arg id "$id" '. - [$id]')"
|
||||
|
||||
if [[ "$password" != 'null' ]] && [[ "$password" != '""' ]]; then
|
||||
"$LLDAP_SET_PASSWORD_PATH" --base-url "$LLDAP_URL" --token "$TOKEN" --username "$id" --password "$password"
|
||||
fi
|
||||
|
||||
local redundant_user_groups=''
|
||||
redundant_user_groups="$(get_user_details "$id" | jq '[ .data.user.groups[].displayName ]')"
|
||||
|
||||
local group=''
|
||||
while read -r group; do
|
||||
if [[ -n "$group" ]]; then
|
||||
add_user_to_group "$id" "$group"
|
||||
redundant_user_groups="$(printf '%s' "$redundant_user_groups" | jq --compact-output --arg group "$group" '. - [$group]')"
|
||||
fi
|
||||
done < <(printf '%s' "$user_config" | jq --raw-output '.groups | if . == null then "" else .[] end')
|
||||
|
||||
local user_group_name=''
|
||||
while read -r user_group_name; do
|
||||
if [[ "$DO_CLEANUP" == 'true' ]]; then
|
||||
remove_user_from_group "$id" "$user_group_name"
|
||||
else
|
||||
printf '[WARNING] User "%s" is not declared as member of the "%s" group in the config files\n' "$id" "$user_group_name"
|
||||
fi
|
||||
done < <(printf '%s' "$redundant_user_groups" | jq --raw-output '.[]')
|
||||
printf -- '--- %s ---\n' "$id"
|
||||
done < <(jq --compact-output '.' -- "${user_config_files[@]}")
|
||||
|
||||
rm -r "$TMP_AVATAR_DIR"
|
||||
|
||||
printf -- '\n--- redundant users ---\n'
|
||||
if [[ "$redundant_users" == '[]' ]]; then
|
||||
printf 'There are no redundant users\n'
|
||||
else
|
||||
local id=''
|
||||
while read -r id; do
|
||||
if [[ "$DO_CLEANUP" == 'true' ]]; then
|
||||
delete_user "$id"
|
||||
else
|
||||
printf '[WARNING] User "%s" is not declared in config files\n' "$id"
|
||||
fi
|
||||
done < <(printf '%s' "$redundant_users" | jq --raw-output '.[]')
|
||||
fi
|
||||
printf -- '--- redundant users ---\n'
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -6,6 +6,7 @@ LDAP configuration is in ```/dokuwiki/conf/local.protected.php```:
|
||||
<?php
|
||||
$conf['useacl'] = 1; //enable ACL
|
||||
$conf['authtype'] = 'authldap'; //enable this Auth plugin
|
||||
$conf['superuser'] = 'admin';
|
||||
$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';
|
||||
|
||||
30
example_configs/gitlab.md
Normal file
30
example_configs/gitlab.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# GitLab Configuration
|
||||
|
||||
Members of the group ``git_user`` will have access to GitLab.
|
||||
|
||||
Edit ``/etc/gitlab/gitlab.rb``:
|
||||
|
||||
```ruby
|
||||
gitlab_rails['ldap_enabled'] = true
|
||||
gitlab_rails['ldap_servers'] = {
|
||||
'main' => {
|
||||
'label' => 'LDAP',
|
||||
'host' => 'ldap.example.com',
|
||||
'port' => 3890,
|
||||
'uid' => 'uid',
|
||||
'base' => 'ou=people,dc=example,dc=com',
|
||||
'encryption' => 'plain',
|
||||
'bind_dn' => 'uid=bind_user,ou=people,dc=example,dc=com',
|
||||
'password' => '<bind user password>',
|
||||
'active_directory' => false,
|
||||
'user_filter' => '(&(objectclass=person)(memberof=cn=git_user,ou=groups,dc=example,dc=com))',
|
||||
'attributes' => {
|
||||
'username' => 'uid',
|
||||
'email' => 'mail',
|
||||
'name' => 'displayName',
|
||||
'first_name' => 'givenName',
|
||||
'last_name' => 'sn'
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
28
example_configs/grocy.md
Normal file
28
example_configs/grocy.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Configuration for Grocy
|
||||
|
||||
Adjust the following values in the file `config/data/config.php` or add environment variables for them (prefixed with `GROCY_`).
|
||||
|
||||
NOTE: If the environment variables are not working (for example in the linuxserver.io Docker Image), you need to add `clear_env = no` under the `[www]` in `/config/php/www2.conf`.
|
||||
|
||||
Replace `dc=example,dc=com` with your LLDAP configured domain.
|
||||
|
||||
### AUTH_CLASS
|
||||
Needs to be set to `Grocy\Middleware\LdapAuthMiddleware` in order to use LDAP
|
||||
|
||||
### LDAP_ADDRESS
|
||||
The address of your ldap server, eg: `ldap://lldap.example.com:389`
|
||||
|
||||
### LDAP_BASE_DN
|
||||
The base dn, usually points directly to the `people`, eg: `ou=people,dc=example,dc=com`
|
||||
|
||||
### LDAP_BIND_DN
|
||||
The reader user for lldap, eg: `uid=ldap-reader,ou=people,dc=example,dc=com`
|
||||
|
||||
### LDAP_BIND_PW
|
||||
The password for the reader user
|
||||
|
||||
### LDAP_USER_FILTER
|
||||
The filter to use for the users, eg. for a separate group: `(&(objectClass=person)(memberof=cn=grocy_users,ou=groups,dc=example,dc=com))`
|
||||
|
||||
### LDAP_UID_ATTR
|
||||
The user id attribute, should be `uid`
|
||||
@@ -16,9 +16,20 @@ homeassistant:
|
||||
- type: homeassistant
|
||||
- type: command_line
|
||||
command: /config/lldap-ha-auth.sh
|
||||
# Only allow users in the 'homeassistant_user' group to login.
|
||||
# Change to ["https://lldap.example.com"] to allow all users
|
||||
args: ["https://lldap.example.com", "homeassistant_user"]
|
||||
# arguments: [<LDAP Host>, <regular user group>, <admin user group>, <local user group>]
|
||||
# <regular user group>: Find users that has permission to access homeassistant, anyone inside
|
||||
# this group will have the default 'system-users' permission in homeassistant.
|
||||
#
|
||||
# <admin user group>: Allow users in the <regular user group> to be assigned into 'system-admin' group.
|
||||
# Anyone inside this group will not have the 'system-users' permission as only one permission group
|
||||
# is allowed in homeassistant
|
||||
#
|
||||
# <local user group>: Users in the <local user group> (e.g., 'homeassistant_local') can only access
|
||||
# homeassistant inside LAN network.
|
||||
#
|
||||
# Only the first argument is required. ["https://lldap.example.com"] allows all users to log in from
|
||||
# anywhere and have 'system-users' permissions.
|
||||
args: ["https://lldap.example.com", "homeassistant_user", "homeassistant_admin", "homeassistant_local"]
|
||||
meta: true
|
||||
```
|
||||
3. Reload your config or restart Home Assistant
|
||||
|
||||
81
example_configs/jenkins.md
Normal file
81
example_configs/jenkins.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Configuration for Jenkins
|
||||
|
||||
## Jenkins base setup
|
||||
|
||||
To setup LLDAP for Jenkins navigate to Dashboard/Manage Jenkins/Security.
|
||||
|
||||
*Note: Jenkins LDAP plugin has to be installed!</br>*
|
||||
*Note: "dc=example,dc=com" is default configuration, you should replace it with your base DN.*
|
||||
|
||||
1) Set **Security Realm** to **LDAP**
|
||||
2) Click Add Server
|
||||
3) Setup config fields as stated below
|
||||
|
||||
## Config fields
|
||||
|
||||
#### Server
|
||||
*(This can be replaced by server ip/your domain etc.)*
|
||||
```
|
||||
ldap://example.com:3890
|
||||
```
|
||||
### Advanced Server Configuration Dropdown
|
||||
|
||||
#### root DN
|
||||
```
|
||||
dc=example,dc=com
|
||||
```
|
||||
|
||||
#### Allow blank rootDN
|
||||
```
|
||||
true
|
||||
```
|
||||
|
||||
#### User search base
|
||||
```
|
||||
ou=people
|
||||
```
|
||||
|
||||
#### User search filter
|
||||
```
|
||||
uid={0}
|
||||
```
|
||||
|
||||
#### Group search base
|
||||
```
|
||||
ou=groups
|
||||
```
|
||||
|
||||
#### Group search filter
|
||||
```
|
||||
(& (cn={0})(objectclass=groupOfNames))
|
||||
```
|
||||
|
||||
#### Group membership
|
||||
Select Search for LDAP groups containing user and leave Group membership filter empty
|
||||
|
||||
#### Manager DN
|
||||
Leave here your admin account
|
||||
```
|
||||
cn=admin,ou=people,dc=example,dc=com
|
||||
```
|
||||
#### Manager Password
|
||||
Leave it as is
|
||||
|
||||
#### Display Name LDAP attribute
|
||||
Leave cn as it inputs username
|
||||
```
|
||||
cn
|
||||
```
|
||||
|
||||
#### Email Address LDAP attribute
|
||||
```
|
||||
mail
|
||||
```
|
||||
|
||||
### Tips & Tricks
|
||||
- Always use Test LDAP settings so you won't get locked out. It works without password.
|
||||
- If you want to setup your permissions, go to Authorization setting and select Matrix-based security. Add group/user (it has to exist in LLDAP) and you can grant him permissions. Note that Overall Read forbids users to read jenkins and execute actions. Administer gives full rights.
|
||||
|
||||
### Useful links:
|
||||
https://plugins.jenkins.io/ldap/</br>
|
||||
https://www.jenkins.io/doc/book/security/managing-security/
|
||||
19
example_configs/kasm.md
Normal file
19
example_configs/kasm.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Configuration for Kasm
|
||||
|
||||
In Kasm, go to *Admin* -> *Authentication* -> *LDAP* and add a configuration.
|
||||
- *Name*: whatever you like
|
||||
- *Url* is your lldap host (or IP) and port, e.g. `ldap://lldap.example.com:3890`
|
||||
- *Search Base* is is your base dn, e.g `dc=example,dc=com`
|
||||
- *Search Filter* is `(&(objectClass=person)(uid={0})(memberof=cn=kasm,ou=groups,dc=example,dc=com))`. Replace `cn=kasm,ou=groups,dc=example,dc=com` with the dn to the group necessary to login to Kasm.
|
||||
- *Group Membership Filter* `(&(objectClass=groupOfUniqueNames)(member={0}))`
|
||||
- *Email attribute* `mail`
|
||||
- *Service Account DN* a lldap user, preferably not a admin but a member of the group `lldap_strict readonly`. Mine is called `cn=query,ou=people,dc=example,dc=com`
|
||||
- *Service Account Password*: querys password
|
||||
- Activate *Search Subtree*, *Auto Create App User* and *Enabled*
|
||||
- under *Attribute Mapping* you can map the following:
|
||||
- *Email* -> `mail`
|
||||
- *First Name* -> `givenname`
|
||||
- *Last Name* -> `sn`
|
||||
- If you want to map groups from your lldap to Kasm, edit the group, scroll to *SSO Group Mappings* and add a new SSO mapping:
|
||||
- select your lldap as provider
|
||||
- *Group Attributes* is the full DN of your group, e.g. `cn=kasm_moreaccess,ou=groups,dc=example,dc=com`
|
||||
@@ -66,5 +66,26 @@ fi
|
||||
|
||||
DISPLAY_NAME=$(jq -r .displayName <<< $USER_JSON)
|
||||
|
||||
IS_ADMIN=false
|
||||
if [[ ! -z "$3" ]] && jq -e '.groups|map(.displayName)|index("'"$3"'")' <<< "$USER_JSON" > /dev/null 2>&1; then
|
||||
IS_ADMIN=true
|
||||
fi
|
||||
|
||||
IS_LOCAL=false
|
||||
if [[ ! -z "$4" ]] && jq -e '.groups|map(.displayName)|index("'"$4"'")' <<< "$USER_JSON" > /dev/null 2>&1; then
|
||||
IS_LOCAL=true
|
||||
fi
|
||||
|
||||
[[ ! -z "$DISPLAY_NAME" ]] && echo "name = $DISPLAY_NAME"
|
||||
|
||||
if [[ "$IS_ADMIN" = true ]]; then
|
||||
echo "group = system-admin"
|
||||
else
|
||||
echo "group = system-users"
|
||||
fi
|
||||
|
||||
if [[ "$IS_LOCAL" = true ]]; then
|
||||
echo "local_only = true"
|
||||
else
|
||||
echo "local_only = false"
|
||||
fi
|
||||
@@ -1,6 +1,6 @@
|
||||
[Unit]
|
||||
Description=Nitnelave LLDAP
|
||||
Documentation=https://github.com/nitnelave/lldap
|
||||
Documentation=https://github.com/lldap/lldap
|
||||
|
||||
# Only sqlite
|
||||
After=network.target
|
||||
|
||||
83
example_configs/maddy.md
Normal file
83
example_configs/maddy.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# Configuration for Maddy Mail Server
|
||||
|
||||
Documentation for maddy LDAP can be found [here](https://maddy.email/reference/auth/ldap/).
|
||||
Maddy will automatically create an imap-acct if a new user connects via LDAP.
|
||||
Replace `dc=example,dc=com` with your LLDAP configured domain.
|
||||
|
||||
|
||||
## Simple Setup
|
||||
Depending on the mail client(s) the simple setup can work for you. However, if this does not work for you, follow the instructions in the `Advanced Setup` section.
|
||||
|
||||
### DN Template
|
||||
You only have to specify the dn template:
|
||||
```
|
||||
dn_template "cn={username},ou=people,dc=example,dc=com"
|
||||
```
|
||||
|
||||
### Config Example with Docker
|
||||
Example maddy configuration with LLDAP running in docker.
|
||||
You can replace `local_authdb` with another name if you want to use multiple auth backends.
|
||||
If you only want to use one storage backend make sure to disable `auth.pass_table local_authdb` in your config if it is still active.
|
||||
```
|
||||
auth.ldap local_authdb {
|
||||
urls ldap://lldap:3890
|
||||
|
||||
dn_template "cn={username},ou=people,dc=example,dc=com"
|
||||
|
||||
starttls off
|
||||
debug off
|
||||
connect_timeout 1m
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
## Advanced Setup
|
||||
If the simple setup does not work for you, you can use a proper lookup.
|
||||
|
||||
### Bind Credentials
|
||||
If you have a service account in LLDAP with restricted rights (e.g. `lldap_strict_readonly`), replace `admin` with your LLDAP service account.
|
||||
Replace `admin_password` with the password of either the admin or service account.
|
||||
```
|
||||
bind plain "cn=admin,ou=people,dc=example,dc=com" "admin_password"
|
||||
```
|
||||
If you do not want to use plain auth check the [maddy LDAP page](https://maddy.email/reference/auth/ldap/) for other options.
|
||||
|
||||
### Base DN
|
||||
```
|
||||
base_dn "dc=example,dc=com"
|
||||
```
|
||||
|
||||
### Filter
|
||||
Depending on the mail client, maddy receives and sends either the username or the full E-Mail address as username (even if the username is not an E-Mail).
|
||||
For the username use:
|
||||
```
|
||||
filter "(&(objectClass=person)(uid={username}))"
|
||||
```
|
||||
For mapping the username (as E-Mail):
|
||||
```
|
||||
filter "(&(objectClass=person)(mail={username}))"
|
||||
```
|
||||
For allowing both, username and username as E-Mail use:
|
||||
```
|
||||
filter "(&(|(uid={username})(mail={username}))(objectClass=person))"
|
||||
```
|
||||
|
||||
### Config Example with Docker
|
||||
Example maddy configuration with LLDAP running in docker.
|
||||
You can replace `local_authdb` with another name if you want to use multiple auth backends.
|
||||
If you only want to use one storage backend make sure to disable `auth.pass_table local_authdb` in your config if it is still active.
|
||||
```
|
||||
auth.ldap local_authdb {
|
||||
urls ldap://lldap:3890
|
||||
|
||||
bind plain "cn=admin,ou=people,dc=example,dc=com" "admin_password"
|
||||
base_dn "dc=example,dc=com"
|
||||
filter "(&(|(uid={username})(mail={username}))(objectClass=person))"
|
||||
|
||||
starttls off
|
||||
debug off
|
||||
connect_timeout 1m
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
96
example_configs/mailserver.md
Normal file
96
example_configs/mailserver.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# Mailserver Docker
|
||||
|
||||
[Docker-mailserver](https://docker-mailserver.github.io/docker-mailserver/latest/) is a Production-ready full-stack but simple mail server (SMTP, IMAP, LDAP, Antispam, Antivirus, etc.) running inside a container.
|
||||
|
||||
To integrate with LLDAP, ensure you correctly adjust the `docker-mailserver` container environment values.
|
||||
|
||||
## Compose File Sample
|
||||
```yaml
|
||||
version: "3.9"
|
||||
services:
|
||||
lldap:
|
||||
image: lldap/lldap:stable
|
||||
ports:
|
||||
- "3890:3890"
|
||||
- "17170:17170"
|
||||
volumes:
|
||||
- "lldap_data:/data"
|
||||
environment:
|
||||
- VERBOSE=true
|
||||
- TZ=Etc/UTC
|
||||
- LLDAP_JWT_SECRET=yourjwt
|
||||
- LLDAP_LDAP_USER_PASS=adminpassword
|
||||
- LLDAP_LDAP_BASE_DN=dc=example,dc=com
|
||||
|
||||
mailserver:
|
||||
image: ghcr.io/docker-mailserver/docker-mailserver:latest
|
||||
container_name: mailserver
|
||||
hostname: mail.example.com
|
||||
ports:
|
||||
- "25:25" # SMTP (explicit TLS => STARTTLS)
|
||||
- "143:143" # IMAP4 (explicit TLS => STARTTLS)
|
||||
- "465:465" # ESMTP (implicit TLS)
|
||||
- "587:587" # ESMTP (explicit TLS => STARTTLS)
|
||||
- "993:993" # IMAP4 (implicit TLS)
|
||||
volumes:
|
||||
- mailserver-data:/var/mail
|
||||
- mailserver-state:/var/mail-state
|
||||
- mailserver-config:/tmp/docker-mailserver/
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
restart: always
|
||||
stop_grace_period: 1m
|
||||
healthcheck:
|
||||
test: "ss --listening --tcp | grep -P 'LISTEN.+:smtp' || exit 1"
|
||||
timeout: 3s
|
||||
retries: 0
|
||||
environment:
|
||||
- LOG_LEVEL=debug
|
||||
- SUPERVISOR_LOGLEVEL=debug
|
||||
- SPAMASSASSIN_SPAM_TO_INBOX=1
|
||||
- ENABLE_FAIL2BAN=0
|
||||
- ENABLE_AMAVIS=0
|
||||
- SPOOF_PROTECTION=1
|
||||
- ENABLE_OPENDKIM=0
|
||||
- ENABLE_OPENDMARC=0
|
||||
# >>> Postfix LDAP Integration
|
||||
- ACCOUNT_PROVISIONER=LDAP
|
||||
- LDAP_SERVER_HOST=lldap:3890
|
||||
- LDAP_SEARCH_BASE=ou=people,dc=example,dc=com
|
||||
- LDAP_BIND_DN=uid=admin,ou=people,dc=example,dc=com
|
||||
- LDAP_BIND_PW=adminpassword
|
||||
- LDAP_QUERY_FILTER_USER=(&(objectClass=inetOrgPerson)(|(uid=%u)(mail=%u)))
|
||||
- LDAP_QUERY_FILTER_GROUP=(&(objectClass=groupOfUniqueNames)(uid=%s))
|
||||
- LDAP_QUERY_FILTER_ALIAS=(&(objectClass=inetOrgPerson)(|(uid=%u)(mail=%u)))
|
||||
- LDAP_QUERY_FILTER_DOMAIN=(mail=*@%s)
|
||||
# <<< Postfix LDAP Integration
|
||||
# >>> Dovecot LDAP Integration
|
||||
- DOVECOT_AUTH_BIND=yes
|
||||
- DOVECOT_USER_FILTER=(&(objectClass=inetOrgPerson)(|(uid=%u)(mail=%u)))
|
||||
- DOVECOT_USER_ATTRS==uid=5000,=gid=5000,=home=/var/mail/%Ln,=mail=maildir:~/Maildir
|
||||
- POSTMASTER_ADDRESS=postmaster@d3n.com
|
||||
cap_add:
|
||||
- SYS_PTRACE
|
||||
- NET_ADMIN # For Fail2Ban to work
|
||||
|
||||
roundcubemail:
|
||||
image: roundcube/roundcubemail:latest
|
||||
container_name: roundcubemail
|
||||
restart: always
|
||||
volumes:
|
||||
- roundcube_data:/var/www/html
|
||||
ports:
|
||||
- "9002:80"
|
||||
environment:
|
||||
- ROUNDCUBEMAIL_DB_TYPE=sqlite
|
||||
- ROUNDCUBEMAIL_SKIN=elastic
|
||||
- ROUNDCUBEMAIL_DEFAULT_HOST=mailserver # IMAP
|
||||
- ROUNDCUBEMAIL_SMTP_SERVER=mailserver # SMTP
|
||||
|
||||
volumes:
|
||||
mailserver-data:
|
||||
mailserver-config:
|
||||
mailserver-state:
|
||||
lldap_data:
|
||||
roundcube_data:
|
||||
|
||||
```
|
||||
15
example_configs/mastodon.env.example
Normal file
15
example_configs/mastodon.env.example
Normal file
@@ -0,0 +1,15 @@
|
||||
## ADD after values in the existing .env file.
|
||||
## This example uses the unsecured 3890 port. For ldaps, set LDAP_METHOD=simple_tls and LDAP_PORT=6360
|
||||
## For more details, see https://github.com/joylarkin/mastodon-documentation/blob/master/Running-Mastodon/Enabling-LDAP-login.md
|
||||
LDAP_ENABLED=true
|
||||
LDAP_METHOD=plain
|
||||
LDAP_HOST=lldap
|
||||
LDAP_PORT=3890
|
||||
LDAP_BASE=dc=domain,dc=com
|
||||
LDAP_BIND_DN=uid=admin,ou=people,dc=domain,dc=com
|
||||
LDAP_PASSWORD=<lldap_admin_password_here>
|
||||
LDAP_UID=uid
|
||||
LDAP_MAIL=mail
|
||||
LDAP_UID_CONVERSION_ENABLED=true
|
||||
# match username or mail to authenticate, and onlow allow users belonging to group 'mastodon'
|
||||
LDAP_SEARCH_FILTER=(&(memberof=cn=mastodon,ou=groups,dc=domain,dc=com)(|(%{uid}=%{email})(%{mail}=%{email})))
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
If you're here, there are some assumptions being made about access and capabilities you have on your system:
|
||||
1. You have Authelia up and running, understand its functionality, and have read through the documentation.
|
||||
2. You have [LLDAP](https://github.com/nitnelave/lldap) up and running.
|
||||
2. You have [LLDAP](https://github.com/lldap/lldap) up and running.
|
||||
3. You have Nextcloud and LLDAP communicating and without any config errors. See the [example config for Nextcloud](nextcloud.md)
|
||||
|
||||
## Authelia
|
||||
@@ -87,4 +87,4 @@ If this is set to *true* then the user flow will _skip_ the login page and autom
|
||||
### Conclusion
|
||||
And that's it! Assuming all the settings that worked for me, work for you, you should be able to login using OpenID Connect via Authelia. If you find any errors, it's a good idea to keep a document of all your settings from Authelia/Nextcloud/LLDAP etc so that you can easily reference and ensure everything lines up.
|
||||
|
||||
If you have any issues, please create a [discussion](https://github.com/nitnelave/lldap/discussions) or join the [Discord](https://discord.gg/h5PEdRMNyP).
|
||||
If you have any issues, please create a [discussion](https://github.com/lldap/lldap/discussions) or join the [Discord](https://discord.gg/h5PEdRMNyP).
|
||||
|
||||
20
example_configs/radicale.md
Normal file
20
example_configs/radicale.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Configuration of RADICALE authentification with lldap.
|
||||
|
||||
# Fork of the radicale LDAP plugin to work with LLDAP : https://github.com/shroomify-it/radicale-auth-ldap-plugin
|
||||
|
||||
# Full docker-compose stack : https://github.com/shroomify-it/docker-deploy_radicale-agendav-lldap
|
||||
|
||||
# Radicale config file v0.3 (inside docker container /etc/radicale/config https://radicale.org/v3.html#configuration)
|
||||
|
||||
```toml
|
||||
[auth]
|
||||
type = radicale_auth_ldap
|
||||
ldap_url = ldap://lldap:3890
|
||||
ldap_base = dc=example,dc=com
|
||||
ldap_attribute = uid
|
||||
ldap_filter = (objectClass=person)
|
||||
ldap_binddn = uid=admin,ou=people,dc=example,dc=com
|
||||
ldap_password = CHANGEME
|
||||
ldap_scope = LEVEL
|
||||
ldap_support_extended = no
|
||||
```
|
||||
@@ -1,7 +1,38 @@
|
||||
# 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.
|
||||
Seafile can be bridged to LLDAP directly, or by using Authelia as an intermediary. This document will guide you through both setups.
|
||||
|
||||
## Configuring Seafile v11.0+ to use LLDAP directly
|
||||
Starting Seafile v11.0 :
|
||||
- CCNET module doesn't exist anymore
|
||||
- More flexibility is given to authenticate in seafile : ID binding can now be different from user email, so LLDAP UID can be used.
|
||||
|
||||
Add the following to your `seafile/conf/seahub_settings.py` :
|
||||
```
|
||||
ENABLE_LDAP = True
|
||||
LDAP_SERVER_URL = 'ldap://192.168.1.100:3890'
|
||||
LDAP_BASE_DN = 'ou=people,dc=example,dc=com'
|
||||
LDAP_ADMIN_DN = 'uid=admin,ou=people,dc=example,dc=com'
|
||||
LDAP_ADMIN_PASSWORD = 'CHANGE_ME'
|
||||
LDAP_PROVIDER = 'ldap'
|
||||
LDAP_LOGIN_ATTR = 'uid'
|
||||
LDAP_CONTACT_EMAIL_ATTR = 'mail'
|
||||
LDAP_USER_ROLE_ATTR = ''
|
||||
LDAP_USER_FIRST_NAME_ATTR = 'givenName'
|
||||
LDAP_USER_LAST_NAME_ATTR = 'sn'
|
||||
LDAP_USER_NAME_REVERSE = False
|
||||
```
|
||||
|
||||
* 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 UID and password.
|
||||
|
||||
Note : There is currently no ldap binding for users' avatar. If interested, do [mention it](https://forum.seafile.com/t/feature-request-avatar-picture-from-ldap/3350/6) to the developers to give more credit to the feature.
|
||||
|
||||
## Configuring Seafile (prior to v11.0) to use LLDAP directly
|
||||
**Note for Seafile before v11:** Seafile's LDAP interface used to require a unique, immutable user identifier in the format of `username@domain`. This isn't true starting Seafile v11.0 and ulterior versions (see previous section Configuring Seafile v11.0+ §).
|
||||
For Seafile instances prior to v11, 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.
|
||||
|
||||
## Configuring Seafile to use LLDAP directly
|
||||
Add the following to your `seafile/conf/ccnet.conf` file:
|
||||
```
|
||||
[LDAP]
|
||||
@@ -86,4 +117,4 @@ OAUTH_ATTRIBUTE_MAP = {
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
16
example_configs/traccar.xml
Normal file
16
example_configs/traccar.xml
Normal file
@@ -0,0 +1,16 @@
|
||||
<!-- Append at the end of the <entry> sections in traccar.xml -->
|
||||
<entry key='ldap.enable'>true</entry>
|
||||
<!-- Important: the LDAP port must be specified in both ldap.url and ldap.port -->
|
||||
<entry key='ldap.url'>ldap://lldap:3890</entry>
|
||||
<entry key='ldap.port'>3890</entry>
|
||||
<entry key='ldap.user'>UID=admin,OU=people,DC=domain,DC=com</entry>
|
||||
<entry key='ldap.password'>BIND_USER_PASSWORD_HERE</entry>
|
||||
<entry key='ldap.force'>true</entry>
|
||||
<entry key='ldap.base'>OU=people,DC=domain,DC=com</entry>
|
||||
<entry key='ldap.idAttribute'>uid</entry>
|
||||
<entry key='ldap.nameAttribute'>cn</entry>
|
||||
<entry key='ldap.mailAttribute'>mail</entry>
|
||||
<!-- Only allow users belonging to group 'traccar' to login -->
|
||||
<entry key='ldap.searchFilter'>(&(|(uid=:login)(mail=:login))(memberOf=cn=traccar,ou=groups,dc=domain,dc=com))</entry>
|
||||
<!-- Make new users administrators if they belong to group 'lldap_admin' -->
|
||||
<entry key='ldap.adminFilter'>(&(|(uid=:login)(mail=:login))(memberOf=cn=lldap_admin,ou=groups,dc=domain,dc=com))</entry>
|
||||
@@ -49,7 +49,7 @@ mail
|
||||
```
|
||||
### Display Name Field Mapping
|
||||
```
|
||||
givenname
|
||||
cn
|
||||
```
|
||||
### Avatar Picture Field Mapping
|
||||
```
|
||||
|
||||
51
example_configs/zitadel.md
Normal file
51
example_configs/zitadel.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Configuration for Zitadel
|
||||
In Zitadel, go to `Instance > Settings` for instance-wide LDAP setup or `<Organization Name> > Settings` for organization-wide LDAP setup.
|
||||
|
||||
## Identity Providers Setup
|
||||
Click `Identity Providers` and select `Active Directory/LDAP`.
|
||||
|
||||
**Group filter is not supported in `Zitadel` at the time of writing.**
|
||||
|
||||
Replace every instance of `dc=example,dc=com` with your configured domain.
|
||||
### Connection
|
||||
* Name: The name to identify your identity provider
|
||||
* Servers: `ldaps://<FQDN or Host IP>:<Port for LADPS>` or `ldap://<FQDN or Host IP>:<Port for LADP>`
|
||||
* BaseDn: `dc=example,dc=com`
|
||||
* BindDn: `cn=admin,ou=people,dc=example,dc=com`. It is recommended that you create a separate user account (e.g, `bind_user`) instead of `admin` for sharing Bind credentials with other services. The `bind_user` should be a member of the `lldap_strict_readonly` group to limit access to your LDAP configuration in LLDAP.
|
||||
* Bind Password: `<user password>`
|
||||
|
||||
### User binding
|
||||
* Userbase: `dn`
|
||||
* User filters: `uid`. `mail` will not work.
|
||||
* User Object Classes: `person`
|
||||
|
||||
### LDAP Attributes
|
||||
* ID attribute: `uid`
|
||||
* displayName attribute: `cn`
|
||||
* Email attribute: `mail`
|
||||
* Given name attribute: `givenName`
|
||||
* Family name attribute: `lastName`
|
||||
* Preferred username attribute: `uid`
|
||||
|
||||
### optional
|
||||
The following section applied to `Zitadel` only, nothing will change on `LLDAP` side.
|
||||
|
||||
* Account creation allowed [x]
|
||||
* Account linking allowed [x]
|
||||
|
||||
**Either one of them or both of them must be enabled**
|
||||
|
||||
**DO NOT** enable `Automatic update` if you haven't setup a smtp server. Zitadel will update account's email and sent a verification code to verify the address.
|
||||
If you don't have a smtp server setup correctly and the email adress of `ZITADEL Admin` is changed, you are **permanently** locked out.
|
||||
|
||||
`Automatic creation` can automatically create a new account without user interaction when `Given name attribute`, `Family name attribute`, `Email attribute`, and `Preferred username attribute` are presented.
|
||||
|
||||
## Enable Identity Provider
|
||||
After clicking `Save`, you will be redirected to `Identity Providers` page.
|
||||
|
||||
Enable the LDAP by hovering onto the item and clicking the checkmark (`set as available`)
|
||||
|
||||
## Enable LDAP Login
|
||||
Under `Settings`, select `Login Behavior and Security`
|
||||
|
||||
Under `Advanced`, enable `External IDP allowed`
|
||||
12
generate_secrets.sh
Executable file
12
generate_secrets.sh
Executable file
@@ -0,0 +1,12 @@
|
||||
#! /bin/sh
|
||||
|
||||
function print_random () {
|
||||
LC_ALL=C tr -dc 'A-Za-z0-9!#%&()*+,-./:;<=>?@[\]^_{|}~' </dev/urandom | head -c 32
|
||||
}
|
||||
|
||||
/bin/echo -n "LLDAP_JWT_SECRET='"
|
||||
print_random
|
||||
echo "'"
|
||||
/bin/echo -n "LLDAP_KEY_SEED='"
|
||||
print_random
|
||||
echo "'"
|
||||
@@ -10,7 +10,9 @@
|
||||
## The host address that the LDAP server will be bound to.
|
||||
## To enable IPv6 support, simply switch "ldap_host" to "::":
|
||||
## To only allow connections from localhost (if you want to restrict to local self-hosted services),
|
||||
## change it to "127.0.0.1" ("::1" in case of IPv6)".
|
||||
## change it to "127.0.0.1" ("::1" in case of IPv6).
|
||||
## If LLDAP server is running in docker, set it to "0.0.0.0" ("::" for IPv6) to allow connections
|
||||
## originating from outside the container.
|
||||
#ldap_host = "0.0.0.0"
|
||||
|
||||
## The port on which to have the LDAP server.
|
||||
@@ -19,7 +21,9 @@
|
||||
## The host address that the HTTP server will be bound to.
|
||||
## To enable IPv6 support, simply switch "http_host" to "::".
|
||||
## To only allow connections from localhost (if you want to restrict to local self-hosted services),
|
||||
## change it to "127.0.0.1" ("::1" in case of IPv6)".
|
||||
## change it to "127.0.0.1" ("::1" in case of IPv6).
|
||||
## If LLDAP server is running in docker, set it to "0.0.0.0" ("::" for IPv6) to allow connections
|
||||
## originating from outside the container.
|
||||
#http_host = "0.0.0.0"
|
||||
|
||||
## The port on which to have the HTTP server, for user login and
|
||||
@@ -74,6 +78,12 @@
|
||||
## is just the default one.
|
||||
#ldap_user_pass = "REPLACE_WITH_PASSWORD"
|
||||
|
||||
## Force reset of the admin password.
|
||||
## Break glass in case of emergency: if you lost the admin password, you
|
||||
## can set this to true to force a reset of the admin password to the value
|
||||
## of ldap_user_pass above.
|
||||
# force_reset_admin_password = false
|
||||
|
||||
## Database URL.
|
||||
## This encodes the type of database (SQlite, MySQL, or PostgreSQL)
|
||||
## , the path, the user, password, and sometimes the mode (when
|
||||
@@ -88,21 +98,20 @@
|
||||
database_url = "sqlite:///data/users.db?mode=rwc"
|
||||
|
||||
## Private key file.
|
||||
## Not recommended, use key_seed instead.
|
||||
## Contains the secret private key used to store the passwords safely.
|
||||
## Note that even with a database dump and the private key, an attacker
|
||||
## would still have to perform an (expensive) brute force attack to find
|
||||
## each password.
|
||||
## Randomly generated on first run if it doesn't exist.
|
||||
## Alternatively, you can use key_seed to override this instead of relying on
|
||||
## a file.
|
||||
## Env variable: LLDAP_KEY_FILE
|
||||
key_file = "/data/private_key"
|
||||
#key_file = "/data/private_key"
|
||||
|
||||
## Seed to generate the server private key, see key_file above.
|
||||
## This can be any random string, the recommendation is that it's at least 12
|
||||
## characters long.
|
||||
## Env variable: LLDAP_KEY_SEED
|
||||
#key_seed = "RanD0m STR1ng"
|
||||
key_seed = "RanD0m STR1ng"
|
||||
|
||||
## Ignored attributes.
|
||||
## Some services will request attributes that are not present in LLDAP. When it
|
||||
|
||||
@@ -194,6 +194,7 @@ impl TryFrom<ResultEntry> for User {
|
||||
first_name,
|
||||
last_name,
|
||||
avatar: avatar.map(base64::encode),
|
||||
attributes: None,
|
||||
},
|
||||
password,
|
||||
entry.dn,
|
||||
|
||||
@@ -136,7 +136,7 @@ fn try_login(
|
||||
let ClientLoginStartResult { state, message } =
|
||||
start_login(password, &mut rng).context("Could not initialize login")?;
|
||||
let req = ClientLoginStartRequest {
|
||||
username: username.to_owned(),
|
||||
username: username.into(),
|
||||
login_start_request: message,
|
||||
};
|
||||
let response = client
|
||||
|
||||
122
schema.graphql
generated
122
schema.graphql
generated
@@ -1,22 +1,23 @@
|
||||
type AttributeValue {
|
||||
name: String!
|
||||
value: [String!]!
|
||||
}
|
||||
|
||||
input EqualityConstraint {
|
||||
field: String!
|
||||
value: String!
|
||||
schema: AttributeSchema!
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
createUser(user: CreateUserInput!): User!
|
||||
createGroup(name: String!): Group!
|
||||
createGroupWithDetails(request: CreateGroupInput!): Group!
|
||||
updateUser(user: UpdateUserInput!): Success!
|
||||
updateGroup(group: UpdateGroupInput!): Success!
|
||||
addUserToGroup(userId: String!, groupId: Int!): Success!
|
||||
removeUserFromGroup(userId: String!, groupId: Int!): Success!
|
||||
deleteUser(userId: String!): Success!
|
||||
deleteGroup(groupId: Int!): Success!
|
||||
addUserAttribute(name: String!, attributeType: AttributeType!, isList: Boolean!, isVisible: Boolean!, isEditable: Boolean!): Success!
|
||||
addGroupAttribute(name: String!, attributeType: AttributeType!, isList: Boolean!, isVisible: Boolean!, isEditable: Boolean!): Success!
|
||||
deleteUserAttribute(name: String!): Success!
|
||||
deleteGroupAttribute(name: String!): Success!
|
||||
}
|
||||
|
||||
type Group {
|
||||
@@ -46,17 +47,6 @@ input RequestFilter {
|
||||
"DateTime"
|
||||
scalar DateTimeUtc
|
||||
|
||||
type Schema {
|
||||
userSchema: AttributeList!
|
||||
groupSchema: AttributeList!
|
||||
}
|
||||
|
||||
"The fields that can be updated for a group."
|
||||
input UpdateGroupInput {
|
||||
id: Int!
|
||||
displayName: String
|
||||
}
|
||||
|
||||
type Query {
|
||||
apiVersion: String!
|
||||
user(userId: String!): User!
|
||||
@@ -73,7 +63,79 @@ input CreateUserInput {
|
||||
displayName: String
|
||||
firstName: String
|
||||
lastName: String
|
||||
avatar: String
|
||||
"Base64 encoded JpegPhoto." avatar: String
|
||||
"User-defined attributes." attributes: [AttributeValueInput!]
|
||||
}
|
||||
|
||||
type AttributeSchema {
|
||||
name: String!
|
||||
attributeType: AttributeType!
|
||||
isList: Boolean!
|
||||
isVisible: Boolean!
|
||||
isEditable: Boolean!
|
||||
isHardcoded: Boolean!
|
||||
}
|
||||
|
||||
"The fields that can be updated for a user."
|
||||
input UpdateUserInput {
|
||||
id: String!
|
||||
email: String
|
||||
displayName: String
|
||||
firstName: String
|
||||
lastName: String
|
||||
"Base64 encoded JpegPhoto." avatar: String
|
||||
"""
|
||||
Attribute names to remove.
|
||||
They are processed before insertions.
|
||||
""" removeAttributes: [String!]
|
||||
"""
|
||||
Inserts or updates the given attributes.
|
||||
For lists, the entire list must be provided.
|
||||
""" insertAttributes: [AttributeValueInput!]
|
||||
}
|
||||
|
||||
input EqualityConstraint {
|
||||
field: String!
|
||||
value: String!
|
||||
}
|
||||
|
||||
type Schema {
|
||||
userSchema: AttributeList!
|
||||
groupSchema: AttributeList!
|
||||
}
|
||||
|
||||
"The fields that can be updated for a group."
|
||||
input UpdateGroupInput {
|
||||
"The group ID." id: Int!
|
||||
"The new display name." displayName: String
|
||||
"""
|
||||
Attribute names to remove.
|
||||
They are processed before insertions.
|
||||
""" removeAttributes: [String!]
|
||||
"""
|
||||
Inserts or updates the given attributes.
|
||||
For lists, the entire list must be provided.
|
||||
""" insertAttributes: [AttributeValueInput!]
|
||||
}
|
||||
|
||||
input AttributeValueInput {
|
||||
"""
|
||||
The name of the attribute. It must be present in the schema, and the type informs how
|
||||
to interpret the values.
|
||||
""" name: String!
|
||||
"""
|
||||
The values of the attribute.
|
||||
If the attribute is not a list, the vector must contain exactly one element.
|
||||
Integers (signed 64 bits) are represented as strings.
|
||||
Dates are represented as strings in RFC3339 format, e.g. "2019-10-12T07:20:50.52Z".
|
||||
JpegPhotos are represented as base64 encoded strings. They must be valid JPEGs.
|
||||
""" value: [String!]!
|
||||
}
|
||||
|
||||
"The details required to create a group."
|
||||
input CreateGroupInput {
|
||||
displayName: String!
|
||||
"User-defined attributes." attributes: [AttributeValueInput!]
|
||||
}
|
||||
|
||||
type User {
|
||||
@@ -91,33 +153,21 @@ type User {
|
||||
groups: [Group!]!
|
||||
}
|
||||
|
||||
type AttributeList {
|
||||
attributes: [AttributeSchema!]!
|
||||
enum AttributeType {
|
||||
STRING
|
||||
INTEGER
|
||||
JPEG_PHOTO
|
||||
DATE_TIME
|
||||
}
|
||||
|
||||
type AttributeSchema {
|
||||
name: String!
|
||||
attributeType: String!
|
||||
isList: Boolean!
|
||||
isVisible: Boolean!
|
||||
isEditable: Boolean!
|
||||
isHardcoded: Boolean!
|
||||
type AttributeList {
|
||||
attributes: [AttributeSchema!]!
|
||||
}
|
||||
|
||||
type Success {
|
||||
ok: Boolean!
|
||||
}
|
||||
|
||||
"The fields that can be updated for a user."
|
||||
input UpdateUserInput {
|
||||
id: String!
|
||||
email: String
|
||||
displayName: String
|
||||
firstName: String
|
||||
lastName: String
|
||||
avatar: String
|
||||
}
|
||||
|
||||
schema {
|
||||
query: Query
|
||||
mutation: Mutation
|
||||
|
||||
@@ -8,7 +8,7 @@ keywords = ["cli", "ldap", "graphql", "server", "authentication"]
|
||||
license = "GPL-3.0-only"
|
||||
name = "lldap"
|
||||
repository = "https://github.com/lldap/lldap"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1-alpha"
|
||||
|
||||
[dependencies]
|
||||
actix = "0.13"
|
||||
@@ -25,6 +25,7 @@ base64 = "0.21"
|
||||
bincode = "1.3"
|
||||
cron = "*"
|
||||
derive_builder = "0.12"
|
||||
derive_more = "0.99"
|
||||
figment_file_provider_adapter = "0.1"
|
||||
futures = "*"
|
||||
futures-util = "*"
|
||||
@@ -34,7 +35,7 @@ itertools = "0.10"
|
||||
juniper = "0.15"
|
||||
jwt = "0.16"
|
||||
lber = "0.4.1"
|
||||
ldap3_proto = "^0.4"
|
||||
ldap3_proto = "^0.4.3"
|
||||
log = "*"
|
||||
orion = "0.17"
|
||||
rand_chacha = "0.3"
|
||||
@@ -53,7 +54,7 @@ tracing-actix-web = "0.7"
|
||||
tracing-attributes = "^0.1.21"
|
||||
tracing-log = "*"
|
||||
urlencoding = "2"
|
||||
webpki-roots = "*"
|
||||
webpki-roots = "0.22.2"
|
||||
|
||||
[dependencies.chrono]
|
||||
features = ["serde"]
|
||||
@@ -78,6 +79,7 @@ version = "0.10.1"
|
||||
|
||||
[dependencies.lldap_auth]
|
||||
path = "../auth"
|
||||
features = ["opaque_server", "opaque_client", "sea_orm"]
|
||||
|
||||
[dependencies.opaque-ke]
|
||||
version = "0.6"
|
||||
@@ -162,3 +164,7 @@ features = ["file_locks"]
|
||||
[dev-dependencies.uuid]
|
||||
version = "1"
|
||||
features = ["v4"]
|
||||
|
||||
[dev-dependencies.figment]
|
||||
features = ["test"]
|
||||
version = "*"
|
||||
|
||||
50
server/src/domain/deserialize.rs
Normal file
50
server/src/domain/deserialize.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
use crate::domain::types::{AttributeType, JpegPhoto, Serialized};
|
||||
use anyhow::{bail, Context as AnyhowContext};
|
||||
|
||||
pub fn deserialize_attribute_value(
|
||||
value: &[String],
|
||||
typ: AttributeType,
|
||||
is_list: bool,
|
||||
) -> anyhow::Result<Serialized> {
|
||||
if !is_list && value.len() != 1 {
|
||||
bail!("Attribute is not a list, but multiple values were provided",);
|
||||
}
|
||||
let parse_int = |value: &String| -> anyhow::Result<i64> {
|
||||
value
|
||||
.parse::<i64>()
|
||||
.with_context(|| format!("Invalid integer value {}", value))
|
||||
};
|
||||
let parse_date = |value: &String| -> anyhow::Result<chrono::NaiveDateTime> {
|
||||
Ok(chrono::DateTime::parse_from_rfc3339(value)
|
||||
.with_context(|| format!("Invalid date value {}", value))?
|
||||
.naive_utc())
|
||||
};
|
||||
let parse_photo = |value: &String| -> anyhow::Result<JpegPhoto> {
|
||||
JpegPhoto::try_from(value.as_str()).context("Provided image is not a valid JPEG")
|
||||
};
|
||||
Ok(match (typ, is_list) {
|
||||
(AttributeType::String, false) => Serialized::from(&value[0]),
|
||||
(AttributeType::String, true) => Serialized::from(&value),
|
||||
(AttributeType::Integer, false) => Serialized::from(&parse_int(&value[0])?),
|
||||
(AttributeType::Integer, true) => Serialized::from(
|
||||
&value
|
||||
.iter()
|
||||
.map(parse_int)
|
||||
.collect::<anyhow::Result<Vec<_>>>()?,
|
||||
),
|
||||
(AttributeType::DateTime, false) => Serialized::from(&parse_date(&value[0])?),
|
||||
(AttributeType::DateTime, true) => Serialized::from(
|
||||
&value
|
||||
.iter()
|
||||
.map(parse_date)
|
||||
.collect::<anyhow::Result<Vec<_>>>()?,
|
||||
),
|
||||
(AttributeType::JpegPhoto, false) => Serialized::from(&parse_photo(&value[0])?),
|
||||
(AttributeType::JpegPhoto, true) => Serialized::from(
|
||||
&value
|
||||
.iter()
|
||||
.map(parse_photo)
|
||||
.collect::<anyhow::Result<Vec<_>>>()?,
|
||||
),
|
||||
})
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
use crate::domain::{
|
||||
error::Result,
|
||||
types::{
|
||||
AttributeType, Group, GroupDetails, GroupId, JpegPhoto, User, UserAndGroups, UserColumn,
|
||||
UserId, Uuid,
|
||||
AttributeName, AttributeType, AttributeValue, Email, Group, GroupDetails, GroupId,
|
||||
GroupName, JpegPhoto, Serialized, User, UserAndGroups, UserColumn, UserId, Uuid,
|
||||
},
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
@@ -54,10 +54,10 @@ pub enum UserRequestFilter {
|
||||
UserId(UserId),
|
||||
UserIdSubString(SubStringFilter),
|
||||
Equality(UserColumn, String),
|
||||
AttributeEquality(String, String),
|
||||
AttributeEquality(AttributeName, Serialized),
|
||||
SubString(UserColumn, SubStringFilter),
|
||||
// Check if a user belongs to a group identified by name.
|
||||
MemberOf(String),
|
||||
MemberOf(GroupName),
|
||||
// Same, by id.
|
||||
MemberOfId(GroupId),
|
||||
}
|
||||
@@ -77,12 +77,13 @@ pub enum GroupRequestFilter {
|
||||
And(Vec<GroupRequestFilter>),
|
||||
Or(Vec<GroupRequestFilter>),
|
||||
Not(Box<GroupRequestFilter>),
|
||||
DisplayName(String),
|
||||
DisplayName(GroupName),
|
||||
DisplayNameSubString(SubStringFilter),
|
||||
Uuid(Uuid),
|
||||
GroupId(GroupId),
|
||||
// Check if the group contains a user identified by uid.
|
||||
Member(UserId),
|
||||
AttributeEquality(AttributeName, Serialized),
|
||||
}
|
||||
|
||||
impl From<bool> for GroupRequestFilter {
|
||||
@@ -99,33 +100,44 @@ impl From<bool> for GroupRequestFilter {
|
||||
pub struct CreateUserRequest {
|
||||
// Same fields as User, but no creation_date, and with password.
|
||||
pub user_id: UserId,
|
||||
pub email: String,
|
||||
pub email: Email,
|
||||
pub display_name: Option<String>,
|
||||
pub first_name: Option<String>,
|
||||
pub last_name: Option<String>,
|
||||
pub avatar: Option<JpegPhoto>,
|
||||
pub attributes: Vec<AttributeValue>,
|
||||
}
|
||||
|
||||
#[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: UserId,
|
||||
pub email: Option<String>,
|
||||
pub email: Option<Email>,
|
||||
pub display_name: Option<String>,
|
||||
pub first_name: Option<String>,
|
||||
pub last_name: Option<String>,
|
||||
pub avatar: Option<JpegPhoto>,
|
||||
pub delete_attributes: Vec<AttributeName>,
|
||||
pub insert_attributes: Vec<AttributeValue>,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone, Default)]
|
||||
pub struct CreateGroupRequest {
|
||||
pub display_name: GroupName,
|
||||
pub attributes: Vec<AttributeValue>,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct UpdateGroupRequest {
|
||||
pub group_id: GroupId,
|
||||
pub display_name: Option<String>,
|
||||
pub display_name: Option<GroupName>,
|
||||
pub delete_attributes: Vec<AttributeName>,
|
||||
pub insert_attributes: Vec<AttributeValue>,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct AttributeSchema {
|
||||
pub name: String,
|
||||
pub name: AttributeName,
|
||||
//TODO: pub aliases: Vec<String>,
|
||||
pub attribute_type: AttributeType,
|
||||
pub is_list: bool,
|
||||
@@ -134,16 +146,27 @@ pub struct AttributeSchema {
|
||||
pub is_hardcoded: bool,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct CreateAttributeRequest {
|
||||
pub name: AttributeName,
|
||||
pub attribute_type: AttributeType,
|
||||
pub is_list: bool,
|
||||
pub is_visible: bool,
|
||||
pub is_editable: bool,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct AttributeList {
|
||||
pub attributes: Vec<AttributeSchema>,
|
||||
}
|
||||
|
||||
impl AttributeList {
|
||||
pub fn get_attribute_type(&self, name: &str) -> Option<(AttributeType, bool)> {
|
||||
self.attributes
|
||||
.iter()
|
||||
.find(|a| a.name == name)
|
||||
pub fn get_attribute_schema(&self, name: &AttributeName) -> Option<&AttributeSchema> {
|
||||
self.attributes.iter().find(|a| a.name == *name)
|
||||
}
|
||||
|
||||
pub fn get_attribute_type(&self, name: &AttributeName) -> Option<(AttributeType, bool)> {
|
||||
self.get_attribute_schema(name)
|
||||
.map(|a| (a.attribute_type, a.is_list))
|
||||
}
|
||||
}
|
||||
@@ -160,20 +183,20 @@ pub trait LoginHandler: Send + Sync {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait GroupListerBackendHandler: SchemaBackendHandler {
|
||||
pub trait GroupListerBackendHandler: ReadSchemaBackendHandler {
|
||||
async fn list_groups(&self, filters: Option<GroupRequestFilter>) -> Result<Vec<Group>>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait GroupBackendHandler: SchemaBackendHandler {
|
||||
pub trait GroupBackendHandler: ReadSchemaBackendHandler {
|
||||
async fn get_group_details(&self, group_id: GroupId) -> Result<GroupDetails>;
|
||||
async fn update_group(&self, request: UpdateGroupRequest) -> Result<()>;
|
||||
async fn create_group(&self, group_name: &str) -> Result<GroupId>;
|
||||
async fn create_group(&self, request: CreateGroupRequest) -> Result<GroupId>;
|
||||
async fn delete_group(&self, group_id: GroupId) -> Result<()>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait UserListerBackendHandler: SchemaBackendHandler {
|
||||
pub trait UserListerBackendHandler: ReadSchemaBackendHandler {
|
||||
async fn list_users(
|
||||
&self,
|
||||
filters: Option<UserRequestFilter>,
|
||||
@@ -182,7 +205,7 @@ pub trait UserListerBackendHandler: SchemaBackendHandler {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait UserBackendHandler: SchemaBackendHandler {
|
||||
pub trait UserBackendHandler: ReadSchemaBackendHandler {
|
||||
async fn get_user_details(&self, user_id: &UserId) -> Result<User>;
|
||||
async fn create_user(&self, request: CreateUserRequest) -> Result<()>;
|
||||
async fn update_user(&self, request: UpdateUserRequest) -> Result<()>;
|
||||
@@ -193,10 +216,19 @@ pub trait UserBackendHandler: SchemaBackendHandler {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait SchemaBackendHandler {
|
||||
pub trait ReadSchemaBackendHandler {
|
||||
async fn get_schema(&self) -> Result<Schema>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait SchemaBackendHandler: ReadSchemaBackendHandler {
|
||||
async fn add_user_attribute(&self, request: CreateAttributeRequest) -> Result<()>;
|
||||
async fn add_group_attribute(&self, request: CreateAttributeRequest) -> Result<()>;
|
||||
// Note: It's up to the caller to make sure that the attribute is not hardcoded.
|
||||
async fn delete_user_attribute(&self, name: &AttributeName) -> Result<()>;
|
||||
async fn delete_group_attribute(&self, name: &AttributeName) -> Result<()>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait BackendHandler:
|
||||
Send
|
||||
@@ -205,6 +237,7 @@ pub trait BackendHandler:
|
||||
+ UserBackendHandler
|
||||
+ UserListerBackendHandler
|
||||
+ GroupListerBackendHandler
|
||||
+ ReadSchemaBackendHandler
|
||||
+ SchemaBackendHandler
|
||||
{
|
||||
}
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
use chrono::TimeZone;
|
||||
use ldap3_proto::{
|
||||
proto::LdapOp, LdapFilter, LdapPartialAttribute, LdapResultCode, LdapSearchResultEntry,
|
||||
};
|
||||
use tracing::{debug, instrument, warn};
|
||||
|
||||
use crate::domain::{
|
||||
deserialize::deserialize_attribute_value,
|
||||
handler::{GroupListerBackendHandler, GroupRequestFilter},
|
||||
ldap::error::LdapError,
|
||||
types::{Group, UserId, Uuid},
|
||||
schema::{PublicSchema, SchemaGroupAttributeExtractor},
|
||||
types::{AttributeName, AttributeType, Group, UserId, Uuid},
|
||||
};
|
||||
|
||||
use super::{
|
||||
error::LdapResult,
|
||||
utils::{
|
||||
expand_attribute_wildcards, get_group_id_from_distinguished_name,
|
||||
get_user_id_from_distinguished_name, map_group_field, LdapInfo,
|
||||
expand_attribute_wildcards, get_custom_attribute, get_group_id_from_distinguished_name,
|
||||
get_user_id_from_distinguished_name, map_group_field, GroupFieldType, LdapInfo,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -22,40 +25,57 @@ pub fn get_group_attribute(
|
||||
base_dn_str: &str,
|
||||
attribute: &str,
|
||||
user_filter: &Option<UserId>,
|
||||
ignored_group_attributes: &[String],
|
||||
ignored_group_attributes: &[AttributeName],
|
||||
schema: &PublicSchema,
|
||||
) -> Option<Vec<Vec<u8>>> {
|
||||
let attribute = attribute.to_ascii_lowercase();
|
||||
let attribute_values = match attribute.as_str() {
|
||||
"objectclass" => vec![b"groupOfUniqueNames".to_vec()],
|
||||
let attribute = AttributeName::from(attribute);
|
||||
let attribute_values = match map_group_field(&attribute, schema) {
|
||||
GroupFieldType::ObjectClass => vec![b"groupOfUniqueNames".to_vec()],
|
||||
// Always returned as part of the base response.
|
||||
"dn" | "distinguishedname" => return None,
|
||||
"cn" | "uid" | "id" => vec![group.display_name.clone().into_bytes()],
|
||||
"entryuuid" | "uuid" => vec![group.uuid.to_string().into_bytes()],
|
||||
"member" | "uniquemember" => group
|
||||
GroupFieldType::Dn => return None,
|
||||
GroupFieldType::EntryDn => {
|
||||
vec![format!("uid={},ou=groups,{}", group.display_name, base_dn_str).into_bytes()]
|
||||
}
|
||||
GroupFieldType::DisplayName => vec![group.display_name.to_string().into_bytes()],
|
||||
GroupFieldType::CreationDate => vec![chrono::Utc
|
||||
.from_utc_datetime(&group.creation_date)
|
||||
.to_rfc3339()
|
||||
.into_bytes()],
|
||||
GroupFieldType::Member => group
|
||||
.users
|
||||
.iter()
|
||||
.filter(|u| user_filter.as_ref().map(|f| *u == f).unwrap_or(true))
|
||||
.map(|u| format!("uid={},ou=people,{}", u, base_dn_str).into_bytes())
|
||||
.collect(),
|
||||
"1.1" => return None,
|
||||
// We ignore the operational attribute wildcard
|
||||
"+" => return None,
|
||||
"*" => {
|
||||
panic!(
|
||||
"Matched {}, * should have been expanded into attribute list and * removed",
|
||||
attribute
|
||||
)
|
||||
GroupFieldType::Uuid => vec![group.uuid.to_string().into_bytes()],
|
||||
GroupFieldType::Attribute(attr, _, _) => {
|
||||
get_custom_attribute::<SchemaGroupAttributeExtractor>(&group.attributes, &attr, schema)?
|
||||
}
|
||||
_ => {
|
||||
if !ignored_group_attributes.contains(&attribute) {
|
||||
warn!(
|
||||
r#"Ignoring unrecognized group attribute: {}\n\
|
||||
To disable this warning, add it to "ignored_group_attributes" in the config."#,
|
||||
GroupFieldType::NoMatch => match attribute.as_str() {
|
||||
"1.1" => return None,
|
||||
// We ignore the operational attribute wildcard
|
||||
"+" => return None,
|
||||
"*" => {
|
||||
panic!(
|
||||
"Matched {}, * should have been expanded into attribute list and * removed",
|
||||
attribute
|
||||
);
|
||||
)
|
||||
}
|
||||
return None;
|
||||
}
|
||||
_ => {
|
||||
if ignored_group_attributes.contains(&attribute) {
|
||||
return None;
|
||||
}
|
||||
get_custom_attribute::<SchemaGroupAttributeExtractor>(
|
||||
&group.attributes,
|
||||
&attribute,
|
||||
schema,
|
||||
).or_else(||{warn!(
|
||||
r#"Ignoring unrecognized group attribute: {}\n\
|
||||
To disable this warning, add it to "ignored_group_attributes" in the config."#,
|
||||
attribute
|
||||
);None})?
|
||||
}
|
||||
},
|
||||
};
|
||||
if attribute_values.len() == 1 && attribute_values[0].is_empty() {
|
||||
None
|
||||
@@ -80,12 +100,11 @@ fn expand_group_attribute_wildcards(attributes: &[String]) -> Vec<&str> {
|
||||
fn make_ldap_search_group_result_entry(
|
||||
group: Group,
|
||||
base_dn_str: &str,
|
||||
attributes: &[String],
|
||||
expanded_attributes: &[&str],
|
||||
user_filter: &Option<UserId>,
|
||||
ignored_group_attributes: &[String],
|
||||
ignored_group_attributes: &[AttributeName],
|
||||
schema: &PublicSchema,
|
||||
) -> LdapSearchResultEntry {
|
||||
let expanded_attributes = expand_group_attribute_wildcards(attributes);
|
||||
|
||||
LdapSearchResultEntry {
|
||||
dn: format!("cn={},ou=groups,{}", group.display_name, base_dn_str),
|
||||
attributes: expanded_attributes
|
||||
@@ -97,6 +116,7 @@ fn make_ldap_search_group_result_entry(
|
||||
a,
|
||||
user_filter,
|
||||
ignored_group_attributes,
|
||||
schema,
|
||||
)?;
|
||||
Some(LdapPartialAttribute {
|
||||
atype: a.to_string(),
|
||||
@@ -107,57 +127,79 @@ fn make_ldap_search_group_result_entry(
|
||||
}
|
||||
}
|
||||
|
||||
fn get_group_attribute_equality_filter(
|
||||
field: &AttributeName,
|
||||
typ: AttributeType,
|
||||
is_list: bool,
|
||||
value: &str,
|
||||
) -> LdapResult<GroupRequestFilter> {
|
||||
deserialize_attribute_value(&[value.to_owned()], typ, is_list)
|
||||
.map_err(|e| LdapError {
|
||||
code: LdapResultCode::Other,
|
||||
message: format!("Invalid value for attribute {}: {}", field, e),
|
||||
})
|
||||
.map(|v| GroupRequestFilter::AttributeEquality(field.clone(), v))
|
||||
}
|
||||
|
||||
fn convert_group_filter(
|
||||
ldap_info: &LdapInfo,
|
||||
filter: &LdapFilter,
|
||||
schema: &PublicSchema,
|
||||
) -> LdapResult<GroupRequestFilter> {
|
||||
let rec = |f| convert_group_filter(ldap_info, f);
|
||||
let rec = |f| convert_group_filter(ldap_info, f, schema);
|
||||
match filter {
|
||||
LdapFilter::Equality(field, value) => {
|
||||
let field = &field.to_ascii_lowercase();
|
||||
let value = &value.to_ascii_lowercase();
|
||||
match field.as_str() {
|
||||
"member" | "uniquemember" => {
|
||||
let field = AttributeName::from(field.as_str());
|
||||
let value = value.to_ascii_lowercase();
|
||||
match map_group_field(&field, schema) {
|
||||
GroupFieldType::DisplayName => Ok(GroupRequestFilter::DisplayName(value.into())),
|
||||
GroupFieldType::Uuid => Ok(GroupRequestFilter::Uuid(
|
||||
Uuid::try_from(value.as_str()).map_err(|e| LdapError {
|
||||
code: LdapResultCode::InappropriateMatching,
|
||||
message: format!("Invalid UUID: {:#}", e),
|
||||
})?,
|
||||
)),
|
||||
GroupFieldType::Member => {
|
||||
let user_name = get_user_id_from_distinguished_name(
|
||||
value,
|
||||
&value,
|
||||
&ldap_info.base_dn,
|
||||
&ldap_info.base_dn_str,
|
||||
)?;
|
||||
Ok(GroupRequestFilter::Member(user_name))
|
||||
}
|
||||
"objectclass" => Ok(GroupRequestFilter::from(matches!(
|
||||
GroupFieldType::ObjectClass => Ok(GroupRequestFilter::from(matches!(
|
||||
value.as_str(),
|
||||
"groupofuniquenames" | "groupofnames"
|
||||
))),
|
||||
"dn" => Ok(get_group_id_from_distinguished_name(
|
||||
value.to_ascii_lowercase().as_str(),
|
||||
&ldap_info.base_dn,
|
||||
&ldap_info.base_dn_str,
|
||||
)
|
||||
.map(GroupRequestFilter::DisplayName)
|
||||
.unwrap_or_else(|_| {
|
||||
warn!("Invalid dn filter on group: {}", value);
|
||||
GroupRequestFilter::from(false)
|
||||
})),
|
||||
_ => match map_group_field(field) {
|
||||
Some("display_name") => Ok(GroupRequestFilter::DisplayName(value.to_string())),
|
||||
Some("uuid") => Ok(GroupRequestFilter::Uuid(
|
||||
Uuid::try_from(value.as_str()).map_err(|e| LdapError {
|
||||
code: LdapResultCode::InappropriateMatching,
|
||||
message: format!("Invalid UUID: {:#}", e),
|
||||
})?,
|
||||
)),
|
||||
_ => {
|
||||
if !ldap_info.ignored_group_attributes.contains(field) {
|
||||
warn!(
|
||||
r#"Ignoring unknown group attribute "{:?}" in filter.\n\
|
||||
GroupFieldType::Dn | GroupFieldType::EntryDn => {
|
||||
Ok(get_group_id_from_distinguished_name(
|
||||
value.as_str(),
|
||||
&ldap_info.base_dn,
|
||||
&ldap_info.base_dn_str,
|
||||
)
|
||||
.map(GroupRequestFilter::DisplayName)
|
||||
.unwrap_or_else(|_| {
|
||||
warn!("Invalid dn filter on group: {}", value);
|
||||
GroupRequestFilter::from(false)
|
||||
}))
|
||||
}
|
||||
GroupFieldType::NoMatch => {
|
||||
if !ldap_info.ignored_group_attributes.contains(&field) {
|
||||
warn!(
|
||||
r#"Ignoring unknown group attribute "{}" in filter.\n\
|
||||
To disable this warning, add it to "ignored_group_attributes" in the config."#,
|
||||
field
|
||||
);
|
||||
}
|
||||
Ok(GroupRequestFilter::from(false))
|
||||
field
|
||||
);
|
||||
}
|
||||
},
|
||||
Ok(GroupRequestFilter::from(false))
|
||||
}
|
||||
GroupFieldType::Attribute(field, typ, is_list) => {
|
||||
get_group_attribute_equality_filter(&field, typ, is_list, &value)
|
||||
}
|
||||
GroupFieldType::CreationDate => Err(LdapError {
|
||||
code: LdapResultCode::UnwillingToPerform,
|
||||
message: "Creation date filter for groups not supported".to_owned(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
LdapFilter::And(filters) => Ok(GroupRequestFilter::And(
|
||||
@@ -168,24 +210,23 @@ fn convert_group_filter(
|
||||
)),
|
||||
LdapFilter::Not(filter) => Ok(GroupRequestFilter::Not(Box::new(rec(filter)?))),
|
||||
LdapFilter::Present(field) => {
|
||||
let field = &field.to_ascii_lowercase();
|
||||
Ok(GroupRequestFilter::from(
|
||||
field == "objectclass"
|
||||
|| field == "dn"
|
||||
|| field == "distinguishedname"
|
||||
|| map_group_field(field).is_some(),
|
||||
))
|
||||
let field = AttributeName::from(field.as_str());
|
||||
Ok(GroupRequestFilter::from(!matches!(
|
||||
map_group_field(&field, schema),
|
||||
GroupFieldType::NoMatch
|
||||
)))
|
||||
}
|
||||
LdapFilter::Substring(field, substring_filter) => {
|
||||
let field = &field.to_ascii_lowercase();
|
||||
match map_group_field(field.as_str()) {
|
||||
Some("display_name") => Ok(GroupRequestFilter::DisplayNameSubString(
|
||||
let field = AttributeName::from(field.as_str());
|
||||
match map_group_field(&field, schema) {
|
||||
GroupFieldType::DisplayName => Ok(GroupRequestFilter::DisplayNameSubString(
|
||||
substring_filter.clone().into(),
|
||||
)),
|
||||
GroupFieldType::NoMatch => Ok(GroupRequestFilter::from(false)),
|
||||
_ => Err(LdapError {
|
||||
code: LdapResultCode::UnwillingToPerform,
|
||||
message: format!(
|
||||
"Unsupported group attribute for substring filter: {:?}",
|
||||
"Unsupported group attribute for substring filter: \"{}\"",
|
||||
field
|
||||
),
|
||||
}),
|
||||
@@ -204,8 +245,9 @@ pub async fn get_groups_list<Backend: GroupListerBackendHandler>(
|
||||
ldap_filter: &LdapFilter,
|
||||
base: &str,
|
||||
backend: &Backend,
|
||||
schema: &PublicSchema,
|
||||
) -> LdapResult<Vec<Group>> {
|
||||
let filters = convert_group_filter(ldap_info, ldap_filter)?;
|
||||
let filters = convert_group_filter(ldap_info, ldap_filter, schema)?;
|
||||
debug!(?filters);
|
||||
backend
|
||||
.list_groups(Some(filters))
|
||||
@@ -221,14 +263,22 @@ pub fn convert_groups_to_ldap_op<'a>(
|
||||
attributes: &'a [String],
|
||||
ldap_info: &'a LdapInfo,
|
||||
user_filter: &'a Option<UserId>,
|
||||
schema: &'a PublicSchema,
|
||||
) -> impl Iterator<Item = LdapOp> + 'a {
|
||||
let expanded_attributes = if groups.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(expand_group_attribute_wildcards(attributes))
|
||||
};
|
||||
|
||||
groups.into_iter().map(move |g| {
|
||||
LdapOp::SearchResultEntry(make_ldap_search_group_result_entry(
|
||||
g,
|
||||
&ldap_info.base_dn_str,
|
||||
attributes,
|
||||
expanded_attributes.as_ref().unwrap(),
|
||||
user_filter,
|
||||
&ldap_info.ignored_group_attributes,
|
||||
schema,
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ use ldap3_proto::{
|
||||
use tracing::{debug, instrument, warn};
|
||||
|
||||
use crate::domain::{
|
||||
handler::{Schema, UserListerBackendHandler, UserRequestFilter},
|
||||
deserialize::deserialize_attribute_value,
|
||||
handler::{UserListerBackendHandler, UserRequestFilter},
|
||||
ldap::{
|
||||
error::{LdapError, LdapResult},
|
||||
utils::{
|
||||
@@ -13,7 +14,8 @@ use crate::domain::{
|
||||
get_user_id_from_distinguished_name, map_user_field, LdapInfo, UserFieldType,
|
||||
},
|
||||
},
|
||||
types::{GroupDetails, User, UserAndGroups, UserColumn, UserId},
|
||||
schema::{PublicSchema, SchemaUserAttributeExtractor},
|
||||
types::{AttributeName, AttributeType, GroupDetails, User, UserAndGroups, UserColumn, UserId},
|
||||
};
|
||||
|
||||
pub fn get_user_attribute(
|
||||
@@ -21,62 +23,79 @@ pub fn get_user_attribute(
|
||||
attribute: &str,
|
||||
base_dn_str: &str,
|
||||
groups: Option<&[GroupDetails]>,
|
||||
ignored_user_attributes: &[String],
|
||||
schema: &Schema,
|
||||
ignored_user_attributes: &[AttributeName],
|
||||
schema: &PublicSchema,
|
||||
) -> Option<Vec<Vec<u8>>> {
|
||||
let attribute = attribute.to_ascii_lowercase();
|
||||
let attribute_values = match attribute.as_str() {
|
||||
"objectclass" => vec![
|
||||
let attribute = AttributeName::from(attribute);
|
||||
let attribute_values = match map_user_field(&attribute, schema) {
|
||||
UserFieldType::ObjectClass => vec![
|
||||
b"inetOrgPerson".to_vec(),
|
||||
b"posixAccount".to_vec(),
|
||||
b"mailAccount".to_vec(),
|
||||
b"person".to_vec(),
|
||||
],
|
||||
// dn is always returned as part of the base response.
|
||||
"dn" | "distinguishedname" => return None,
|
||||
"uid" | "user_id" | "id" => vec![user.user_id.to_string().into_bytes()],
|
||||
"entryuuid" | "uuid" => vec![user.uuid.to_string().into_bytes()],
|
||||
"mail" | "email" => vec![user.email.clone().into_bytes()],
|
||||
"givenname" | "first_name" | "firstname" => {
|
||||
get_custom_attribute(&user.attributes, "first_name", schema)?
|
||||
UserFieldType::Dn => return None,
|
||||
UserFieldType::EntryDn => {
|
||||
vec![format!("uid={},ou=people,{}", &user.user_id, base_dn_str).into_bytes()]
|
||||
}
|
||||
"sn" | "last_name" | "lastname" => {
|
||||
get_custom_attribute(&user.attributes, "last_name", schema)?
|
||||
}
|
||||
"jpegphoto" | "avatar" => get_custom_attribute(&user.attributes, "avatar", schema)?,
|
||||
"memberof" => groups
|
||||
UserFieldType::MemberOf => groups
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|id_and_name| {
|
||||
format!("cn={},ou=groups,{}", &id_and_name.display_name, base_dn_str).into_bytes()
|
||||
})
|
||||
.collect(),
|
||||
"cn" | "displayname" => vec![user.display_name.clone()?.into_bytes()],
|
||||
"creationdate" | "creation_date" | "createtimestamp" | "modifytimestamp" => {
|
||||
vec![chrono::Utc
|
||||
.from_utc_datetime(&user.creation_date)
|
||||
.to_rfc3339()
|
||||
.into_bytes()]
|
||||
UserFieldType::PrimaryField(UserColumn::UserId) => {
|
||||
vec![user.user_id.to_string().into_bytes()]
|
||||
}
|
||||
"1.1" => return None,
|
||||
// We ignore the operational attribute wildcard.
|
||||
"+" => return None,
|
||||
"*" => {
|
||||
panic!(
|
||||
"Matched {}, * should have been expanded into attribute list and * removed",
|
||||
attribute
|
||||
)
|
||||
UserFieldType::PrimaryField(UserColumn::Email) => vec![user.email.to_string().into_bytes()],
|
||||
UserFieldType::PrimaryField(
|
||||
UserColumn::LowercaseEmail
|
||||
| UserColumn::PasswordHash
|
||||
| UserColumn::TotpSecret
|
||||
| UserColumn::MfaType,
|
||||
) => panic!("Should not get here"),
|
||||
UserFieldType::PrimaryField(UserColumn::Uuid) => vec![user.uuid.to_string().into_bytes()],
|
||||
UserFieldType::PrimaryField(UserColumn::DisplayName) => {
|
||||
vec![user.display_name.clone()?.into_bytes()]
|
||||
}
|
||||
_ => {
|
||||
if !ignored_user_attributes.contains(&attribute) {
|
||||
warn!(
|
||||
r#"Ignoring unrecognized group attribute: {}\n\
|
||||
To disable this warning, add it to "ignored_user_attributes" in the config."#,
|
||||
UserFieldType::PrimaryField(UserColumn::CreationDate) => vec![chrono::Utc
|
||||
.from_utc_datetime(&user.creation_date)
|
||||
.to_rfc3339()
|
||||
.into_bytes()],
|
||||
UserFieldType::Attribute(attr, _, _) => {
|
||||
get_custom_attribute::<SchemaUserAttributeExtractor>(&user.attributes, &attr, schema)?
|
||||
}
|
||||
UserFieldType::NoMatch => match attribute.as_str() {
|
||||
"1.1" => return None,
|
||||
// We ignore the operational attribute wildcard.
|
||||
"+" => return None,
|
||||
"*" => {
|
||||
panic!(
|
||||
"Matched {}, * should have been expanded into attribute list and * removed",
|
||||
attribute
|
||||
);
|
||||
)
|
||||
}
|
||||
return None;
|
||||
}
|
||||
_ => {
|
||||
if ignored_user_attributes.contains(&attribute) {
|
||||
return None;
|
||||
}
|
||||
get_custom_attribute::<SchemaUserAttributeExtractor>(
|
||||
&user.attributes,
|
||||
&attribute,
|
||||
schema,
|
||||
)
|
||||
.or_else(|| {
|
||||
warn!(
|
||||
r#"Ignoring unrecognized group attribute: {}\n\
|
||||
To disable this warning, add it to "ignored_user_attributes" in the config."#,
|
||||
attribute
|
||||
);
|
||||
None
|
||||
})?
|
||||
}
|
||||
},
|
||||
};
|
||||
if attribute_values.len() == 1 && attribute_values[0].is_empty() {
|
||||
None
|
||||
@@ -100,12 +119,11 @@ const ALL_USER_ATTRIBUTE_KEYS: &[&str] = &[
|
||||
fn make_ldap_search_user_result_entry(
|
||||
user: User,
|
||||
base_dn_str: &str,
|
||||
attributes: &[String],
|
||||
expanded_attributes: &[&str],
|
||||
groups: Option<&[GroupDetails]>,
|
||||
ignored_user_attributes: &[String],
|
||||
schema: &Schema,
|
||||
ignored_user_attributes: &[AttributeName],
|
||||
schema: &PublicSchema,
|
||||
) -> LdapSearchResultEntry {
|
||||
let expanded_attributes = expand_user_attribute_wildcards(attributes);
|
||||
let dn = format!("uid={},ou=people,{}", user.user_id.as_str(), base_dn_str);
|
||||
LdapSearchResultEntry {
|
||||
dn,
|
||||
@@ -129,8 +147,26 @@ fn make_ldap_search_user_result_entry(
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_user_filter(ldap_info: &LdapInfo, filter: &LdapFilter) -> LdapResult<UserRequestFilter> {
|
||||
let rec = |f| convert_user_filter(ldap_info, f);
|
||||
fn get_user_attribute_equality_filter(
|
||||
field: &AttributeName,
|
||||
typ: AttributeType,
|
||||
is_list: bool,
|
||||
value: &str,
|
||||
) -> LdapResult<UserRequestFilter> {
|
||||
deserialize_attribute_value(&[value.to_owned()], typ, is_list)
|
||||
.map_err(|e| LdapError {
|
||||
code: LdapResultCode::Other,
|
||||
message: format!("Invalid value for attribute {}: {}", field, e),
|
||||
})
|
||||
.map(|v| UserRequestFilter::AttributeEquality(field.clone(), v))
|
||||
}
|
||||
|
||||
fn convert_user_filter(
|
||||
ldap_info: &LdapInfo,
|
||||
filter: &LdapFilter,
|
||||
schema: &PublicSchema,
|
||||
) -> LdapResult<UserRequestFilter> {
|
||||
let rec = |f| convert_user_filter(ldap_info, f, schema);
|
||||
match filter {
|
||||
LdapFilter::And(filters) => Ok(UserRequestFilter::And(
|
||||
filters.iter().map(rec).collect::<LdapResult<_>>()?,
|
||||
@@ -140,71 +176,72 @@ fn convert_user_filter(ldap_info: &LdapInfo, filter: &LdapFilter) -> LdapResult<
|
||||
)),
|
||||
LdapFilter::Not(filter) => Ok(UserRequestFilter::Not(Box::new(rec(filter)?))),
|
||||
LdapFilter::Equality(field, value) => {
|
||||
let field = &field.to_ascii_lowercase();
|
||||
match field.as_str() {
|
||||
"memberof" => Ok(UserRequestFilter::MemberOf(
|
||||
let field = AttributeName::from(field.as_str());
|
||||
let value = value.to_ascii_lowercase();
|
||||
match map_user_field(&field, schema) {
|
||||
UserFieldType::PrimaryField(UserColumn::UserId) => {
|
||||
Ok(UserRequestFilter::UserId(UserId::new(&value)))
|
||||
}
|
||||
UserFieldType::PrimaryField(field) => Ok(UserRequestFilter::Equality(field, value)),
|
||||
UserFieldType::Attribute(field, typ, is_list) => {
|
||||
get_user_attribute_equality_filter(&field, typ, is_list, &value)
|
||||
}
|
||||
UserFieldType::NoMatch => {
|
||||
if !ldap_info.ignored_user_attributes.contains(&field) {
|
||||
warn!(
|
||||
r#"Ignoring unknown user attribute "{}" in filter.\n\
|
||||
To disable this warning, add it to "ignored_user_attributes" in the config"#,
|
||||
field
|
||||
);
|
||||
}
|
||||
Ok(UserRequestFilter::from(false))
|
||||
}
|
||||
UserFieldType::ObjectClass => Ok(UserRequestFilter::from(matches!(
|
||||
value.as_str(),
|
||||
"person" | "inetorgperson" | "posixaccount" | "mailaccount"
|
||||
))),
|
||||
UserFieldType::MemberOf => Ok(UserRequestFilter::MemberOf(
|
||||
get_group_id_from_distinguished_name(
|
||||
&value.to_ascii_lowercase(),
|
||||
&value,
|
||||
&ldap_info.base_dn,
|
||||
&ldap_info.base_dn_str,
|
||||
)?,
|
||||
)),
|
||||
"objectclass" => Ok(UserRequestFilter::from(matches!(
|
||||
value.to_ascii_lowercase().as_str(),
|
||||
"person" | "inetorgperson" | "posixaccount" | "mailaccount"
|
||||
))),
|
||||
"dn" => Ok(get_user_id_from_distinguished_name(
|
||||
value.to_ascii_lowercase().as_str(),
|
||||
&ldap_info.base_dn,
|
||||
&ldap_info.base_dn_str,
|
||||
)
|
||||
.map(UserRequestFilter::UserId)
|
||||
.unwrap_or_else(|_| {
|
||||
warn!("Invalid dn filter on user: {}", value);
|
||||
UserRequestFilter::from(false)
|
||||
})),
|
||||
_ => match map_user_field(field) {
|
||||
UserFieldType::PrimaryField(UserColumn::UserId) => {
|
||||
Ok(UserRequestFilter::UserId(UserId::new(value)))
|
||||
}
|
||||
UserFieldType::PrimaryField(field) => {
|
||||
Ok(UserRequestFilter::Equality(field, value.clone()))
|
||||
}
|
||||
UserFieldType::Attribute(field) => Ok(UserRequestFilter::AttributeEquality(
|
||||
field.to_owned(),
|
||||
value.clone(),
|
||||
)),
|
||||
UserFieldType::NoMatch => {
|
||||
if !ldap_info.ignored_user_attributes.contains(field) {
|
||||
warn!(
|
||||
r#"Ignoring unknown user attribute "{}" in filter.\n\
|
||||
To disable this warning, add it to "ignored_user_attributes" in the config"#,
|
||||
field
|
||||
);
|
||||
}
|
||||
Ok(UserRequestFilter::from(false))
|
||||
}
|
||||
},
|
||||
UserFieldType::EntryDn | UserFieldType::Dn => {
|
||||
Ok(get_user_id_from_distinguished_name(
|
||||
value.as_str(),
|
||||
&ldap_info.base_dn,
|
||||
&ldap_info.base_dn_str,
|
||||
)
|
||||
.map(UserRequestFilter::UserId)
|
||||
.unwrap_or_else(|_| {
|
||||
warn!("Invalid dn filter on user: {}", value);
|
||||
UserRequestFilter::from(false)
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
LdapFilter::Present(field) => {
|
||||
let field = &field.to_ascii_lowercase();
|
||||
let field = AttributeName::from(field.as_str());
|
||||
// Check that it's a field we support.
|
||||
Ok(UserRequestFilter::from(
|
||||
field == "objectclass"
|
||||
|| field == "dn"
|
||||
|| field == "distinguishedname"
|
||||
|| !matches!(map_user_field(field), UserFieldType::NoMatch),
|
||||
field.as_str() == "objectclass"
|
||||
|| field.as_str() == "dn"
|
||||
|| field.as_str() == "distinguishedname"
|
||||
|| !matches!(map_user_field(&field, schema), UserFieldType::NoMatch),
|
||||
))
|
||||
}
|
||||
LdapFilter::Substring(field, substring_filter) => {
|
||||
let field = &field.to_ascii_lowercase();
|
||||
match map_user_field(field.as_str()) {
|
||||
let field = AttributeName::from(field.as_str());
|
||||
match map_user_field(&field, schema) {
|
||||
UserFieldType::PrimaryField(UserColumn::UserId) => Ok(
|
||||
UserRequestFilter::UserIdSubString(substring_filter.clone().into()),
|
||||
),
|
||||
UserFieldType::NoMatch
|
||||
| UserFieldType::Attribute(_)
|
||||
UserFieldType::Attribute(_, _, _)
|
||||
| UserFieldType::ObjectClass
|
||||
| UserFieldType::MemberOf
|
||||
| UserFieldType::Dn
|
||||
| UserFieldType::EntryDn
|
||||
| UserFieldType::PrimaryField(UserColumn::CreationDate)
|
||||
| UserFieldType::PrimaryField(UserColumn::Uuid) => Err(LdapError {
|
||||
code: LdapResultCode::UnwillingToPerform,
|
||||
@@ -213,6 +250,7 @@ fn convert_user_filter(ldap_info: &LdapInfo, filter: &LdapFilter) -> LdapResult<
|
||||
field
|
||||
),
|
||||
}),
|
||||
UserFieldType::NoMatch => Ok(UserRequestFilter::from(false)),
|
||||
UserFieldType::PrimaryField(field) => Ok(UserRequestFilter::SubString(
|
||||
field,
|
||||
substring_filter.clone().into(),
|
||||
@@ -237,8 +275,9 @@ pub async fn get_user_list<Backend: UserListerBackendHandler>(
|
||||
request_groups: bool,
|
||||
base: &str,
|
||||
backend: &Backend,
|
||||
schema: &PublicSchema,
|
||||
) -> LdapResult<Vec<UserAndGroups>> {
|
||||
let filters = convert_user_filter(ldap_info, ldap_filter)?;
|
||||
let filters = convert_user_filter(ldap_info, ldap_filter, schema)?;
|
||||
debug!(?filters);
|
||||
backend
|
||||
.list_users(Some(filters), request_groups)
|
||||
@@ -253,13 +292,18 @@ pub fn convert_users_to_ldap_op<'a>(
|
||||
users: Vec<UserAndGroups>,
|
||||
attributes: &'a [String],
|
||||
ldap_info: &'a LdapInfo,
|
||||
schema: &'a Schema,
|
||||
schema: &'a PublicSchema,
|
||||
) -> impl Iterator<Item = LdapOp> + 'a {
|
||||
let expanded_attributes = if users.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(expand_user_attribute_wildcards(attributes))
|
||||
};
|
||||
users.into_iter().map(move |u| {
|
||||
LdapOp::SearchResultEntry(make_ldap_search_user_result_entry(
|
||||
u.user,
|
||||
&ldap_info.base_dn_str,
|
||||
attributes,
|
||||
expanded_attributes.as_ref().unwrap(),
|
||||
u.groups.as_deref(),
|
||||
&ldap_info.ignored_user_attributes,
|
||||
schema,
|
||||
|
||||
@@ -4,9 +4,12 @@ use ldap3_proto::{proto::LdapSubstringFilter, LdapResultCode};
|
||||
use tracing::{debug, instrument, warn};
|
||||
|
||||
use crate::domain::{
|
||||
handler::{Schema, SubStringFilter},
|
||||
handler::SubStringFilter,
|
||||
ldap::error::{LdapError, LdapResult},
|
||||
types::{AttributeType, AttributeValue, JpegPhoto, UserColumn, UserId},
|
||||
schema::{PublicSchema, SchemaAttributeExtractor},
|
||||
types::{
|
||||
AttributeName, AttributeType, AttributeValue, GroupName, JpegPhoto, UserColumn, UserId,
|
||||
},
|
||||
};
|
||||
|
||||
impl From<LdapSubstringFilter> for SubStringFilter {
|
||||
@@ -102,8 +105,8 @@ pub fn get_group_id_from_distinguished_name(
|
||||
dn: &str,
|
||||
base_tree: &[(String, String)],
|
||||
base_dn_str: &str,
|
||||
) -> LdapResult<String> {
|
||||
get_id_from_distinguished_name(dn, base_tree, base_dn_str, true)
|
||||
) -> LdapResult<GroupName> {
|
||||
get_id_from_distinguished_name(dn, base_tree, base_dn_str, true).map(GroupName::from)
|
||||
}
|
||||
|
||||
#[instrument(skip(all_attribute_keys), level = "debug")]
|
||||
@@ -111,21 +114,21 @@ pub fn expand_attribute_wildcards<'a>(
|
||||
ldap_attributes: &'a [String],
|
||||
all_attribute_keys: &'a [&'static str],
|
||||
) -> Vec<&'a str> {
|
||||
let mut attributes_out = ldap_attributes
|
||||
let extra_attributes =
|
||||
if ldap_attributes.iter().any(|x| x == "*") || ldap_attributes.is_empty() {
|
||||
all_attribute_keys
|
||||
} else {
|
||||
&[]
|
||||
}
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if attributes_out.iter().any(|&x| x == "*") || attributes_out.is_empty() {
|
||||
// Remove occurrences of '*'
|
||||
attributes_out.retain(|&x| x != "*");
|
||||
// Splice in all non-operational attributes
|
||||
attributes_out.extend(all_attribute_keys.iter());
|
||||
}
|
||||
.copied();
|
||||
let attributes_out = ldap_attributes
|
||||
.iter()
|
||||
.map(|s| s.as_str())
|
||||
.filter(|&s| s != "*" && s != "+" && s != "1.1");
|
||||
|
||||
// Deduplicate, preserving order
|
||||
let resolved_attributes = attributes_out
|
||||
.into_iter()
|
||||
let resolved_attributes = itertools::chain(attributes_out, extra_attributes)
|
||||
.unique_by(|a| a.to_ascii_lowercase())
|
||||
.collect_vec();
|
||||
debug!(?resolved_attributes);
|
||||
@@ -155,50 +158,97 @@ pub fn is_subtree(subtree: &[(String, String)], base_tree: &[(String, String)])
|
||||
|
||||
pub enum UserFieldType {
|
||||
NoMatch,
|
||||
ObjectClass,
|
||||
MemberOf,
|
||||
Dn,
|
||||
EntryDn,
|
||||
PrimaryField(UserColumn),
|
||||
Attribute(&'static str),
|
||||
Attribute(AttributeName, AttributeType, bool),
|
||||
}
|
||||
|
||||
pub fn map_user_field(field: &str) -> UserFieldType {
|
||||
assert!(field == field.to_ascii_lowercase());
|
||||
match field {
|
||||
pub fn map_user_field(field: &AttributeName, schema: &PublicSchema) -> UserFieldType {
|
||||
match field.as_str() {
|
||||
"memberof" | "ismemberof" => UserFieldType::MemberOf,
|
||||
"objectclass" => UserFieldType::ObjectClass,
|
||||
"dn" | "distinguishedname" => UserFieldType::Dn,
|
||||
"entrydn" => UserFieldType::EntryDn,
|
||||
"uid" | "user_id" | "id" => UserFieldType::PrimaryField(UserColumn::UserId),
|
||||
"mail" | "email" => UserFieldType::PrimaryField(UserColumn::Email),
|
||||
"cn" | "displayname" | "display_name" => {
|
||||
UserFieldType::PrimaryField(UserColumn::DisplayName)
|
||||
}
|
||||
"givenname" | "first_name" | "firstname" => UserFieldType::Attribute("first_name"),
|
||||
"sn" | "last_name" | "lastname" => UserFieldType::Attribute("last_name"),
|
||||
"avatar" | "jpegphoto" => UserFieldType::Attribute("avatar"),
|
||||
"givenname" | "first_name" | "firstname" => UserFieldType::Attribute(
|
||||
AttributeName::from("first_name"),
|
||||
AttributeType::String,
|
||||
false,
|
||||
),
|
||||
"sn" | "last_name" | "lastname" => UserFieldType::Attribute(
|
||||
AttributeName::from("last_name"),
|
||||
AttributeType::String,
|
||||
false,
|
||||
),
|
||||
"avatar" | "jpegphoto" => UserFieldType::Attribute(
|
||||
AttributeName::from("avatar"),
|
||||
AttributeType::JpegPhoto,
|
||||
false,
|
||||
),
|
||||
"creationdate" | "createtimestamp" | "modifytimestamp" | "creation_date" => {
|
||||
UserFieldType::PrimaryField(UserColumn::CreationDate)
|
||||
}
|
||||
"entryuuid" | "uuid" => UserFieldType::PrimaryField(UserColumn::Uuid),
|
||||
_ => UserFieldType::NoMatch,
|
||||
_ => schema
|
||||
.get_schema()
|
||||
.user_attributes
|
||||
.get_attribute_type(field)
|
||||
.map(|(t, is_list)| UserFieldType::Attribute(field.clone(), t, is_list))
|
||||
.unwrap_or(UserFieldType::NoMatch),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn map_group_field(field: &str) -> Option<&'static str> {
|
||||
assert!(field == field.to_ascii_lowercase());
|
||||
Some(match field {
|
||||
"cn" | "displayname" | "uid" | "display_name" => "display_name",
|
||||
"creationdate" | "createtimestamp" | "modifytimestamp" | "creation_date" => "creation_date",
|
||||
"entryuuid" | "uuid" => "uuid",
|
||||
_ => return None,
|
||||
})
|
||||
pub enum GroupFieldType {
|
||||
NoMatch,
|
||||
DisplayName,
|
||||
CreationDate,
|
||||
ObjectClass,
|
||||
Dn,
|
||||
// Like Dn, but returned as part of the attributes.
|
||||
EntryDn,
|
||||
Member,
|
||||
Uuid,
|
||||
Attribute(AttributeName, AttributeType, bool),
|
||||
}
|
||||
|
||||
pub fn map_group_field(field: &AttributeName, schema: &PublicSchema) -> GroupFieldType {
|
||||
match field.as_str() {
|
||||
"dn" | "distinguishedname" => GroupFieldType::Dn,
|
||||
"entrydn" => GroupFieldType::EntryDn,
|
||||
"objectclass" => GroupFieldType::ObjectClass,
|
||||
"cn" | "displayname" | "uid" | "display_name" | "id" => GroupFieldType::DisplayName,
|
||||
"creationdate" | "createtimestamp" | "modifytimestamp" | "creation_date" => {
|
||||
GroupFieldType::CreationDate
|
||||
}
|
||||
"member" | "uniquemember" => GroupFieldType::Member,
|
||||
"entryuuid" | "uuid" => GroupFieldType::Uuid,
|
||||
_ => schema
|
||||
.get_schema()
|
||||
.group_attributes
|
||||
.get_attribute_type(field)
|
||||
.map(|(t, is_list)| GroupFieldType::Attribute(field.clone(), t, is_list))
|
||||
.unwrap_or(GroupFieldType::NoMatch),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct LdapInfo {
|
||||
pub base_dn: Vec<(String, String)>,
|
||||
pub base_dn_str: String,
|
||||
pub ignored_user_attributes: Vec<String>,
|
||||
pub ignored_group_attributes: Vec<String>,
|
||||
pub ignored_user_attributes: Vec<AttributeName>,
|
||||
pub ignored_group_attributes: Vec<AttributeName>,
|
||||
}
|
||||
|
||||
pub fn get_custom_attribute(
|
||||
pub fn get_custom_attribute<Extractor: SchemaAttributeExtractor>(
|
||||
attributes: &[AttributeValue],
|
||||
attribute_name: &str,
|
||||
schema: &Schema,
|
||||
attribute_name: &AttributeName,
|
||||
schema: &PublicSchema,
|
||||
) -> Option<Vec<Vec<u8>>> {
|
||||
let convert_date = |date| {
|
||||
chrono::Utc
|
||||
@@ -206,13 +256,12 @@ pub fn get_custom_attribute(
|
||||
.to_rfc3339()
|
||||
.into_bytes()
|
||||
};
|
||||
schema
|
||||
.user_attributes
|
||||
Extractor::get_attributes(schema)
|
||||
.get_attribute_type(attribute_name)
|
||||
.and_then(|attribute_type| {
|
||||
attributes
|
||||
.iter()
|
||||
.find(|a| a.name == attribute_name)
|
||||
.find(|a| &a.name == attribute_name)
|
||||
.map(|attribute| match attribute_type {
|
||||
(AttributeType::String, false) => {
|
||||
vec![attribute.value.unwrap::<String>().into_bytes()]
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
pub mod deserialize;
|
||||
pub mod error;
|
||||
pub mod handler;
|
||||
pub mod ldap;
|
||||
pub mod model;
|
||||
pub mod opaque_handler;
|
||||
pub mod schema;
|
||||
pub mod sql_backend_handler;
|
||||
pub mod sql_group_backend_handler;
|
||||
pub mod sql_migrations;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::domain::{handler::AttributeSchema, types::AttributeType};
|
||||
use crate::domain::{
|
||||
handler::AttributeSchema,
|
||||
types::{AttributeName, AttributeType},
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "group_attribute_schema")]
|
||||
@@ -11,7 +14,7 @@ pub struct Model {
|
||||
auto_increment = false,
|
||||
column_name = "group_attribute_schema_name"
|
||||
)]
|
||||
pub attribute_name: String,
|
||||
pub attribute_name: AttributeName,
|
||||
#[sea_orm(column_name = "group_attribute_schema_type")]
|
||||
pub attribute_type: AttributeType,
|
||||
#[sea_orm(column_name = "group_attribute_schema_is_list")]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::domain::types::{AttributeValue, GroupId, Serialized};
|
||||
use crate::domain::types::{AttributeName, AttributeValue, GroupId, Serialized};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "group_attributes")]
|
||||
@@ -17,7 +17,7 @@ pub struct Model {
|
||||
auto_increment = false,
|
||||
column_name = "group_attribute_name"
|
||||
)]
|
||||
pub attribute_name: String,
|
||||
pub attribute_name: AttributeName,
|
||||
#[sea_orm(column_name = "group_attribute_value")]
|
||||
pub value: Serialized,
|
||||
}
|
||||
|
||||
@@ -3,14 +3,15 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::domain::types::{GroupId, Uuid};
|
||||
use crate::domain::types::{GroupId, GroupName, Uuid};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "groups")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub group_id: GroupId,
|
||||
pub display_name: String,
|
||||
pub display_name: GroupName,
|
||||
pub lowercase_display_name: String,
|
||||
pub creation_date: chrono::NaiveDateTime,
|
||||
pub uuid: Uuid,
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::domain::{handler::AttributeSchema, types::AttributeType};
|
||||
use crate::domain::{
|
||||
handler::AttributeSchema,
|
||||
types::{AttributeName, AttributeType},
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "user_attribute_schema")]
|
||||
@@ -11,7 +14,7 @@ pub struct Model {
|
||||
auto_increment = false,
|
||||
column_name = "user_attribute_schema_name"
|
||||
)]
|
||||
pub attribute_name: String,
|
||||
pub attribute_name: AttributeName,
|
||||
#[sea_orm(column_name = "user_attribute_schema_type")]
|
||||
pub attribute_type: AttributeType,
|
||||
#[sea_orm(column_name = "user_attribute_schema_is_list")]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::domain::types::{AttributeValue, Serialized, UserId};
|
||||
use crate::domain::types::{AttributeName, AttributeValue, Serialized, UserId};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "user_attributes")]
|
||||
@@ -17,7 +17,7 @@ pub struct Model {
|
||||
auto_increment = false,
|
||||
column_name = "user_attribute_name"
|
||||
)]
|
||||
pub attribute_name: String,
|
||||
pub attribute_name: AttributeName,
|
||||
#[sea_orm(column_name = "user_attribute_value")]
|
||||
pub value: Serialized,
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user