Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b2dfbe52e | ||
|
|
e1aa2bfb18 | ||
|
|
1377d5aed9 | ||
|
|
fa0185af5e |
@@ -1,9 +1,7 @@
|
|||||||
FROM rust:1.74
|
FROM rust:1.66
|
||||||
|
|
||||||
ARG USERNAME=lldapdev
|
ARG USERNAME=lldapdev
|
||||||
# We need to keep the user as 1001 to match the GitHub runner's UID.
|
ARG USER_UID=1000
|
||||||
# See https://github.com/actions/checkout/issues/956.
|
|
||||||
ARG USER_UID=1001
|
|
||||||
ARG USER_GID=$USER_UID
|
ARG USER_GID=$USER_UID
|
||||||
|
|
||||||
# Create the user
|
# Create the user
|
||||||
@@ -23,4 +21,4 @@ RUN RUSTFLAGS=-Ctarget-feature=-crt-static cargo install wasm-pack \
|
|||||||
|
|
||||||
USER $USERNAME
|
USER $USERNAME
|
||||||
ENV CARGO_HOME=/home/$USERNAME/.cargo
|
ENV CARGO_HOME=/home/$USERNAME/.cargo
|
||||||
ENV SHELL=/bin/bash
|
ENV SHELL=/bin/bash
|
||||||
2
.gitattributes
vendored
2
.gitattributes
vendored
@@ -1,4 +1,4 @@
|
|||||||
example_configs/** linguist-documentation
|
example-configs/** linguist-documentation
|
||||||
docs/** linguist-documentation
|
docs/** linguist-documentation
|
||||||
*.md linguist-documentation
|
*.md linguist-documentation
|
||||||
lldap_config.docker_template.toml linguist-documentation
|
lldap_config.docker_template.toml linguist-documentation
|
||||||
|
|||||||
5
.github/FUNDING.yml
vendored
5
.github/FUNDING.yml
vendored
@@ -1,5 +0,0 @@
|
|||||||
# These are supported funding model platforms
|
|
||||||
|
|
||||||
github: [lldap]
|
|
||||||
|
|
||||||
custom: ['https://bmc.link/nitnelave']
|
|
||||||
29
.github/ISSUE_TEMPLATE/bug_report.md
vendored
29
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,29 +0,0 @@
|
|||||||
---
|
|
||||||
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
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -1,20 +0,0 @@
|
|||||||
---
|
|
||||||
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
25
.github/ISSUE_TEMPLATE/integration-request.md
vendored
@@ -1,25 +0,0 @@
|
|||||||
---
|
|
||||||
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,19 +1,10 @@
|
|||||||
codecov:
|
codecov:
|
||||||
require_ci_to_pass: yes
|
require_ci_to_pass: yes
|
||||||
comment:
|
comment:
|
||||||
layout: "header,diff,files"
|
layout: "diff,flags"
|
||||||
require_changes: true
|
require_changes: true
|
||||||
require_base: true
|
require_base: true
|
||||||
require_head: true
|
require_head: true
|
||||||
coverage:
|
|
||||||
status:
|
|
||||||
project:
|
|
||||||
default:
|
|
||||||
target: "75%"
|
|
||||||
threshold: "0.1%"
|
|
||||||
removed_code_behavior: adjust_base
|
|
||||||
github_checks:
|
|
||||||
annotations: true
|
|
||||||
ignore:
|
ignore:
|
||||||
- "app"
|
- "app"
|
||||||
- "docs"
|
- "docs"
|
||||||
|
|||||||
94
.github/workflows/Dockerfile.ci.alpine
vendored
94
.github/workflows/Dockerfile.ci.alpine
vendored
@@ -1,6 +1,72 @@
|
|||||||
FROM localhost:5000/lldap/lldap:alpine-base
|
FROM debian:bullseye AS lldap
|
||||||
# Taken directly from https://github.com/tianon/gosu/blob/master/INSTALL.md
|
ARG DEBIAN_FRONTEND=noninteractive
|
||||||
ENV GOSU_VERSION 1.17
|
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-gnueabihf-lldap-bin/lldap target/lldap && \
|
||||||
|
mv bin/armv7-unknown-linux-gnueabihf-lldap_migration_tool-bin/lldap_migration_tool target/lldap_migration_tool && \
|
||||||
|
mv bin/armv7-unknown-linux-gnueabihf-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
|
||||||
|
chmod +x target/lldap && \
|
||||||
|
chmod +x target/lldap_migration_tool && \
|
||||||
|
chmod +x target/lldap_set_password && \
|
||||||
|
ls -la target/ . && \
|
||||||
|
pwd \
|
||||||
|
; fi
|
||||||
|
|
||||||
|
# Web and App dir
|
||||||
|
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||||
|
COPY lldap_config.docker_template.toml /lldap/
|
||||||
|
COPY web/index_local.html web/index.html
|
||||||
|
RUN cp target/lldap /lldap/ && \
|
||||||
|
cp target/lldap_migration_tool /lldap/ && \
|
||||||
|
cp target/lldap_set_password /lldap/ && \
|
||||||
|
cp -R web/index.html \
|
||||||
|
web/pkg \
|
||||||
|
web/static \
|
||||||
|
/lldap/app/
|
||||||
|
|
||||||
|
WORKDIR /lldap
|
||||||
|
RUN set -x \
|
||||||
|
&& for file in $(cat /lldap/app/static/libraries.txt); do wget -P app/static "$file"; done \
|
||||||
|
&& for file in $(cat /lldap/app/static/fonts/fonts.txt); do wget -P app/static/fonts "$file"; done \
|
||||||
|
&& chmod a+r -R .
|
||||||
|
|
||||||
|
FROM alpine:3.16
|
||||||
|
WORKDIR /app
|
||||||
|
ENV UID=1000
|
||||||
|
ENV GID=1000
|
||||||
|
ENV USER=lldap
|
||||||
|
ENV GOSU_VERSION 1.14
|
||||||
|
# Fetch gosu from git
|
||||||
RUN set -eux; \
|
RUN set -eux; \
|
||||||
\
|
\
|
||||||
apk add --no-cache --virtual .gosu-deps \
|
apk add --no-cache --virtual .gosu-deps \
|
||||||
@@ -17,7 +83,7 @@ RUN set -eux; \
|
|||||||
export GNUPGHOME="$(mktemp -d)"; \
|
export GNUPGHOME="$(mktemp -d)"; \
|
||||||
gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; \
|
gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; \
|
||||||
gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \
|
gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \
|
||||||
gpgconf --kill all; \
|
command -v gpgconf && gpgconf --kill all || :; \
|
||||||
rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \
|
rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \
|
||||||
\
|
\
|
||||||
# clean up fetch dependencies
|
# clean up fetch dependencies
|
||||||
@@ -27,4 +93,22 @@ RUN set -eux; \
|
|||||||
# verify that the binary works
|
# verify that the binary works
|
||||||
gosu --version; \
|
gosu --version; \
|
||||||
gosu nobody true
|
gosu nobody true
|
||||||
COPY --chown=$USER:$USER docker-entrypoint.sh /docker-entrypoint.sh
|
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"]
|
||||||
|
|||||||
85
.github/workflows/Dockerfile.ci.alpine-base
vendored
85
.github/workflows/Dockerfile.ci.alpine-base
vendored
@@ -1,85 +0,0 @@
|
|||||||
FROM debian:bullseye AS lldap
|
|
||||||
ARG DEBIAN_FRONTEND=noninteractive
|
|
||||||
ARG TARGETPLATFORM
|
|
||||||
RUN apt update && apt install -y wget
|
|
||||||
WORKDIR /dim
|
|
||||||
COPY bin/ bin/
|
|
||||||
COPY web/ web/
|
|
||||||
|
|
||||||
RUN mkdir -p target/
|
|
||||||
RUN mkdir -p /lldap/app
|
|
||||||
|
|
||||||
RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \
|
|
||||||
mv bin/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.19
|
|
||||||
WORKDIR /app
|
|
||||||
ENV UID=1000
|
|
||||||
ENV GID=1000
|
|
||||||
ENV USER=lldap
|
|
||||||
RUN apk add --no-cache tini ca-certificates bash tzdata jq curl jo && \
|
|
||||||
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
|
|
||||||
COPY scripts/bootstrap.sh ./
|
|
||||||
ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"]
|
|
||||||
CMD ["run", "--config-file", "/data/lldap_config.toml"]
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
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,31 +1,79 @@
|
|||||||
FROM localhost:5000/lldap/lldap:debian-base
|
FROM debian:bullseye AS lldap
|
||||||
# Taken directly from https://github.com/tianon/gosu/blob/master/INSTALL.md
|
ARG DEBIAN_FRONTEND=noninteractive
|
||||||
ENV GOSU_VERSION 1.17
|
ARG TARGETPLATFORM
|
||||||
RUN set -eux; \
|
RUN apt update && apt install -y wget
|
||||||
# save list of currently installed packages for later so we can clean up
|
WORKDIR /dim
|
||||||
savedAptMark="$(apt-mark showmanual)"; \
|
COPY bin/ bin/
|
||||||
apt-get update; \
|
COPY web/ web/
|
||||||
apt-get install -y --no-install-recommends ca-certificates gnupg wget; \
|
|
||||||
rm -rf /var/lib/apt/lists/*; \
|
RUN mkdir -p target/
|
||||||
\
|
RUN mkdir -p /lldap/app
|
||||||
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"; \
|
RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \
|
||||||
wget -O /usr/local/bin/gosu.asc "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch.asc"; \
|
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 && \
|
||||||
# verify the signature
|
mv bin/x86_64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
|
||||||
export GNUPGHOME="$(mktemp -d)"; \
|
chmod +x target/lldap && \
|
||||||
gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; \
|
chmod +x target/lldap_migration_tool && \
|
||||||
gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \
|
chmod +x target/lldap_set_password && \
|
||||||
gpgconf --kill all; \
|
ls -la target/ . && \
|
||||||
rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \
|
pwd \
|
||||||
\
|
; fi
|
||||||
# clean up fetch dependencies
|
|
||||||
apt-mark auto '.*' > /dev/null; \
|
RUN if [ "${TARGETPLATFORM}" = "linux/arm64" ]; then \
|
||||||
[ -z "$savedAptMark" ] || apt-mark manual $savedAptMark; \
|
mv bin/aarch64-unknown-linux-musl-lldap-bin/lldap target/lldap && \
|
||||||
apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false; \
|
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 /usr/local/bin/gosu; \
|
chmod +x target/lldap && \
|
||||||
# verify that the binary works
|
chmod +x target/lldap_migration_tool && \
|
||||||
gosu --version; \
|
chmod +x target/lldap_set_password && \
|
||||||
gosu nobody true
|
ls -la target/ . && \
|
||||||
COPY --chown=$USER:$USER docker-entrypoint.sh /docker-entrypoint.sh
|
pwd \
|
||||||
|
; fi
|
||||||
|
|
||||||
|
RUN if [ "${TARGETPLATFORM}" = "linux/arm/v7" ]; then \
|
||||||
|
mv bin/armv7-unknown-linux-gnueabihf-lldap-bin/lldap target/lldap && \
|
||||||
|
mv bin/armv7-unknown-linux-gnueabihf-lldap_migration_tool-bin/lldap_migration_tool target/lldap_migration_tool && \
|
||||||
|
mv bin/armv7-unknown-linux-gnueabihf-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"]
|
||||||
|
|||||||
80
.github/workflows/Dockerfile.ci.debian-base
vendored
80
.github/workflows/Dockerfile.ci.debian-base
vendored
@@ -1,80 +0,0 @@
|
|||||||
FROM debian:bullseye AS lldap
|
|
||||||
ARG DEBIAN_FRONTEND=noninteractive
|
|
||||||
ARG TARGETPLATFORM
|
|
||||||
RUN apt update && apt install -y wget
|
|
||||||
WORKDIR /dim
|
|
||||||
COPY bin/ bin/
|
|
||||||
COPY web/ web/
|
|
||||||
|
|
||||||
RUN mkdir -p target/
|
|
||||||
RUN mkdir -p /lldap/app
|
|
||||||
|
|
||||||
RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \
|
|
||||||
mv bin/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 jq curl jo && \
|
|
||||||
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
|
|
||||||
COPY scripts/bootstrap.sh ./
|
|
||||||
ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"]
|
|
||||||
CMD ["run", "--config-file", "/data/lldap_config.toml"]
|
|
||||||
HEALTHCHECK CMD ["/app/lldap", "healthcheck", "--config-file", "/data/lldap_config.toml"]
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
FROM localhost:5000/lldap/lldap:debian-base
|
|
||||||
COPY --chown=$USER:$USER docker-entrypoint-rootless.sh /docker-entrypoint.sh
|
|
||||||
USER $USER
|
|
||||||
50
.github/workflows/Dockerfile.dev
vendored
50
.github/workflows/Dockerfile.dev
vendored
@@ -1,41 +1,45 @@
|
|||||||
# Keep tracking base image
|
# Keep tracking base image
|
||||||
FROM rust:1.81-slim-bookworm
|
FROM rust:1.66-slim-bullseye
|
||||||
|
|
||||||
# Set needed env path
|
# 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"
|
ENV PATH="/opt/aarch64-linux-musl-cross/:/opt/aarch64-linux-musl-cross/bin/:/opt/x86_64-linux-musl-cross/:/opt/x86_64-linux-musl-cross/bin/:$PATH"
|
||||||
|
|
||||||
# Set building env
|
### Install build deps x86_64
|
||||||
ENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse \
|
|
||||||
CARGO_NET_GIT_FETCH_WITH_CLI=true \
|
|
||||||
CARGO_TARGET_ARMV7_UNKNOWN_LINUX_MUSLEABIHF_LINKER=armv7l-linux-musleabihf-gcc \
|
|
||||||
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-linux-musl-gcc \
|
|
||||||
CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER=x86_64-linux-musl-gcc \
|
|
||||||
CC_armv7_unknown_linux_musleabihf=armv7l-linux-musleabihf-gcc \
|
|
||||||
CC_x86_64_unknown_linux_musl=x86_64-linux-musl-gcc \
|
|
||||||
CC_aarch64_unknown_linux_musl=aarch64-linux-musl-gcc
|
|
||||||
|
|
||||||
### Install Additional Build Tools
|
|
||||||
RUN apt update && \
|
RUN apt update && \
|
||||||
apt install -y --no-install-recommends curl git wget make perl pkg-config tar jq gzip && \
|
apt install -y --no-install-recommends curl git wget build-essential make perl pkg-config curl tar jq musl-tools gzip && \
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - && \
|
||||||
|
apt update && \
|
||||||
|
apt install -y --no-install-recommends nodejs && \
|
||||||
apt clean && \
|
apt clean && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
### Add musl-gcc aarch64, x86_64 and armv7l
|
### Install build deps aarch64 build
|
||||||
|
RUN dpkg --add-architecture arm64 && \
|
||||||
|
apt update && \
|
||||||
|
apt install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libc6-arm64-cross libc6-dev-arm64-cross gzip && \
|
||||||
|
apt clean && \
|
||||||
|
rm -rf /var/lib/apt/lists/* && \
|
||||||
|
rustup target add aarch64-unknown-linux-gnu
|
||||||
|
|
||||||
|
### armhf deps
|
||||||
|
RUN dpkg --add-architecture armhf && \
|
||||||
|
apt update && \
|
||||||
|
apt install -y gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf libc6-armhf-cross libc6-dev-armhf-cross gzip && \
|
||||||
|
apt clean && \
|
||||||
|
rm -rf /var/lib/apt/lists/* && \
|
||||||
|
rustup target add armv7-unknown-linux-gnueabihf
|
||||||
|
|
||||||
|
### Add musl-gcc aarch64 and x86_64
|
||||||
RUN wget -c https://musl.cc/x86_64-linux-musl-cross.tgz && \
|
RUN wget -c https://musl.cc/x86_64-linux-musl-cross.tgz && \
|
||||||
tar zxf ./x86_64-linux-musl-cross.tgz -C /opt && \
|
tar zxf ./x86_64-linux-musl-cross.tgz -C /opt && \
|
||||||
wget -c https://musl.cc/aarch64-linux-musl-cross.tgz && \
|
wget -c https://musl.cc/aarch64-linux-musl-cross.tgz && \
|
||||||
tar zxf ./aarch64-linux-musl-cross.tgz -C /opt && \
|
tar zxf ./aarch64-linux-musl-cross.tgz -C /opt && \
|
||||||
wget -c http://musl.cc/armv7l-linux-musleabihf-cross.tgz && \
|
|
||||||
tar zxf ./armv7l-linux-musleabihf-cross.tgz -C /opt && \
|
|
||||||
rm ./x86_64-linux-musl-cross.tgz && \
|
rm ./x86_64-linux-musl-cross.tgz && \
|
||||||
rm ./aarch64-linux-musl-cross.tgz && \
|
rm ./aarch64-linux-musl-cross.tgz
|
||||||
rm ./armv7l-linux-musleabihf-cross.tgz
|
|
||||||
|
|
||||||
### Add musl target
|
### Add musl target
|
||||||
RUN rustup target add x86_64-unknown-linux-musl && \
|
RUN rustup target add x86_64-unknown-linux-musl && \
|
||||||
rustup target add aarch64-unknown-linux-musl && \
|
rustup target add aarch64-unknown-linux-musl
|
||||||
rustup target add armv7-unknown-linux-musleabihf && \
|
|
||||||
rustup target add x86_64-unknown-freebsd
|
|
||||||
|
|
||||||
|
|
||||||
CMD ["bash"]
|
CMD ["bash"]
|
||||||
|
|||||||
229
.github/workflows/docker-build-static.yml
vendored
229
.github/workflows/docker-build-static.yml
vendored
@@ -30,6 +30,7 @@ env:
|
|||||||
|
|
||||||
# build-ui , create/compile the web
|
# build-ui , create/compile the web
|
||||||
### install wasm
|
### install wasm
|
||||||
|
### install rollup
|
||||||
### run app/build.sh
|
### run app/build.sh
|
||||||
### upload artifacts
|
### upload artifacts
|
||||||
|
|
||||||
@@ -39,10 +40,10 @@ env:
|
|||||||
# GitHub actions randomly timeout when downloading musl-gcc, using custom dev image #
|
# GitHub actions randomly timeout when downloading musl-gcc, using custom dev image #
|
||||||
# Look into .github/workflows/Dockerfile.dev for development image details #
|
# Look into .github/workflows/Dockerfile.dev for development image details #
|
||||||
# Using lldap dev image based on https://hub.docker.com/_/rust and musl-gcc bundled #
|
# Using lldap dev image based on https://hub.docker.com/_/rust and musl-gcc bundled #
|
||||||
# lldap/rust-dev #
|
|
||||||
#######################################################################################
|
#######################################################################################
|
||||||
# Cargo build
|
### Cargo build
|
||||||
### armv7, aarch64 and amd64 is musl based
|
### aarch64 and amd64 is musl based
|
||||||
|
### armv7 is glibc based, musl had issue with time_t when cross compile https://github.com/rust-lang/libc/issues/1848
|
||||||
|
|
||||||
# build-ui,builds-armhf, build-aarch64, build-amd64 will upload artifacts will be used next job
|
# build-ui,builds-armhf, build-aarch64, build-amd64 will upload artifacts will be used next job
|
||||||
|
|
||||||
@@ -50,11 +51,12 @@ env:
|
|||||||
### will run lldap with postgres, mariadb and sqlite backend, do selfcheck command.
|
### will run lldap with postgres, mariadb and sqlite backend, do selfcheck command.
|
||||||
|
|
||||||
# Build docker image
|
# Build docker image
|
||||||
### Triplet docker image arch with debian and alpine base
|
### Triplet docker image arch with debian base
|
||||||
|
### amd64 & aarch64 with alpine base
|
||||||
# build-docker-image job will fetch artifacts and run Dockerfile.ci then push the image.
|
# build-docker-image job will fetch artifacts and run Dockerfile.ci then push the image.
|
||||||
### Look into .github/workflows/Dockerfile.ci.debian or .github/workflowds/Dockerfile.ci.alpine
|
### Look into .github/workflows/Dockerfile.ci.debian or .github/workflowds/Dockerfile.ci.alpine
|
||||||
|
|
||||||
# Create release artifacts
|
# create release artifacts
|
||||||
### Fetch artifacts
|
### Fetch artifacts
|
||||||
### Clean up web artifact
|
### Clean up web artifact
|
||||||
### Setup folder structure
|
### Setup folder structure
|
||||||
@@ -84,11 +86,11 @@ jobs:
|
|||||||
needs: pre_job
|
needs: pre_job
|
||||||
if: ${{ needs.pre_job.outputs.should_skip != 'true' || github.event_name == 'release' }}
|
if: ${{ needs.pre_job.outputs.should_skip != 'true' || github.event_name == 'release' }}
|
||||||
container:
|
container:
|
||||||
image: lldap/rust-dev:v81
|
image: nitnelave/rust-dev:latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v3.5.2
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
/usr/local/cargo/bin
|
/usr/local/cargo/bin
|
||||||
@@ -99,6 +101,8 @@ jobs:
|
|||||||
key: lldap-ui-${{ hashFiles('**/Cargo.lock') }}
|
key: lldap-ui-${{ hashFiles('**/Cargo.lock') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
lldap-ui-
|
lldap-ui-
|
||||||
|
- name: Install rollup (nodejs)
|
||||||
|
run: npm install -g rollup
|
||||||
- name: Add wasm target (rust)
|
- name: Add wasm target (rust)
|
||||||
run: rustup target add wasm32-unknown-unknown
|
run: rustup target add wasm32-unknown-unknown
|
||||||
- name: Install wasm-pack with cargo
|
- name: Install wasm-pack with cargo
|
||||||
@@ -110,7 +114,7 @@ jobs:
|
|||||||
- name: Check build path
|
- name: Check build path
|
||||||
run: ls -al app/
|
run: ls -al app/
|
||||||
- name: Upload ui artifacts
|
- name: Upload ui artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: ui
|
name: ui
|
||||||
path: app/
|
path: app/
|
||||||
@@ -121,19 +125,21 @@ jobs:
|
|||||||
needs: pre_job
|
needs: pre_job
|
||||||
if: ${{ needs.pre_job.outputs.should_skip != 'true' || github.event_name == 'release' }}
|
if: ${{ needs.pre_job.outputs.should_skip != 'true' || github.event_name == 'release' }}
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
matrix:
|
||||||
target: [armv7-unknown-linux-musleabihf, aarch64-unknown-linux-musl, x86_64-unknown-linux-musl]
|
target: [armv7-unknown-linux-gnueabihf, aarch64-unknown-linux-musl, x86_64-unknown-linux-musl]
|
||||||
container:
|
container:
|
||||||
image: lldap/rust-dev:v81
|
image: nitnelave/rust-dev:latest
|
||||||
env:
|
env:
|
||||||
|
CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER: arm-linux-gnueabihf-gcc
|
||||||
|
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER: aarch64-linux-musl-gcc
|
||||||
|
CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER: x86_64-linux-musl-gcc
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
RUSTFLAGS: -Ctarget-feature=+crt-static
|
RUSTFLAGS: -Ctarget-feature=+crt-static
|
||||||
CARGO_HOME: ${GITHUB_WORKSPACE}/.cargo
|
CARGO_HOME: ${GITHUB_WORKSPACE}/.cargo
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v3.5.2
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
.cargo/bin
|
.cargo/bin
|
||||||
@@ -149,17 +155,17 @@ jobs:
|
|||||||
- name: Check path
|
- name: Check path
|
||||||
run: ls -al target/release
|
run: ls -al target/release
|
||||||
- name: Upload ${{ matrix.target}} lldap artifacts
|
- name: Upload ${{ matrix.target}} lldap artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: ${{ matrix.target}}-lldap-bin
|
name: ${{ matrix.target}}-lldap-bin
|
||||||
path: target/${{ matrix.target }}/release/lldap
|
path: target/${{ matrix.target }}/release/lldap
|
||||||
- name: Upload ${{ matrix.target }} migration tool artifacts
|
- name: Upload ${{ matrix.target }} migration tool artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: ${{ matrix.target }}-lldap_migration_tool-bin
|
name: ${{ matrix.target }}-lldap_migration_tool-bin
|
||||||
path: target/${{ matrix.target }}/release/lldap_migration_tool
|
path: target/${{ matrix.target }}/release/lldap_migration_tool
|
||||||
- name: Upload ${{ matrix.target }} password tool artifacts
|
- name: Upload ${{ matrix.target }} password tool artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: ${{ matrix.target }}-lldap_set_password-bin
|
name: ${{ matrix.target }}-lldap_set_password-bin
|
||||||
path: target/${{ matrix.target }}/release/lldap_set_password
|
path: target/${{ matrix.target }}/release/lldap_set_password
|
||||||
@@ -180,7 +186,7 @@ jobs:
|
|||||||
MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: 1
|
MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: 1
|
||||||
options: >-
|
options: >-
|
||||||
--name mariadb
|
--name mariadb
|
||||||
--health-cmd="mariadb-admin ping" --health-interval=5s --health-timeout=2s --health-retries=3
|
--health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3
|
||||||
|
|
||||||
postgresql:
|
postgresql:
|
||||||
image: postgres:latest
|
image: postgres:latest
|
||||||
@@ -199,7 +205,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Download artifacts
|
- name: Download artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: x86_64-unknown-linux-musl-lldap-bin
|
name: x86_64-unknown-linux-musl-lldap-bin
|
||||||
path: bin/
|
path: bin/
|
||||||
@@ -275,7 +281,7 @@ jobs:
|
|||||||
MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: 1
|
MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: 1
|
||||||
options: >-
|
options: >-
|
||||||
--name mariadb
|
--name mariadb
|
||||||
--health-cmd="mariadb-admin ping" --health-interval=5s --health-timeout=2s --health-retries=3
|
--health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3
|
||||||
|
|
||||||
|
|
||||||
mysql:
|
mysql:
|
||||||
@@ -293,19 +299,14 @@ jobs:
|
|||||||
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout scripts
|
|
||||||
uses: actions/checkout@v4.2.2
|
|
||||||
with:
|
|
||||||
sparse-checkout: 'scripts'
|
|
||||||
|
|
||||||
- name: Download LLDAP artifacts
|
- name: Download LLDAP artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: x86_64-unknown-linux-musl-lldap-bin
|
name: x86_64-unknown-linux-musl-lldap-bin
|
||||||
path: bin/
|
path: bin/
|
||||||
|
|
||||||
- name: Download LLDAP set password
|
- name: Download LLDAP set password
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: x86_64-unknown-linux-musl-lldap_set_password-bin
|
name: x86_64-unknown-linux-musl-lldap_set_password-bin
|
||||||
path: bin/
|
path: bin/
|
||||||
@@ -346,8 +347,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Export and Converting to Postgress
|
- name: Export and Converting to Postgress
|
||||||
run: |
|
run: |
|
||||||
bash ./scripts/sqlite_dump_commands.sh | sqlite3 ./users.db > ./dump.sql
|
curl -L https://raw.githubusercontent.com/lldap/lldap/main/scripts/sqlite_dump_commands.sh -o helper.sh
|
||||||
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
|
chmod +x ./helper.sh
|
||||||
|
./helper.sh | sqlite3 ./users.db > ./dump.sql
|
||||||
|
sed -i -r -e "s/X'([[:xdigit:]]+'[^'])/'\\\x\\1/g" -e '1s/^/BEGIN;\n/' -e '$aCOMMIT;' ./dump.sql
|
||||||
|
|
||||||
- name: Create schema on postgres
|
- name: Create schema on postgres
|
||||||
run: |
|
run: |
|
||||||
@@ -355,14 +358,16 @@ jobs:
|
|||||||
|
|
||||||
- name: Copy converted db to postgress and import
|
- name: Copy converted db to postgress and import
|
||||||
run: |
|
run: |
|
||||||
|
docker ps -a
|
||||||
docker cp ./dump.sql postgresql:/tmp/dump.sql
|
docker cp ./dump.sql postgresql:/tmp/dump.sql
|
||||||
docker exec postgresql bash -c "psql -U lldapuser -d lldap < /tmp/dump.sql" | tee import.log
|
docker exec postgresql bash -c "psql -U lldapuser -d lldap < /tmp/dump.sql"
|
||||||
rm ./dump.sql
|
rm ./dump.sql
|
||||||
! grep ERROR import.log > /dev/null
|
|
||||||
|
|
||||||
- name: Export and Converting to mariadb
|
- name: Export and Converting to mariadb
|
||||||
run: |
|
run: |
|
||||||
bash ./scripts/sqlite_dump_commands.sh | sqlite3 ./users.db > ./dump.sql
|
curl -L https://raw.githubusercontent.com/lldap/lldap/main/scripts/sqlite_dump_commands.sh -o helper.sh
|
||||||
|
chmod +x ./helper.sh
|
||||||
|
./helper.sh | sqlite3 ./users.db > ./dump.sql
|
||||||
cp ./dump.sql ./dump-no-sed.sql
|
cp ./dump.sql ./dump-no-sed.sql
|
||||||
sed -i -r -e "s/([^']'[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{9})\+00:00'([^'])/\1'\2/g" \-e 's/^INSERT INTO "?([a-zA-Z0-9_]+)"?/INSERT INTO `\1`/' -e '1s/^/START TRANSACTION;\n/' -e '$aCOMMIT;' ./dump.sql
|
sed -i -r -e "s/([^']'[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{9})\+00:00'([^'])/\1'\2/g" \-e 's/^INSERT INTO "?([a-zA-Z0-9_]+)"?/INSERT INTO `\1`/' -e '1s/^/START TRANSACTION;\n/' -e '$aCOMMIT;' ./dump.sql
|
||||||
sed -i '1 i\SET FOREIGN_KEY_CHECKS = 0;' ./dump.sql
|
sed -i '1 i\SET FOREIGN_KEY_CHECKS = 0;' ./dump.sql
|
||||||
@@ -372,14 +377,16 @@ jobs:
|
|||||||
|
|
||||||
- name: Copy converted db to mariadb and import
|
- name: Copy converted db to mariadb and import
|
||||||
run: |
|
run: |
|
||||||
|
docker ps -a
|
||||||
docker cp ./dump.sql mariadb:/tmp/dump.sql
|
docker cp ./dump.sql mariadb:/tmp/dump.sql
|
||||||
docker exec mariadb bash -c "mariadb -ulldapuser -plldappass -f lldap < /tmp/dump.sql" | tee import.log
|
docker exec mariadb bash -c "mariadb -ulldapuser -plldappass -f lldap < /tmp/dump.sql"
|
||||||
rm ./dump.sql
|
rm ./dump.sql
|
||||||
! grep ERROR import.log > /dev/null
|
|
||||||
|
|
||||||
- name: Export and Converting to mysql
|
- name: Export and Converting to mysql
|
||||||
run: |
|
run: |
|
||||||
bash ./scripts/sqlite_dump_commands.sh | sqlite3 ./users.db > ./dump.sql
|
curl -L https://raw.githubusercontent.com/lldap/lldap/main/scripts/sqlite_dump_commands.sh -o helper.sh
|
||||||
|
chmod +x ./helper.sh
|
||||||
|
./helper.sh | sqlite3 ./users.db > ./dump.sql
|
||||||
sed -i -r -e 's/^INSERT INTO "?([a-zA-Z0-9_]+)"?/INSERT INTO `\1`/' -e '1s/^/START TRANSACTION;\n/' -e '$aCOMMIT;' ./dump.sql
|
sed -i -r -e 's/^INSERT INTO "?([a-zA-Z0-9_]+)"?/INSERT INTO `\1`/' -e '1s/^/START TRANSACTION;\n/' -e '$aCOMMIT;' ./dump.sql
|
||||||
sed -i '1 i\SET FOREIGN_KEY_CHECKS = 0;' ./dump.sql
|
sed -i '1 i\SET FOREIGN_KEY_CHECKS = 0;' ./dump.sql
|
||||||
|
|
||||||
@@ -388,10 +395,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Copy converted db to mysql and import
|
- name: Copy converted db to mysql and import
|
||||||
run: |
|
run: |
|
||||||
|
docker ps -a
|
||||||
docker cp ./dump.sql mysql:/tmp/dump.sql
|
docker cp ./dump.sql mysql:/tmp/dump.sql
|
||||||
docker exec mysql bash -c "mysql -ulldapuser -plldappass -f lldap < /tmp/dump.sql" | tee import.log
|
docker exec mysql bash -c "mysql -ulldapuser -plldappass -f lldap < /tmp/dump.sql"
|
||||||
rm ./dump.sql
|
rm ./dump.sql
|
||||||
! grep ERROR import.log > /dev/null
|
|
||||||
|
|
||||||
- name: Run lldap with postgres DB and healthcheck again
|
- name: Run lldap with postgres DB and healthcheck again
|
||||||
run: |
|
run: |
|
||||||
@@ -427,16 +434,12 @@ jobs:
|
|||||||
LLDAP_http_port: 17173
|
LLDAP_http_port: 17173
|
||||||
LLDAP_JWT_SECRET: somejwtsecret
|
LLDAP_JWT_SECRET: somejwtsecret
|
||||||
|
|
||||||
- name: Test Dummy User Postgres
|
- name: Test Dummy User
|
||||||
run: ldapsearch -H ldap://localhost:3891 -LLL -D "uid=dummyuser,ou=people,dc=example,dc=com" -w 'dummypassword' -s "One" -b "ou=people,dc=example,dc=com"
|
run: |
|
||||||
- name: Test Dummy User MariaDB
|
ldapsearch -H ldap://localhost:3891 -LLL -D "uid=dummyuser,ou=people,dc=example,dc=com" -w 'dummypassword' -s "One" -b "ou=people,dc=example,dc=com"
|
||||||
run: ldapsearch -H ldap://localhost:3892 -LLL -D "uid=dummyuser,ou=people,dc=example,dc=com" -w 'dummypassword' -s "One" -b "ou=people,dc=example,dc=com"
|
ldapsearch -H ldap://localhost:3892 -LLL -D "uid=dummyuser,ou=people,dc=example,dc=com" -w 'dummypassword' -s "One" -b "ou=people,dc=example,dc=com"
|
||||||
- name: Test Dummy User MySQL
|
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"
|
||||||
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:
|
build-docker-image:
|
||||||
needs: [build-ui, build-bin]
|
needs: [build-ui, build-bin]
|
||||||
name: Build Docker image
|
name: Build Docker image
|
||||||
@@ -446,7 +449,7 @@ jobs:
|
|||||||
container: ["debian","alpine"]
|
container: ["debian","alpine"]
|
||||||
include:
|
include:
|
||||||
- container: alpine
|
- container: alpine
|
||||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
platforms: linux/amd64,linux/arm64
|
||||||
tags: |
|
tags: |
|
||||||
type=ref,event=pr
|
type=ref,event=pr
|
||||||
type=semver,pattern=v{{version}}
|
type=semver,pattern=v{{version}}
|
||||||
@@ -459,8 +462,6 @@ jobs:
|
|||||||
type=raw,value=stable,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
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=stable,enable=${{ startsWith(github.ref, 'refs/tags/v') }},suffix=
|
||||||
type=raw,value=latest,enable={{ is_default_branch }},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
|
- container: debian
|
||||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||||
tags: |
|
tags: |
|
||||||
@@ -470,102 +471,31 @@ jobs:
|
|||||||
type=semver,pattern=v{{major}}.{{minor}}
|
type=semver,pattern=v{{major}}.{{minor}}
|
||||||
type=raw,value=latest,enable={{ is_default_branch }}
|
type=raw,value=latest,enable={{ is_default_branch }}
|
||||||
type=raw,value=stable,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
type=raw,value=stable,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||||
type=raw,value={{ date 'YYYY-MM-DD' }},enable={{ is_default_branch }}
|
|
||||||
services:
|
|
||||||
registry:
|
|
||||||
image: registry:2
|
|
||||||
ports:
|
|
||||||
- 5000:5000
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v3.5.2
|
||||||
|
|
||||||
- name: Download all artifacts
|
- name: Download all artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v3
|
||||||
with:
|
with:
|
||||||
path: bin
|
path: bin
|
||||||
|
|
||||||
- name: Download llap ui artifacts
|
- name: Download llap ui artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: ui
|
name: ui
|
||||||
path: web
|
path: web
|
||||||
|
|
||||||
- name: Setup QEMU
|
- name: Setup QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v2
|
||||||
- name: Setup buildx
|
- uses: docker/setup-buildx-action@v2
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
with:
|
|
||||||
driver-opts: network=host
|
|
||||||
|
|
||||||
- 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@v6
|
|
||||||
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: |
|
|
||||||
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: |
|
|
||||||
latest=false
|
|
||||||
suffix=-${{ matrix.container }}-rootless
|
|
||||||
tags: ${{ matrix.tags }}
|
|
||||||
|
|
||||||
- name: Docker ${{ matrix.container }} meta
|
- name: Docker ${{ matrix.container }} meta
|
||||||
id: meta-standard
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v4
|
||||||
with:
|
with:
|
||||||
# list of Docker images to use as base name for tags
|
# list of Docker images to use as base name for tags
|
||||||
images: |
|
images: |
|
||||||
@@ -580,15 +510,12 @@ jobs:
|
|||||||
# latest-alpine
|
# latest-alpine
|
||||||
# stable
|
# stable
|
||||||
# stable-alpine
|
# stable-alpine
|
||||||
# YYYY-MM-DD
|
|
||||||
# YYYY-MM-DD-alpine
|
|
||||||
#################
|
#################
|
||||||
# vX-debian
|
# vX-debian
|
||||||
# vX.Y-debian
|
# vX.Y-debian
|
||||||
# vX.Y.Z-debian
|
# vX.Y.Z-debian
|
||||||
# latest-debian
|
# latest-debian
|
||||||
# stable-debian
|
# stable-debian
|
||||||
# YYYY-MM-DD-debian
|
|
||||||
#################
|
#################
|
||||||
# Check matrix for tag list definition
|
# Check matrix for tag list definition
|
||||||
flavor: |
|
flavor: |
|
||||||
@@ -599,49 +526,39 @@ jobs:
|
|||||||
# Docker login to nitnelave/lldap and lldap/lldap
|
# Docker login to nitnelave/lldap and lldap/lldap
|
||||||
- name: Login to Nitnelave/LLDAP Docker Hub
|
- name: Login to Nitnelave/LLDAP Docker Hub
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: nitnelave
|
username: nitnelave
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build ${{ matrix.container }}-rootless Docker Image
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
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
|
|
||||||
|
|
||||||
### This docker build always the last, due :latest tag pushed multiple times, for whatever variants may added in future add docker build above this
|
########################################
|
||||||
|
#### docker image build ####
|
||||||
|
########################################
|
||||||
- name: Build ${{ matrix.container }} Docker Image
|
- name: Build ${{ matrix.container }} Docker Image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v4
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
platforms: ${{ matrix.platforms }}
|
platforms: ${{ matrix.platforms }}
|
||||||
file: ./.github/workflows/Dockerfile.ci.${{ matrix.container }}
|
file: ./.github/workflows/Dockerfile.ci.${{ matrix.container }}
|
||||||
tags: |
|
tags: |
|
||||||
${{ steps.meta-standard.outputs.tags }}
|
${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta-standard.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
cache-from: type=gha,mode=max
|
cache-from: type=gha,mode=max
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
- name: Update repo description
|
- name: Update repo description
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
uses: peter-evans/dockerhub-description@v4
|
uses: peter-evans/dockerhub-description@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||||
@@ -649,7 +566,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Update lldap repo description
|
- name: Update lldap repo description
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
uses: peter-evans/dockerhub-description@v4
|
uses: peter-evans/dockerhub-description@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||||
@@ -667,7 +584,7 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- name: Download all artifacts
|
- name: Download all artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v3
|
||||||
with:
|
with:
|
||||||
path: bin/
|
path: bin/
|
||||||
- name: Check file
|
- name: Check file
|
||||||
@@ -676,19 +593,19 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
mv bin/aarch64-unknown-linux-musl-lldap-bin/lldap bin/aarch64-lldap
|
mv bin/aarch64-unknown-linux-musl-lldap-bin/lldap bin/aarch64-lldap
|
||||||
mv bin/x86_64-unknown-linux-musl-lldap-bin/lldap bin/amd64-lldap
|
mv bin/x86_64-unknown-linux-musl-lldap-bin/lldap bin/amd64-lldap
|
||||||
mv bin/armv7-unknown-linux-musleabihf-lldap-bin/lldap bin/armhf-lldap
|
mv bin/armv7-unknown-linux-gnueabihf-lldap-bin/lldap bin/armhf-lldap
|
||||||
mv bin/aarch64-unknown-linux-musl-lldap_migration_tool-bin/lldap_migration_tool bin/aarch64-lldap_migration_tool
|
mv bin/aarch64-unknown-linux-musl-lldap_migration_tool-bin/lldap_migration_tool bin/aarch64-lldap_migration_tool
|
||||||
mv bin/x86_64-unknown-linux-musl-lldap_migration_tool-bin/lldap_migration_tool bin/amd64-lldap_migration_tool
|
mv bin/x86_64-unknown-linux-musl-lldap_migration_tool-bin/lldap_migration_tool bin/amd64-lldap_migration_tool
|
||||||
mv bin/armv7-unknown-linux-musleabihf-lldap_migration_tool-bin/lldap_migration_tool bin/armhf-lldap_migration_tool
|
mv bin/armv7-unknown-linux-gnueabihf-lldap_migration_tool-bin/lldap_migration_tool bin/armhf-lldap_migration_tool
|
||||||
mv bin/aarch64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password bin/aarch64-lldap_set_password
|
mv bin/aarch64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password bin/aarch64-lldap_set_password
|
||||||
mv bin/x86_64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password bin/amd64-lldap_set_password
|
mv bin/x86_64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password bin/amd64-lldap_set_password
|
||||||
mv bin/armv7-unknown-linux-musleabihf-lldap_set_password-bin/lldap_set_password bin/armhf-lldap_set_password
|
mv bin/armv7-unknown-linux-gnueabihf-lldap_set_password-bin/lldap_set_password bin/armhf-lldap_set_password
|
||||||
chmod +x bin/*-lldap
|
chmod +x bin/*-lldap
|
||||||
chmod +x bin/*-lldap_migration_tool
|
chmod +x bin/*-lldap_migration_tool
|
||||||
chmod +x bin/*-lldap_set_password
|
chmod +x bin/*-lldap_set_password
|
||||||
|
|
||||||
- name: Download llap ui artifacts
|
- name: Download llap ui artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: ui
|
name: ui
|
||||||
path: web
|
path: web
|
||||||
|
|||||||
20
.github/workflows/release-bot.yml
vendored
20
.github/workflows/release-bot.yml
vendored
@@ -1,20 +0,0 @@
|
|||||||
name: Release Bot
|
|
||||||
|
|
||||||
on:
|
|
||||||
release:
|
|
||||||
types: [published]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
comment:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
issues: write
|
|
||||||
pull-requests: write
|
|
||||||
steps:
|
|
||||||
- uses: nflaig/release-comment-on-pr@master
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.RELEASE_BOT_TOKEN }}
|
|
||||||
message: |
|
|
||||||
Thank you everyone for the contribution!
|
|
||||||
This feature is now available in the latest release, [${releaseTag}](${releaseUrl}).
|
|
||||||
You can support LLDAP by starring our repo, contributing some configuration examples and becoming a sponsor.
|
|
||||||
49
.github/workflows/rust.yml
vendored
49
.github/workflows/rust.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout sources
|
- name: Checkout sources
|
||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v3.5.2
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@v2
|
||||||
- name: Build
|
- name: Build
|
||||||
run: cargo build --verbose --workspace
|
run: cargo build --verbose --workspace
|
||||||
@@ -52,7 +52,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout sources
|
- name: Checkout sources
|
||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v3.5.2
|
||||||
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout sources
|
- name: Checkout sources
|
||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v3.5.2
|
||||||
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
|
||||||
@@ -81,14 +81,12 @@ jobs:
|
|||||||
|
|
||||||
coverage:
|
coverage:
|
||||||
name: Code coverage
|
name: Code coverage
|
||||||
needs:
|
needs: pre_job
|
||||||
- pre_job
|
|
||||||
- test
|
|
||||||
if: ${{ needs.pre_job.outputs.should_skip != 'true' || (github.event_name == 'push' && github.ref == 'refs/heads/main') }}
|
if: ${{ needs.pre_job.outputs.should_skip != 'true' || (github.event_name == 'push' && github.ref == 'refs/heads/main') }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout sources
|
- name: Checkout sources
|
||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v3.5.2
|
||||||
|
|
||||||
- name: Install Rust
|
- 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
|
run: rustup toolchain install nightly --component llvm-tools-preview && rustup component add llvm-tools-preview --toolchain stable-x86_64-unknown-linux-gnu
|
||||||
@@ -101,10 +99,41 @@ jobs:
|
|||||||
run: cargo llvm-cov --workspace --no-report
|
run: cargo llvm-cov --workspace --no-report
|
||||||
- name: Aggregate reports
|
- name: Aggregate reports
|
||||||
run: cargo llvm-cov --no-run --lcov --output-path lcov.info
|
run: cargo llvm-cov --no-run --lcov --output-path lcov.info
|
||||||
- name: Upload coverage to Codecov (main)
|
- name: Upload coverage to Codecov
|
||||||
uses: codecov/codecov-action@v4
|
uses: codecov/codecov-action@v3
|
||||||
|
if: github.ref != 'refs/heads/main' || github.event_name != 'push'
|
||||||
|
with:
|
||||||
|
files: lcov.info
|
||||||
|
fail_ci_if_error: true
|
||||||
|
- name: Upload coverage to Codecov (main)
|
||||||
|
uses: codecov/codecov-action@v3
|
||||||
|
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||||
with:
|
with:
|
||||||
files: lcov.info
|
files: lcov.info
|
||||||
fail_ci_if_error: true
|
fail_ci_if_error: true
|
||||||
codecov_yml_path: .github/codecov.yml
|
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|
||||||
|
publish-crates:
|
||||||
|
name: Publish on crates.io
|
||||||
|
if: ${{ needs.pre_job.outputs.should_skip != 'true' || (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'release' }}
|
||||||
|
needs: pre_job
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
target: [lldap_auth, lldap, lldap_app, lldap_set_password, lldap_migration_tool]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3.5.2
|
||||||
|
- uses: actions-rs/toolchain@v1
|
||||||
|
with:
|
||||||
|
toolchain: stable
|
||||||
|
override: true
|
||||||
|
- name: Publish ${{ matrix.target }} crate
|
||||||
|
uses: katyo/publish-crates@v2
|
||||||
|
with:
|
||||||
|
args: -p ${{ matrix.target }}
|
||||||
|
dry-run: ${{ github.event_name != 'release' }}
|
||||||
|
check-repo: ${{ github.event_name != 'pull_request' }}
|
||||||
|
registry-token: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
||||||
|
ignore-unpublished-changes: ${{ github.event_name != 'release' }}
|
||||||
|
|
||||||
|
|||||||
145
CHANGELOG.md
145
CHANGELOG.md
@@ -5,153 +5,10 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [0.6.0] 2024-11-09
|
|
||||||
|
|
||||||
### Breaking
|
|
||||||
|
|
||||||
- The endpoint `/auth/reset/step1` is now `POST` instead of `GET` (#704)
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Custom attributes are now supported (#67) ! You can add new fields (string, integers, JPEG or dates) to users and query them. That unlocks many integrations with other services, and allows for a deeper/more customized integration. Special thanks to @pixelrazor and @bojidar-bg for their help with the UI.
|
|
||||||
- Custom object classes (for all users/groups) can now be added (#833)
|
|
||||||
- Barebones support for Paged Results Control (no paging, no respect for windows, but a correct response with all the results) (#698)
|
|
||||||
- A daily docker image is tagged and released. (#613)
|
|
||||||
- A bootstrap script allows reading the list of users/groups from a file and making sure the server contains exactly the same thing. (#654)
|
|
||||||
- Make it possible to serve lldap behind a sub-path in (#752)
|
|
||||||
- LLDAP can now be found on a custom package repository for opensuse, fedora, ubuntu, debian and centos ([Repository link](https://software.opensuse.org//download.html?project=home%3AMasgalor%3ALLDAP&package=lldap)). Thanks @Masgalor for setting it up and maintaining it.
|
|
||||||
- There's now an option to force reset the admin password (#748) optionally on every restart (#959)
|
|
||||||
- There's a rootless docker container (#755)
|
|
||||||
- entryDN is now supported (#780)
|
|
||||||
- Unknown LDAP controls are now detected and ignored (#787, #799)
|
|
||||||
- A community-developed CLI for scripting (#793)
|
|
||||||
- Added a way to print raw logs to debug long-running sessions (#992)
|
|
||||||
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- The official docker repository is now `lldap/lldap`
|
|
||||||
- Removed password length limitation in lldap_set_password tool
|
|
||||||
- Group names and emails are now case insensitive, but keep their casing (#666)
|
|
||||||
- Better error messages (and exit code (#745)) when changing the private key (#778, #1008), using the wrong SMTP port (#970), using the wrong env variables (#972)
|
|
||||||
- Allow `member=` filters with plain user names (not full DNs) (#949)
|
|
||||||
- Correctly detect and refuse anonymous binds (#974)
|
|
||||||
- Clearer logging (#971, #981, #982)
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Logging out applies globally, not just in the local browser. (#721)
|
|
||||||
- It's no longer possible to create the same user twice (#745)
|
|
||||||
- Fix wide substring filters (#738)
|
|
||||||
- Don't log the database password if provided in the connection URL (#735)
|
|
||||||
- Fix a panic when postgres uses a different collation (#821)
|
|
||||||
- The UI now defaults to the user ID for users with no display names (#843)
|
|
||||||
- Fix searching for users with more than one `memberOf` filter (#872)
|
|
||||||
- Fix compilation on Windows (#932) and Illumos (#964)
|
|
||||||
- The UI now correctly detects whether password resets are enabled. (#753)
|
|
||||||
- Fix a missing lowercasing of username when changing passwords through LDAP (#1012)
|
|
||||||
- Fix SQLite writers erroring when racing (#1021)
|
|
||||||
- LDAP sessions no longer buffer their logs until unbind, causing memory leaks (#1025)
|
|
||||||
|
|
||||||
### Performance
|
|
||||||
|
|
||||||
- Only expand attributes once per query, not per result (#687)
|
|
||||||
|
|
||||||
### Security
|
|
||||||
|
|
||||||
- When asked to send a password reset to an unknown email, sleep for 3 seconds and don't print the email in the error (#887)
|
|
||||||
|
|
||||||
### New services
|
|
||||||
|
|
||||||
Linux user accounts can now be managed by LLDAP, using PAM and nslcd.
|
|
||||||
|
|
||||||
- Apereo CAS server
|
|
||||||
- Carpal
|
|
||||||
- Gitlab
|
|
||||||
- Grocy
|
|
||||||
- Harbor
|
|
||||||
- Home Assistant
|
|
||||||
- Jenkins
|
|
||||||
- Kasm
|
|
||||||
- Maddy
|
|
||||||
- Mastodon
|
|
||||||
- Metabase
|
|
||||||
- MegaRAC-BMC
|
|
||||||
- Netbox
|
|
||||||
- OCIS
|
|
||||||
- Prosody
|
|
||||||
- Radicale
|
|
||||||
- SonarQube
|
|
||||||
- Traccar
|
|
||||||
- Zitadel
|
|
||||||
|
|
||||||
## [0.5.0] 2023-09-14
|
|
||||||
|
|
||||||
### Breaking
|
|
||||||
|
|
||||||
- Emails and UUIDs are now enforced to be unique.
|
|
||||||
- If you have several users with the same email, you'll have to disambiguate
|
|
||||||
them. You can do that by either issuing SQL commands directly
|
|
||||||
(`UPDATE users SET email = 'x@x' WHERE user_id = 'bob';`), or by reverting
|
|
||||||
to a 0.4.x version of LLDAP and editing the user through the web UI.
|
|
||||||
An error will prevent LLDAP 0.5+ from starting otherwise.
|
|
||||||
- This was done to prevent account takeover for systems that allow to
|
|
||||||
login via email.
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- The server private key can be set as a seed from an env variable (#504).
|
|
||||||
- This is especially useful when you have multiple containers, they don't
|
|
||||||
need to share a writeable folder.
|
|
||||||
- Added support for changing the password through a plain LDAP Modify
|
|
||||||
operation (as opposed to an extended operation), to allow Jellyfin
|
|
||||||
to change password (#620).
|
|
||||||
- Allow creating a user with multiple objectClass (#612).
|
|
||||||
- Emails now have a message ID (#608).
|
|
||||||
- Added a warning for browsers that have WASM/JS disabled (#639).
|
|
||||||
- Added support for querying OUs in LDAP (#669).
|
|
||||||
- Added a button to clear the avatar in the UI (#358).
|
|
||||||
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Groups are now sorted by name in the web UI (#623).
|
|
||||||
- ARM build now uses musl (#584).
|
|
||||||
- Improved logging.
|
|
||||||
- Default admin user is only created if there are no admins (#563).
|
|
||||||
- That allows you to remove the default admin, making it harder to
|
|
||||||
bruteforce.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Fixed URL parsing with a trailing slash in the password setting utility
|
|
||||||
(#597).
|
|
||||||
|
|
||||||
In addition to all that, there was significant progress towards #67,
|
|
||||||
user-defined attributes. That complex feature will unblock integration with many
|
|
||||||
systems, including PAM authentication.
|
|
||||||
|
|
||||||
### New services
|
|
||||||
|
|
||||||
- Ejabberd
|
|
||||||
- Ergo
|
|
||||||
- LibreNMS
|
|
||||||
- Mealie
|
|
||||||
- MinIO
|
|
||||||
- OpnSense
|
|
||||||
- PfSense
|
|
||||||
- PowerDnsAdmin
|
|
||||||
- Proxmox
|
|
||||||
- Squid
|
|
||||||
- Tandoor recipes
|
|
||||||
- TheLounge
|
|
||||||
- Zabbix-web
|
|
||||||
- Zulip
|
|
||||||
|
|
||||||
## [0.4.3] 2023-04-11
|
## [0.4.3] 2023-04-11
|
||||||
|
|
||||||
The repository has changed from `nitnelave/lldap` to `lldap/lldap`, both on GitHub
|
The repository has changed from `nitnelave/lldap` to `lldap/lldap`, both on GitHub
|
||||||
and on DockerHub (although we will keep publishing the images to
|
and on DockerHub (although we will keep publishing the images to
|
||||||
`nitnelave/lldap` for the foreseeable future). All data on GitHub has been
|
`nitnelave/lldap` for the foreseeable future). All data on GitHub has been
|
||||||
migrated, and the new docker images are available both on DockerHub and on the
|
migrated, and the new docker images are available both on DockerHub and on the
|
||||||
GHCR under `lldap/lldap`.
|
GHCR under `lldap/lldap`.
|
||||||
|
|||||||
@@ -1,97 +0,0 @@
|
|||||||
# 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.
|
|
||||||
2936
Cargo.lock
generated
2936
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -9,8 +9,6 @@ members = [
|
|||||||
|
|
||||||
default-members = ["server"]
|
default-members = ["server"]
|
||||||
|
|
||||||
resolver = "2"
|
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
lto = true
|
lto = true
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ RUN cargo build --release -p lldap -p lldap_migration_tool -p lldap_set_password
|
|||||||
&& ./app/build.sh
|
&& ./app/build.sh
|
||||||
|
|
||||||
# Final image
|
# Final image
|
||||||
FROM alpine:3.19
|
FROM alpine:3.16
|
||||||
|
|
||||||
ENV GOSU_VERSION 1.14
|
ENV GOSU_VERSION 1.14
|
||||||
# Fetch gosu from git
|
# Fetch gosu from git
|
||||||
@@ -80,7 +80,6 @@ COPY --from=builder /app/app/static app/static
|
|||||||
COPY --from=builder /app/app/pkg app/pkg
|
COPY --from=builder /app/app/pkg app/pkg
|
||||||
COPY --from=builder /app/target/release/lldap /app/target/release/lldap_migration_tool /app/target/release/lldap_set_password ./
|
COPY --from=builder /app/target/release/lldap /app/target/release/lldap_migration_tool /app/target/release/lldap_set_password ./
|
||||||
COPY docker-entrypoint.sh lldap_config.docker_template.toml ./
|
COPY docker-entrypoint.sh lldap_config.docker_template.toml ./
|
||||||
COPY scripts/bootstrap.sh ./
|
|
||||||
|
|
||||||
RUN set -x \
|
RUN set -x \
|
||||||
&& apk add --no-cache bash tzdata \
|
&& apk add --no-cache bash tzdata \
|
||||||
|
|||||||
393
README.md
393
README.md
@@ -5,9 +5,9 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/lldap/lldap/actions/workflows/rust.yml?query=branch%3Amain">
|
<a href="https://github.com/nitnelave/lldap/actions/workflows/rust.yml?query=branch%3Amain">
|
||||||
<img
|
<img
|
||||||
src="https://github.com/lldap/lldap/actions/workflows/rust.yml/badge.svg"
|
src="https://github.com/nitnelave/lldap/actions/workflows/rust.yml/badge.svg"
|
||||||
alt="Build"/>
|
alt="Build"/>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://discord.gg/h5PEdRMNyP">
|
<a href="https://discord.gg/h5PEdRMNyP">
|
||||||
@@ -36,26 +36,15 @@
|
|||||||
- [About](#about)
|
- [About](#about)
|
||||||
- [Installation](#installation)
|
- [Installation](#installation)
|
||||||
- [With Docker](#with-docker)
|
- [With Docker](#with-docker)
|
||||||
- [With Kubernetes](#with-kubernetes)
|
|
||||||
- [From a package repository](#from-a-package-repository)
|
|
||||||
- [With FreeBSD](#with-freebsd)
|
|
||||||
- [From source](#from-source)
|
- [From source](#from-source)
|
||||||
- [Backend](#backend)
|
|
||||||
- [Frontend](#frontend)
|
|
||||||
- [Cross-compilation](#cross-compilation)
|
- [Cross-compilation](#cross-compilation)
|
||||||
- [Usage](#usage)
|
|
||||||
- [Recommended architecture](#recommended-architecture)
|
|
||||||
- [Client configuration](#client-configuration)
|
- [Client configuration](#client-configuration)
|
||||||
- [Compatible services](#compatible-services)
|
- [Compatible services](#compatible-services)
|
||||||
- [General configuration guide](#general-configuration-guide)
|
- [General configuration guide](#general-configuration-guide)
|
||||||
- [Integration with OS's](#integration-with-oss)
|
|
||||||
- [Sample client configurations](#sample-client-configurations)
|
- [Sample client configurations](#sample-client-configurations)
|
||||||
- [Incompatible services](#incompatible-services)
|
|
||||||
- [Migrating from SQLite](#migrating-from-sqlite)
|
|
||||||
- [Comparisons with other services](#comparisons-with-other-services)
|
- [Comparisons with other services](#comparisons-with-other-services)
|
||||||
- [vs OpenLDAP](#vs-openldap)
|
- [vs OpenLDAP](#vs-openldap)
|
||||||
- [vs FreeIPA](#vs-freeipa)
|
- [vs FreeIPA](#vs-freeipa)
|
||||||
- [vs Kanidm](#vs-kanidm)
|
|
||||||
- [I can't log in!](#i-cant-log-in)
|
- [I can't log in!](#i-cant-log-in)
|
||||||
- [Contributions](#contributions)
|
- [Contributions](#contributions)
|
||||||
|
|
||||||
@@ -67,7 +56,7 @@ many backends, from KeyCloak to Authelia to Nextcloud and
|
|||||||
[more](#compatible-services)!
|
[more](#compatible-services)!
|
||||||
|
|
||||||
<img
|
<img
|
||||||
src="https://raw.githubusercontent.com/lldap/lldap/master/screenshot.png"
|
src="https://raw.githubusercontent.com/nitnelave/lldap/master/screenshot.png"
|
||||||
alt="Screenshot of the user list page"
|
alt="Screenshot of the user list page"
|
||||||
width="50%"
|
width="50%"
|
||||||
align="right"
|
align="right"
|
||||||
@@ -100,10 +89,9 @@ MySQL/MariaDB or PostgreSQL.
|
|||||||
|
|
||||||
### With Docker
|
### With Docker
|
||||||
|
|
||||||
The image is available at `lldap/lldap`. You should persist the `/data`
|
The image is available at `nitnelave/lldap`. You should persist the `/data`
|
||||||
folder, which contains your configuration and the SQLite database (you can
|
folder, which contains your configuration, the database and the private key
|
||||||
remove this step if you use a different DB and configure with environment
|
file.
|
||||||
variables only).
|
|
||||||
|
|
||||||
Configure the server by copying the `lldap_config.docker_template.toml` to
|
Configure the server by copying the `lldap_config.docker_template.toml` to
|
||||||
`/data/lldap_config.toml` and updating the configuration values (especially the
|
`/data/lldap_config.toml` and updating the configuration values (especially the
|
||||||
@@ -111,12 +99,10 @@ Configure the server by copying the `lldap_config.docker_template.toml` to
|
|||||||
Environment variables should be prefixed with `LLDAP_` to override the
|
Environment variables should be prefixed with `LLDAP_` to override the
|
||||||
configuration.
|
configuration.
|
||||||
|
|
||||||
If the `lldap_config.toml` doesn't exist when starting up, LLDAP will use
|
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.
|
||||||
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
|
Secrets can also be set through a file. The filename should be specified by the
|
||||||
variables `LLDAP_JWT_SECRET_FILE` or `LLDAP_KEY_SEED_FILE`, and the file
|
variables `LLDAP_JWT_SECRET_FILE` or `LLDAP_LDAP_USER_PASS_FILE`, and the file
|
||||||
contents are loaded into the respective configuration parameters. Note that
|
contents are loaded into the respective configuration parameters. Note that
|
||||||
`_FILE` variables take precedence.
|
`_FILE` variables take precedence.
|
||||||
|
|
||||||
@@ -126,7 +112,6 @@ Example for docker compose:
|
|||||||
- `:latest` tag image contains recently pushed code or feature tests, in which some instability can be expected.
|
- `: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 `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.
|
- If no `TZ` is set, default `UTC` timezone will be used.
|
||||||
- You can generate the secrets by running `./generate_secrets.sh`
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
version: "3"
|
version: "3"
|
||||||
@@ -137,12 +122,10 @@ volumes:
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
lldap:
|
lldap:
|
||||||
image: lldap/lldap:stable
|
image: nitnelave/lldap:stable
|
||||||
ports:
|
ports:
|
||||||
# For LDAP, not recommended to expose, see Usage section.
|
# For LDAP
|
||||||
#- "3890:3890"
|
- "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
|
# For the web front-end
|
||||||
- "17170:17170"
|
- "17170:17170"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -154,24 +137,11 @@ services:
|
|||||||
- GID=####
|
- GID=####
|
||||||
- TZ=####/####
|
- TZ=####/####
|
||||||
- LLDAP_JWT_SECRET=REPLACE_WITH_RANDOM
|
- LLDAP_JWT_SECRET=REPLACE_WITH_RANDOM
|
||||||
- LLDAP_KEY_SEED=REPLACE_WITH_RANDOM
|
- LLDAP_LDAP_USER_PASS=REPLACE_WITH_PASSWORD
|
||||||
- LLDAP_LDAP_BASE_DN=dc=example,dc=com
|
- LLDAP_LDAP_BASE_DN=dc=example,dc=com
|
||||||
# If using LDAPS, set enabled true and configure cert and key path
|
|
||||||
# - LLDAP_LDAPS_OPTIONS__ENABLED=true
|
|
||||||
# - LLDAP_LDAPS_OPTIONS__CERT_FILE=/path/to/certfile.crt
|
|
||||||
# - LLDAP_LDAPS_OPTIONS__KEY_FILE=/path/to/keyfile.key
|
|
||||||
# You can also set a different database:
|
# You can also set a different database:
|
||||||
# - LLDAP_DATABASE_URL=mysql://mysql-user:password@mysql-server/my-database
|
# - LLDAP_DATABASE_URL=mysql://mysql-user:password@mysql-server/my-database
|
||||||
# - LLDAP_DATABASE_URL=postgres://postgres-user:password@postgres-server/my-database
|
# - LLDAP_DATABASE_URL=postgres://postgres-user:password@postgres-server/my-database
|
||||||
# If using SMTP, set the following variables
|
|
||||||
# - LLDAP_SMTP_OPTIONS__ENABLE_PASSWORD_RESET=true
|
|
||||||
# - LLDAP_SMTP_OPTIONS__SERVER=smtp.example.com
|
|
||||||
# - LLDAP_SMTP_OPTIONS__PORT=465 # Check your smtp providor's documentation for this setting
|
|
||||||
# - LLDAP_SMTP_OPTIONS__SMTP_ENCRYPTION=TLS # How the connection is encrypted, either "NONE" (no encryption, port 25), "TLS" (sometimes called SSL, port 465) or "STARTTLS" (sometimes called TLS, port 587).
|
|
||||||
# - LLDAP_SMTP_OPTIONS__USER=no-reply@example.com # The SMTP user, usually your email address
|
|
||||||
# - LLDAP_SMTP_OPTIONS__PASSWORD=PasswordGoesHere # The SMTP password
|
|
||||||
# - LLDAP_SMTP_OPTIONS__FROM=no-reply <no-reply@example.com> # The header field, optional: how the sender appears in the email. The first is a free-form name, followed by an email between <>.
|
|
||||||
# - LLDAP_SMTP_OPTIONS__TO=admin <admin@example.com> # Same for reply-to, optional.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Then the service will listen on two ports, one for LDAP and one for the web
|
Then the service will listen on two ports, one for LDAP and one for the web
|
||||||
@@ -181,239 +151,6 @@ front-end.
|
|||||||
|
|
||||||
See https://github.com/Evantage-WS/lldap-kubernetes for a LLDAP deployment for Kubernetes
|
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.
|
|
||||||
|
|
||||||
Each package offers a [systemd service](https://wiki.archlinux.org/title/systemd#Using_units) `lldap.service` to (auto-)start and stop lldap.<br>
|
|
||||||
When using the distributed packages, the default login is `admin/password`. You can change that from the web UI after starting the service.
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>Arch</b></summary>
|
|
||||||
<br>
|
|
||||||
Arch Linux offers unofficial support through the <a href="https://wiki.archlinux.org/title/Arch_User_Repository">Arch User Repository (AUR)</a>.<br>
|
|
||||||
The package descriptions can be used <a href="https://wiki.archlinux.org/title/Arch_User_Repository#Getting_started">to create and install packages</a>.<br><br>
|
|
||||||
Maintainer: <a href="https://github.com/Zepmann">@Zepmann</a><br>
|
|
||||||
Support: <a href="https://github.com/lldap/lldap/discussions">Discussions</a><br>
|
|
||||||
Package repository: <a href="https://aur.archlinux.org/packages">Arch user repository</a><br>
|
|
||||||
<table>
|
|
||||||
<tr>
|
|
||||||
<td>Available packages:</td>
|
|
||||||
<td><a href="https://aur.archlinux.org/packages/lldap">lldap</a></td>
|
|
||||||
<td>Builds the latest stable version.</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td></td>
|
|
||||||
<td><a href="https://aur.archlinux.org/packages/lldap-bin">lldap-bin</a></td>
|
|
||||||
<td>Uses the latest pre-compiled binaries from the <a href="https://aur.archlinux.org/packages/lldap-bin">releases in this repository</a>.<br>
|
|
||||||
This package is recommended if you want to run lldap on a system with limited resources.</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td></td>
|
|
||||||
<td><a href="https://aur.archlinux.org/packages/lldap-git">lldap-git</a></td>
|
|
||||||
<td>Builds the latest main branch code.</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
LLDPA configuration file: /etc/lldap.toml<br>
|
|
||||||
</details>
|
|
||||||
<details>
|
|
||||||
<summary><b>Debian</b></summary>
|
|
||||||
<br>
|
|
||||||
Unofficial Debian support is offered through the <a href="https://build.opensuse.org/">openSUSE Build Service</a>.<br><br>
|
|
||||||
Maintainer: <a href="https://github.com/Masgalor">@Masgalor</a><br>
|
|
||||||
Support: <a href="https://codeberg.org/Masgalor/LLDAP-Packaging/issues">Codeberg</a>, <a href="https://github.com/lldap/lldap/discussions">Discussions</a><br>
|
|
||||||
Package repository: <a href="https://software.opensuse.org//download.html?project=home%3AMasgalor%3ALLDAP&package=lldap">SUSE openBuildService</a><br>
|
|
||||||
<table>
|
|
||||||
<tr>
|
|
||||||
<td>Available packages:</td>
|
|
||||||
<td>lldap</td>
|
|
||||||
<td>Light LDAP server for authentication.</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td></td>
|
|
||||||
<td>lldap-extras</td>
|
|
||||||
<td>Meta-Package for LLDAP and its tools and extensions.</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td></td>
|
|
||||||
<td>lldap-migration-tool</td>
|
|
||||||
<td>CLI migration tool to go from OpenLDAP to LLDAP.</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td></td>
|
|
||||||
<td>lldap-set-password</td>
|
|
||||||
<td>CLI tool to set a user password in LLDAP.</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td></td>
|
|
||||||
<td>lldap-cli</td>
|
|
||||||
<td>LLDAP-CLI is an unofficial command line interface for LLDAP.</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
LLDPA configuration file: /etc/lldap/lldap_config.toml<br>
|
|
||||||
</details>
|
|
||||||
<details>
|
|
||||||
<summary><b>CentOS</b></summary>
|
|
||||||
<br>
|
|
||||||
Unofficial CentOS support is offered through the <a href="https://build.opensuse.org/">openSUSE Build Service</a>.<br><br>
|
|
||||||
Maintainer: <a href="https://github.com/Masgalor">@Masgalor</a><br>
|
|
||||||
Support: <a href="https://codeberg.org/Masgalor/LLDAP-Packaging/issues">Codeberg</a>, <a href="https://github.com/lldap/lldap/discussions">Discussions</a><br>
|
|
||||||
Package repository: <a href="https://software.opensuse.org//download.html?project=home%3AMasgalor%3ALLDAP&package=lldap">SUSE openBuildService</a><br>
|
|
||||||
<table>
|
|
||||||
<tr>
|
|
||||||
<td>Available packages:</td>
|
|
||||||
<td>lldap</td>
|
|
||||||
<td>Light LDAP server for authentication.</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td></td>
|
|
||||||
<td>lldap-extras</td>
|
|
||||||
<td>Meta-Package for LLDAP and its tools and extensions.</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td></td>
|
|
||||||
<td>lldap-migration-tool</td>
|
|
||||||
<td>CLI migration tool to go from OpenLDAP to LLDAP.</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td></td>
|
|
||||||
<td>lldap-set-password</td>
|
|
||||||
<td>CLI tool to set a user password in LLDAP.</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td></td>
|
|
||||||
<td>lldap-cli</td>
|
|
||||||
<td>LLDAP-CLI is an unofficial command line interface for LLDAP.</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
LLDPA configuration file: /etc/lldap/lldap_config.toml<br>
|
|
||||||
</details>
|
|
||||||
<details>
|
|
||||||
<summary><b>Fedora</b></summary>
|
|
||||||
<br>
|
|
||||||
Unofficial Fedora support is offered through the <a href="https://build.opensuse.org/">openSUSE Build Service</a>.<br><br>
|
|
||||||
Maintainer: <a href="https://github.com/Masgalor">@Masgalor</a><br>
|
|
||||||
Support: <a href="https://codeberg.org/Masgalor/LLDAP-Packaging/issues">Codeberg</a>, <a href="https://github.com/lldap/lldap/discussions">Discussions</a><br>
|
|
||||||
Package repository: <a href="https://software.opensuse.org//download.html?project=home%3AMasgalor%3ALLDAP&package=lldap">SUSE openBuildService</a><br>
|
|
||||||
<table>
|
|
||||||
<tr>
|
|
||||||
<td>Available packages:</td>
|
|
||||||
<td>lldap</td>
|
|
||||||
<td>Light LDAP server for authentication.</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td></td>
|
|
||||||
<td>lldap-extras</td>
|
|
||||||
<td>Meta-Package for LLDAP and its tools and extensions.</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td></td>
|
|
||||||
<td>lldap-migration-tool</td>
|
|
||||||
<td>CLI migration tool to go from OpenLDAP to LLDAP.</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td></td>
|
|
||||||
<td>lldap-set-password</td>
|
|
||||||
<td>CLI tool to set a user password in LLDAP.</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td></td>
|
|
||||||
<td>lldap-cli</td>
|
|
||||||
<td>LLDAP-CLI is an unofficial command line interface for LLDAP.</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
LLDPA configuration file: /etc/lldap/lldap_config.toml<br>
|
|
||||||
</details>
|
|
||||||
<details>
|
|
||||||
<summary><b>OpenSUSE</b></summary>
|
|
||||||
<br>
|
|
||||||
Unofficial OpenSUSE support is offered through the <a href="https://build.opensuse.org/">openSUSE Build Service</a>.<br><br>
|
|
||||||
Maintainer: <a href="https://github.com/Masgalor">@Masgalor</a><br>
|
|
||||||
Support: <a href="https://codeberg.org/Masgalor/LLDAP-Packaging/issues">Codeberg</a>, <a href="https://github.com/lldap/lldap/discussions">Discussions</a><br>
|
|
||||||
Package repository: <a href="https://software.opensuse.org//download.html?project=home%3AMasgalor%3ALLDAP&package=lldap">SUSE openBuildService</a><br>
|
|
||||||
<table>
|
|
||||||
<tr>
|
|
||||||
<td>Available packages:</td>
|
|
||||||
<td>lldap</td>
|
|
||||||
<td>Light LDAP server for authentication.</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td></td>
|
|
||||||
<td>lldap-extras</td>
|
|
||||||
<td>Meta-Package for LLDAP and its tools and extensions.</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td></td>
|
|
||||||
<td>lldap-migration-tool</td>
|
|
||||||
<td>CLI migration tool to go from OpenLDAP to LLDAP.</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td></td>
|
|
||||||
<td>lldap-set-password</td>
|
|
||||||
<td>CLI tool to set a user password in LLDAP.</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td></td>
|
|
||||||
<td>lldap-cli</td>
|
|
||||||
<td>LLDAP-CLI is an unofficial command line interface for LLDAP.</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
LLDPA configuration file: /etc/lldap/lldap_config.toml<br>
|
|
||||||
</details>
|
|
||||||
<details>
|
|
||||||
<summary><b>Ubuntu</b></summary>
|
|
||||||
<br>
|
|
||||||
Unofficial Ubuntu support is offered through the <a href="https://build.opensuse.org/">openSUSE Build Service</a>.<br><br>
|
|
||||||
Maintainer: <a href="https://github.com/Masgalor">@Masgalor</a><br>
|
|
||||||
Support: <a href="https://codeberg.org/Masgalor/LLDAP-Packaging/issues">Codeberg</a>, <a href="https://github.com/lldap/lldap/discussions">Discussions</a><br>
|
|
||||||
Package repository: <a href="https://software.opensuse.org//download.html?project=home%3AMasgalor%3ALLDAP&package=lldap">SUSE openBuildService</a><br>
|
|
||||||
<table>
|
|
||||||
<tr>
|
|
||||||
<td>Available packages:</td>
|
|
||||||
<td>lldap</td>
|
|
||||||
<td>Light LDAP server for authentication.</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td></td>
|
|
||||||
<td>lldap-extras</td>
|
|
||||||
<td>Meta-Package for LLDAP and its tools and extensions.</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td></td>
|
|
||||||
<td>lldap-migration-tool</td>
|
|
||||||
<td>CLI migration tool to go from OpenLDAP to LLDAP.</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td></td>
|
|
||||||
<td>lldap-set-password</td>
|
|
||||||
<td>CLI tool to set a user password in LLDAP.</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td></td>
|
|
||||||
<td>lldap-cli</td>
|
|
||||||
<td>LLDAP-CLI is an unofficial command line interface for LLDAP.</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
LLDPA configuration file: /etc/lldap/lldap_config.toml<br>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
### With FreeBSD
|
|
||||||
|
|
||||||
You can also install it as a rc.d service in FreeBSD, see
|
|
||||||
[FreeBSD-install.md](example_configs/freebsd/freebsd-install.md).
|
|
||||||
|
|
||||||
The rc.d script file
|
|
||||||
[rc.d_lldap](example_configs/freebsd/rc.d_lldap).
|
|
||||||
|
|
||||||
### From source
|
### From source
|
||||||
|
|
||||||
#### Backend
|
#### Backend
|
||||||
@@ -435,13 +172,15 @@ just run `cargo run -- run` to run the server.
|
|||||||
#### Frontend
|
#### Frontend
|
||||||
|
|
||||||
To bring up the server, you'll need to compile the frontend. In addition to
|
To bring up the server, you'll need to compile the frontend. In addition to
|
||||||
`cargo`, you'll need WASM-pack, which can be installed by running `cargo install wasm-pack`.
|
`cargo`, you'll need:
|
||||||
|
|
||||||
|
- WASM-pack: `cargo install wasm-pack`
|
||||||
|
|
||||||
Then you can build the frontend files with
|
Then you can build the frontend files with
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
./app/build.sh
|
./app/build.sh
|
||||||
```
|
````
|
||||||
|
|
||||||
(you'll need to run this after every front-end change to update the WASM
|
(you'll need to run this after every front-end change to update the WASM
|
||||||
package served).
|
package served).
|
||||||
@@ -476,50 +215,6 @@ You can then get the compiled server binary in
|
|||||||
Raspberry Pi (or other target), with the folder structure maintained (`app`
|
Raspberry Pi (or other target), with the folder structure maintained (`app`
|
||||||
files in an `app` folder next to the binary).
|
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).
|
|
||||||
|
|
||||||
You can create and manage custom attributes through the Web UI, or through the
|
|
||||||
community-contributed CLI frontend (
|
|
||||||
[Zepmann/lldap-cli](https://github.com/Zepmann/lldap-cli)). This is necessary
|
|
||||||
for some service integrations.
|
|
||||||
|
|
||||||
The [bootstrap.sh](scripts/bootstrap.sh) script can enforce a list of
|
|
||||||
users/groups/attributes from a given file, reflecting it on the server.
|
|
||||||
|
|
||||||
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
|
## Client configuration
|
||||||
|
|
||||||
### Compatible services
|
### Compatible services
|
||||||
@@ -551,13 +246,6 @@ admin rights in the Web UI. Most LDAP integrations should instead use a user in
|
|||||||
the `lldap_strict_readonly` or `lldap_password_manager` group, to avoid granting full
|
the `lldap_strict_readonly` or `lldap_password_manager` group, to avoid granting full
|
||||||
administration access to many services.
|
administration access to many services.
|
||||||
|
|
||||||
### Integration with OS's
|
|
||||||
|
|
||||||
Integration with Linux accounts is possible, through PAM and nslcd. See [PAM
|
|
||||||
configuration guide](example_configs/pam/README.md).
|
|
||||||
|
|
||||||
Integration with Windows (e.g. Samba) is WIP.
|
|
||||||
|
|
||||||
### Sample client configurations
|
### Sample client configurations
|
||||||
|
|
||||||
Some specific clients have been tested to work and come with sample
|
Some specific clients have been tested to work and come with sample
|
||||||
@@ -566,86 +254,37 @@ folder for help with:
|
|||||||
|
|
||||||
- [Airsonic Advanced](example_configs/airsonic-advanced.md)
|
- [Airsonic Advanced](example_configs/airsonic-advanced.md)
|
||||||
- [Apache Guacamole](example_configs/apacheguacamole.md)
|
- [Apache Guacamole](example_configs/apacheguacamole.md)
|
||||||
- [Apereo CAS Server](example_configs/apereo_cas_server.md)
|
|
||||||
- [Authelia](example_configs/authelia_config.yml)
|
- [Authelia](example_configs/authelia_config.yml)
|
||||||
- [Authentik](example_configs/authentik.md)
|
- [Authentik](example_configs/authentik.md)
|
||||||
- [Bookstack](example_configs/bookstack.env.example)
|
- [Bookstack](example_configs/bookstack.env.example)
|
||||||
- [Calibre-Web](example_configs/calibre_web.md)
|
- [Calibre-Web](example_configs/calibre_web.md)
|
||||||
- [Carpal](example_configs/carpal.md)
|
|
||||||
- [Dell iDRAC](example_configs/dell_idrac.md)
|
- [Dell iDRAC](example_configs/dell_idrac.md)
|
||||||
- [Dex](example_configs/dex_config.yml)
|
- [Dex](example_configs/dex_config.yml)
|
||||||
- [Dokuwiki](example_configs/dokuwiki.md)
|
- [Dokuwiki](example_configs/dokuwiki.md)
|
||||||
- [Dolibarr](example_configs/dolibarr.md)
|
- [Dolibarr](example_configs/dolibarr.md)
|
||||||
- [Ejabberd](example_configs/ejabberd.md)
|
- [Ejabberd](example_configs/ejabberd.md)
|
||||||
- [Emby](example_configs/emby.md)
|
- [Emby](example_configs/emby.md)
|
||||||
- [Ergo IRCd](example_configs/ergo.md)
|
|
||||||
- [Gitea](example_configs/gitea.md)
|
- [Gitea](example_configs/gitea.md)
|
||||||
- [GitLab](example_configs/gitlab.md)
|
|
||||||
- [Grafana](example_configs/grafana_ldap_config.toml)
|
- [Grafana](example_configs/grafana_ldap_config.toml)
|
||||||
- [Grocy](example_configs/grocy.md)
|
|
||||||
- [Harbor](example_configs/harbor.md)
|
|
||||||
- [Hedgedoc](example_configs/hedgedoc.md)
|
- [Hedgedoc](example_configs/hedgedoc.md)
|
||||||
- [Home Assistant](example_configs/home-assistant.md)
|
|
||||||
- [Jellyfin](example_configs/jellyfin.md)
|
- [Jellyfin](example_configs/jellyfin.md)
|
||||||
- [Jenkins](example_configs/jenkins.md)
|
|
||||||
- [Jitsi Meet](example_configs/jitsi_meet.conf)
|
- [Jitsi Meet](example_configs/jitsi_meet.conf)
|
||||||
- [Kasm](example_configs/kasm.md)
|
|
||||||
- [KeyCloak](example_configs/keycloak.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)
|
- [Matrix](example_configs/matrix_synapse.yml)
|
||||||
- [Mealie](example_configs/mealie.md)
|
|
||||||
- [Metabase](example_configs/metabase.md)
|
|
||||||
- [MegaRAC-BMC](example_configs/MegaRAC-SP-X-BMC.md)
|
|
||||||
- [MinIO](example_configs/minio.md)
|
|
||||||
- [Netbox](example_configs/netbox.md)
|
|
||||||
- [Nextcloud](example_configs/nextcloud.md)
|
- [Nextcloud](example_configs/nextcloud.md)
|
||||||
- [Nexus](example_configs/nexus.md)
|
- [Nexus](example_configs/nexus.md)
|
||||||
- [OCIS (OwnCloud Infinite Scale)](example_configs/ocis.md)
|
|
||||||
- [Organizr](example_configs/Organizr.md)
|
- [Organizr](example_configs/Organizr.md)
|
||||||
- [Portainer](example_configs/portainer.md)
|
- [Portainer](example_configs/portainer.md)
|
||||||
- [PowerDNS Admin](example_configs/powerdns_admin.md)
|
|
||||||
- [Prosody](example_configs/prosody.md)
|
|
||||||
- [Proxmox VE](example_configs/proxmox.md)
|
|
||||||
- [Radicale](example_configs/radicale.md)
|
|
||||||
- [Rancher](example_configs/rancher.md)
|
- [Rancher](example_configs/rancher.md)
|
||||||
- [Seafile](example_configs/seafile.md)
|
- [Seafile](example_configs/seafile.md)
|
||||||
- [Shaarli](example_configs/shaarli.md)
|
- [Shaarli](example_configs/shaarli.md)
|
||||||
- [SonarQube](example_configs/sonarqube.md)
|
|
||||||
- [Squid](example_configs/squid.md)
|
|
||||||
- [Syncthing](example_configs/syncthing.md)
|
- [Syncthing](example_configs/syncthing.md)
|
||||||
- [TheLounge](example_configs/thelounge.md)
|
|
||||||
- [Traccar](example_configs/traccar.xml)
|
|
||||||
- [Vaultwarden](example_configs/vaultwarden.md)
|
- [Vaultwarden](example_configs/vaultwarden.md)
|
||||||
- [WeKan](example_configs/wekan.md)
|
- [WeKan](example_configs/wekan.md)
|
||||||
- [WG Portal](example_configs/wg_portal.env.example)
|
- [WG Portal](example_configs/wg_portal.env.example)
|
||||||
- [WikiJS](example_configs/wikijs.md)
|
- [WikiJS](example_configs/wikijs.md)
|
||||||
- [XBackBone](example_configs/xbackbone_config.php)
|
- [XBackBone](example_configs/xbackbone_config.php)
|
||||||
- [Zendto](example_configs/zendto.md)
|
- [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
|
## Migrating from SQLite
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ homepage = "https://github.com/lldap/lldap"
|
|||||||
license = "GPL-3.0-only"
|
license = "GPL-3.0-only"
|
||||||
name = "lldap_app"
|
name = "lldap_app"
|
||||||
repository = "https://github.com/lldap/lldap"
|
repository = "https://github.com/lldap/lldap"
|
||||||
version = "0.6.0"
|
version = "0.5.0-alpha"
|
||||||
include = ["src/**/*", "queries/**/*", "Cargo.toml", "../schema.graphql"]
|
include = ["src/**/*", "queries/**/*", "Cargo.toml", "../schema.graphql"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -14,7 +14,7 @@ anyhow = "1"
|
|||||||
base64 = "0.13"
|
base64 = "0.13"
|
||||||
gloo-console = "0.2.3"
|
gloo-console = "0.2.3"
|
||||||
gloo-file = "0.2.3"
|
gloo-file = "0.2.3"
|
||||||
gloo-net = "*"
|
gloo-net = "0.2"
|
||||||
graphql_client = "0.10"
|
graphql_client = "0.10"
|
||||||
http = "0.2"
|
http = "0.2"
|
||||||
jwt = "0.13"
|
jwt = "0.13"
|
||||||
@@ -22,10 +22,10 @@ rand = "0.8"
|
|||||||
serde = "1"
|
serde = "1"
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
url-escape = "0.1.1"
|
url-escape = "0.1.1"
|
||||||
validator = "0.14"
|
validator = "=0.14"
|
||||||
validator_derive = "0.14"
|
validator_derive = "0.16"
|
||||||
wasm-bindgen = "0.2"
|
wasm-bindgen = "0.2"
|
||||||
wasm-bindgen-futures = "*"
|
wasm-bindgen-futures = "0.4"
|
||||||
yew = "0.19.3"
|
yew = "0.19.3"
|
||||||
yew-router = "0.16"
|
yew-router = "0.16"
|
||||||
|
|
||||||
@@ -37,27 +37,23 @@ version = "0.3"
|
|||||||
features = [
|
features = [
|
||||||
"Document",
|
"Document",
|
||||||
"Element",
|
"Element",
|
||||||
"Event",
|
|
||||||
"FileReader",
|
"FileReader",
|
||||||
"FormData",
|
|
||||||
"HtmlDocument",
|
"HtmlDocument",
|
||||||
"HtmlFormElement",
|
|
||||||
"HtmlInputElement",
|
"HtmlInputElement",
|
||||||
"HtmlOptionElement",
|
"HtmlOptionElement",
|
||||||
"HtmlOptionsCollection",
|
"HtmlOptionsCollection",
|
||||||
"HtmlSelectElement",
|
"HtmlSelectElement",
|
||||||
"SubmitEvent",
|
|
||||||
"console",
|
"console",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependencies.chrono]
|
[dependencies.chrono]
|
||||||
version = "*"
|
version = "0.4"
|
||||||
features = [
|
features = [
|
||||||
"wasmbind"
|
"wasmbind"
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependencies.lldap_auth]
|
[dependencies.lldap_auth]
|
||||||
path = "../auth"
|
version = "0.3"
|
||||||
features = [ "opaque_client" ]
|
features = [ "opaque_client" ]
|
||||||
|
|
||||||
[dependencies.image]
|
[dependencies.image]
|
||||||
@@ -75,10 +71,3 @@ rev = "4b9fabffb63393ec7626a4477fd36de12a07fac9"
|
|||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
crate-type = ["cdylib"]
|
crate-type = ["cdylib"]
|
||||||
|
|
||||||
[package.metadata.wasm-pack.profile.dev]
|
|
||||||
wasm-opt = ['--enable-bulk-memory']
|
|
||||||
[package.metadata.wasm-pack.profile.profiling]
|
|
||||||
wasm-opt = ['--enable-bulk-memory']
|
|
||||||
[package.metadata.wasm-pack.profile.release]
|
|
||||||
wasm-opt = ['--enable-bulk-memory']
|
|
||||||
|
|||||||
@@ -4,8 +4,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<title>LLDAP Administration</title>
|
<title>LLDAP Administration</title>
|
||||||
<base href="/">
|
<script src="/static/main.js" type="module" defer></script>
|
||||||
<script src="static/main.js" type="module" defer></script>
|
|
||||||
<link
|
<link
|
||||||
href="https://cdn.jsdelivr.net/npm/bootstrap-dark-5@1.1.3/dist/css/bootstrap-nightshade.min.css"
|
href="https://cdn.jsdelivr.net/npm/bootstrap-dark-5@1.1.3/dist/css/bootstrap-nightshade.min.css"
|
||||||
rel="preload stylesheet"
|
rel="preload stylesheet"
|
||||||
@@ -16,8 +15,8 @@
|
|||||||
src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.1/dist/js/bootstrap.bundle.min.js"
|
src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.1/dist/js/bootstrap.bundle.min.js"
|
||||||
integrity="sha384-/bQdsTh/da6pkI1MST/rWKFNjaCP5gBSY4sEBT38Q/9RBh9AH40zEOg7Hlq2THRZ"
|
integrity="sha384-/bQdsTh/da6pkI1MST/rWKFNjaCP5gBSY4sEBT38Q/9RBh9AH40zEOg7Hlq2THRZ"
|
||||||
crossorigin="anonymous"></script>
|
crossorigin="anonymous"></script>
|
||||||
<script
|
<script
|
||||||
src="https://cdn.jsdelivr.net/npm/bootstrap-dark-5@1.1.3/dist/js/darkmode.min.js"
|
src="https://cdn.jsdelivr.net/npm/bootstrap-dark-5@1.1.3/dist/js/darkmode.min.js"
|
||||||
integrity="sha384-A4SLs39X/aUfwRclRaXvNeXNBTLZdnZdHhhteqbYFS2jZTRD79tKeFeBn7SGXNpi"
|
integrity="sha384-A4SLs39X/aUfwRclRaXvNeXNBTLZdnZdHhhteqbYFS2jZTRD79tKeFeBn7SGXNpi"
|
||||||
crossorigin="anonymous"></script>
|
crossorigin="anonymous"></script>
|
||||||
<link
|
<link
|
||||||
@@ -34,7 +33,7 @@
|
|||||||
href="https://fonts.googleapis.com/css2?family=Bebas+Neue&display=swap" />
|
href="https://fonts.googleapis.com/css2?family=Bebas+Neue&display=swap" />
|
||||||
<link
|
<link
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
href="static/style.css" />
|
href="/static/style.css" />
|
||||||
<script>
|
<script>
|
||||||
function inDarkMode(){
|
function inDarkMode(){
|
||||||
return darkmode.inDarkMode;
|
return darkmode.inDarkMode;
|
||||||
@@ -44,23 +43,6 @@
|
|||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<noscript>
|
|
||||||
<!-- This will be displayed if the user doesn't have JavaScript enabled. -->
|
|
||||||
LLDAP requires JavaScript, please switch to a compatible browser or
|
|
||||||
enable it.
|
|
||||||
</noscript>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
/* Detect if the user has WASM support. */
|
|
||||||
if (typeof WebAssembly === 'undefined') {
|
|
||||||
const pWASMMsg = document.createElement("p")
|
|
||||||
pWASMMsg.innerHTML = `
|
|
||||||
LLDAP requires WASM and JIT for JavaScript, please switch to a
|
|
||||||
compatible browser or enable it.
|
|
||||||
`
|
|
||||||
document.body.appendChild(pWASMMsg)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -13,8 +13,8 @@
|
|||||||
<script
|
<script
|
||||||
src="/static/bootstrap.bundle.min.js"
|
src="/static/bootstrap.bundle.min.js"
|
||||||
integrity="sha384-/bQdsTh/da6pkI1MST/rWKFNjaCP5gBSY4sEBT38Q/9RBh9AH40zEOg7Hlq2THRZ"></script>
|
integrity="sha384-/bQdsTh/da6pkI1MST/rWKFNjaCP5gBSY4sEBT38Q/9RBh9AH40zEOg7Hlq2THRZ"></script>
|
||||||
<script
|
<script
|
||||||
src="/static/darkmode.min.js"
|
src="/static/darkmode.min.js"
|
||||||
integrity="sha384-A4SLs39X/aUfwRclRaXvNeXNBTLZdnZdHhhteqbYFS2jZTRD79tKeFeBn7SGXNpi"></script>
|
integrity="sha384-A4SLs39X/aUfwRclRaXvNeXNBTLZdnZdHhhteqbYFS2jZTRD79tKeFeBn7SGXNpi"></script>
|
||||||
<link
|
<link
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
@@ -40,23 +40,6 @@
|
|||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<noscript>
|
|
||||||
<!-- This will be displayed if the user doesn't have JavaScript enabled. -->
|
|
||||||
LLDAP requires JavaScript, please switch to a compatible browser or
|
|
||||||
enable it.
|
|
||||||
</noscript>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
/* Detect if the user has WASM support. */
|
|
||||||
if (typeof WebAssembly === 'undefined') {
|
|
||||||
const pWASMMsg = document.createElement("p")
|
|
||||||
pWASMMsg.innerHTML = `
|
|
||||||
LLDAP requires WASM and JIT for JavaScript, please switch to a
|
|
||||||
compatible browser or enable it.
|
|
||||||
`
|
|
||||||
document.body.appendChild(pWASMMsg)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
mutation CreateGroup($group: CreateGroupInput!) {
|
mutation CreateGroup($name: String!) {
|
||||||
createGroupWithDetails(request: $group) {
|
createGroup(name: $name) {
|
||||||
id
|
id
|
||||||
displayName
|
displayName
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
mutation CreateGroupAttribute($name: String!, $attributeType: AttributeType!, $isList: Boolean!, $isVisible: Boolean!) {
|
|
||||||
addGroupAttribute(name: $name, attributeType: $attributeType, isList: $isList, isVisible: $isVisible, isEditable: false) {
|
|
||||||
ok
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
mutation CreateUserAttribute($name: String!, $attributeType: AttributeType!, $isList: Boolean!, $isVisible: Boolean!, $isEditable: Boolean!) {
|
|
||||||
addUserAttribute(name: $name, attributeType: $attributeType, isList: $isList, isVisible: $isVisible, isEditable: $isEditable) {
|
|
||||||
ok
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
mutation DeleteGroupAttributeQuery($name: String!) {
|
|
||||||
deleteGroupAttribute(name: $name) {
|
|
||||||
ok
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
mutation DeleteUserAttributeQuery($name: String!) {
|
|
||||||
deleteUserAttribute(name: $name) {
|
|
||||||
ok
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
query GetGroupAttributesSchema {
|
|
||||||
schema {
|
|
||||||
groupSchema {
|
|
||||||
attributes {
|
|
||||||
name
|
|
||||||
attributeType
|
|
||||||
isList
|
|
||||||
isVisible
|
|
||||||
isHardcoded
|
|
||||||
isReadonly
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,22 +8,5 @@ query GetGroupDetails($id: Int!) {
|
|||||||
id
|
id
|
||||||
displayName
|
displayName
|
||||||
}
|
}
|
||||||
attributes {
|
|
||||||
name
|
|
||||||
value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
schema {
|
|
||||||
groupSchema {
|
|
||||||
attributes {
|
|
||||||
name
|
|
||||||
attributeType
|
|
||||||
isList
|
|
||||||
isVisible
|
|
||||||
isEditable
|
|
||||||
isHardcoded
|
|
||||||
isReadonly
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
query GetUserAttributesSchema {
|
|
||||||
schema {
|
|
||||||
userSchema {
|
|
||||||
attributes {
|
|
||||||
name
|
|
||||||
attributeType
|
|
||||||
isList
|
|
||||||
isVisible
|
|
||||||
isEditable
|
|
||||||
isHardcoded
|
|
||||||
isReadonly
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,30 +2,15 @@ query GetUserDetails($id: String!) {
|
|||||||
user(userId: $id) {
|
user(userId: $id) {
|
||||||
id
|
id
|
||||||
email
|
email
|
||||||
avatar
|
|
||||||
displayName
|
displayName
|
||||||
|
firstName
|
||||||
|
lastName
|
||||||
|
avatar
|
||||||
creationDate
|
creationDate
|
||||||
uuid
|
uuid
|
||||||
groups {
|
groups {
|
||||||
id
|
id
|
||||||
displayName
|
displayName
|
||||||
}
|
}
|
||||||
attributes {
|
|
||||||
name
|
|
||||||
value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
schema {
|
|
||||||
userSchema {
|
|
||||||
attributes {
|
|
||||||
name
|
|
||||||
attributeType
|
|
||||||
isList
|
|
||||||
isVisible
|
|
||||||
isEditable
|
|
||||||
isHardcoded
|
|
||||||
isReadonly
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
mutation UpdateGroup($group: UpdateGroupInput!) {
|
|
||||||
updateGroup(group: $group) {
|
|
||||||
ok
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -155,13 +155,8 @@ impl Component for AddGroupMemberComponent {
|
|||||||
let to_add_user_list = self.get_selectable_user_list(ctx, user_list);
|
let to_add_user_list = self.get_selectable_user_list(ctx, user_list);
|
||||||
#[allow(unused_braces)]
|
#[allow(unused_braces)]
|
||||||
let make_select_option = |user: User| {
|
let make_select_option = |user: User| {
|
||||||
let name = if user.display_name.is_empty() {
|
|
||||||
user.id.clone()
|
|
||||||
} else {
|
|
||||||
user.display_name.clone()
|
|
||||||
};
|
|
||||||
html_nested! {
|
html_nested! {
|
||||||
<SelectOption value={user.id.clone()} text={name} key={user.id} />
|
<SelectOption value={user.id.clone()} text={user.display_name.clone()} key={user.id} />
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
html! {
|
html! {
|
||||||
|
|||||||
@@ -1,26 +1,23 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
components::{
|
components::{
|
||||||
banner::Banner,
|
|
||||||
change_password::ChangePasswordForm,
|
change_password::ChangePasswordForm,
|
||||||
create_group::CreateGroupForm,
|
create_group::CreateGroupForm,
|
||||||
create_group_attribute::CreateGroupAttributeForm,
|
|
||||||
create_user::CreateUserForm,
|
create_user::CreateUserForm,
|
||||||
create_user_attribute::CreateUserAttributeForm,
|
|
||||||
group_details::GroupDetails,
|
group_details::GroupDetails,
|
||||||
group_schema_table::ListGroupSchema,
|
|
||||||
group_table::GroupTable,
|
group_table::GroupTable,
|
||||||
login::LoginForm,
|
login::LoginForm,
|
||||||
|
logout::LogoutButton,
|
||||||
reset_password_step1::ResetPasswordStep1Form,
|
reset_password_step1::ResetPasswordStep1Form,
|
||||||
reset_password_step2::ResetPasswordStep2Form,
|
reset_password_step2::ResetPasswordStep2Form,
|
||||||
router::{AppRoute, Link, Redirect},
|
router::{AppRoute, Link, Redirect},
|
||||||
user_details::UserDetails,
|
user_details::UserDetails,
|
||||||
user_schema_table::ListUserSchema,
|
|
||||||
user_table::UserTable,
|
user_table::UserTable,
|
||||||
},
|
},
|
||||||
infra::{api::HostService, cookies::get_cookie},
|
infra::{api::HostService, cookies::get_cookie},
|
||||||
};
|
};
|
||||||
|
|
||||||
use gloo_console::error;
|
use gloo_console::error;
|
||||||
|
use wasm_bindgen::prelude::*;
|
||||||
use yew::{
|
use yew::{
|
||||||
function_component,
|
function_component,
|
||||||
html::Scope,
|
html::Scope,
|
||||||
@@ -33,6 +30,25 @@ use yew_router::{
|
|||||||
BrowserRouter, Switch,
|
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)]
|
#[function_component(AppContainer)]
|
||||||
pub fn app_container() -> Html {
|
pub fn app_container() -> Html {
|
||||||
html! {
|
html! {
|
||||||
@@ -119,11 +135,10 @@ impl Component for App {
|
|||||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
let link = ctx.link().clone();
|
let link = ctx.link().clone();
|
||||||
let is_admin = self.is_admin();
|
let is_admin = self.is_admin();
|
||||||
let username = self.user_info.clone().map(|(username, _)| username);
|
|
||||||
let password_reset_enabled = self.password_reset_enabled;
|
let password_reset_enabled = self.password_reset_enabled;
|
||||||
html! {
|
html! {
|
||||||
<div>
|
<div>
|
||||||
<Banner is_admin={is_admin} username={username} on_logged_out={link.callback(|_| Msg::Logout)} />
|
{self.view_banner(ctx)}
|
||||||
<div class="container py-3 bg-kug">
|
<div class="container py-3 bg-kug">
|
||||||
<div class="row justify-content-center" style="padding-bottom: 80px;">
|
<div class="row justify-content-center" style="padding-bottom: 80px;">
|
||||||
<main class="py-3" style="max-width: 1000px">
|
<main class="py-3" style="max-width: 1000px">
|
||||||
@@ -212,12 +227,6 @@ impl App {
|
|||||||
AppRoute::CreateGroup => html! {
|
AppRoute::CreateGroup => html! {
|
||||||
<CreateGroupForm/>
|
<CreateGroupForm/>
|
||||||
},
|
},
|
||||||
AppRoute::CreateUserAttribute => html! {
|
|
||||||
<CreateUserAttributeForm/>
|
|
||||||
},
|
|
||||||
AppRoute::CreateGroupAttribute => html! {
|
|
||||||
<CreateGroupAttributeForm/>
|
|
||||||
},
|
|
||||||
AppRoute::ListGroups => html! {
|
AppRoute::ListGroups => html! {
|
||||||
<div>
|
<div>
|
||||||
<GroupTable />
|
<GroupTable />
|
||||||
@@ -227,14 +236,8 @@ impl App {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
},
|
},
|
||||||
AppRoute::ListUserSchema => html! {
|
|
||||||
<ListUserSchema />
|
|
||||||
},
|
|
||||||
AppRoute::ListGroupSchema => html! {
|
|
||||||
<ListGroupSchema />
|
|
||||||
},
|
|
||||||
AppRoute::GroupDetails { group_id } => html! {
|
AppRoute::GroupDetails { group_id } => html! {
|
||||||
<GroupDetails group_id={*group_id} is_admin={is_admin} />
|
<GroupDetails group_id={*group_id} />
|
||||||
},
|
},
|
||||||
AppRoute::UserDetails { user_id } => html! {
|
AppRoute::UserDetails { user_id } => html! {
|
||||||
<UserDetails username={user_id.clone()} is_admin={is_admin} />
|
<UserDetails username={user_id.clone()} is_admin={is_admin} />
|
||||||
@@ -260,6 +263,91 @@ 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 {
|
fn view_footer(&self) -> Html {
|
||||||
html! {
|
html! {
|
||||||
<footer class="text-center fixed-bottom text-muted bg-light py-2">
|
<footer class="text-center fixed-bottom text-muted bg-light py-2">
|
||||||
@@ -267,7 +355,7 @@ impl App {
|
|||||||
<span>{format!("LLDAP version {}", env!("CARGO_PKG_VERSION"))}</span>
|
<span>{format!("LLDAP version {}", env!("CARGO_PKG_VERSION"))}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<a href="https://github.com/lldap/lldap" class="me-4 text-reset">
|
<a href="https://github.com/nitnelave/lldap" class="me-4 text-reset">
|
||||||
<i class="bi-github"></i>
|
<i class="bi-github"></i>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://discord.gg/h5PEdRMNyP" class="me-4 text-reset">
|
<a href="https://discord.gg/h5PEdRMNyP" class="me-4 text-reset">
|
||||||
@@ -278,7 +366,7 @@ impl App {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>{"License "}<a href="https://github.com/lldap/lldap/blob/main/LICENSE" class="link-secondary">{"GNU GPL"}</a></span>
|
<span>{"License "}<a href="https://github.com/nitnelave/lldap/blob/main/LICENSE" class="link-secondary">{"GNU GPL"}</a></span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
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",
|
|
||||||
variables_derives = "Clone,PartialEq,Eq",
|
|
||||||
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>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
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,8 +1,5 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
components::{
|
components::router::{AppRoute, Link},
|
||||||
form::{field::Field, submit::Submit},
|
|
||||||
router::{AppRoute, Link},
|
|
||||||
},
|
|
||||||
infra::{
|
infra::{
|
||||||
api::HostService,
|
api::HostService,
|
||||||
common_component::{CommonComponent, CommonComponentParts},
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
@@ -100,7 +97,7 @@ impl CommonComponent<ChangePasswordForm> for ChangePasswordForm {
|
|||||||
.context("Could not initialize login")?;
|
.context("Could not initialize login")?;
|
||||||
self.opaque_data = OpaqueData::Login(login_start_request.state);
|
self.opaque_data = OpaqueData::Login(login_start_request.state);
|
||||||
let req = login::ClientLoginStartRequest {
|
let req = login::ClientLoginStartRequest {
|
||||||
username: ctx.props().username.clone().into(),
|
username: ctx.props().username.clone(),
|
||||||
login_start_request: login_start_request.message,
|
login_start_request: login_start_request.message,
|
||||||
};
|
};
|
||||||
self.common.call_backend(
|
self.common.call_backend(
|
||||||
@@ -131,13 +128,11 @@ impl CommonComponent<ChangePasswordForm> for ChangePasswordForm {
|
|||||||
Msg::SubmitNewPassword => {
|
Msg::SubmitNewPassword => {
|
||||||
let mut rng = rand::rngs::OsRng;
|
let mut rng = rand::rngs::OsRng;
|
||||||
let new_password = self.form.model().password;
|
let new_password = self.form.model().password;
|
||||||
let registration_start_request = opaque::client::registration::start_registration(
|
let registration_start_request =
|
||||||
new_password.as_bytes(),
|
opaque::client::registration::start_registration(&new_password, &mut rng)
|
||||||
&mut rng,
|
.context("Could not initiate password change")?;
|
||||||
)
|
|
||||||
.context("Could not initiate password change")?;
|
|
||||||
let req = registration::ClientRegistrationStartRequest {
|
let req = registration::ClientRegistrationStartRequest {
|
||||||
username: ctx.props().username.clone().into(),
|
username: ctx.props().username.clone(),
|
||||||
registration_start_request: registration_start_request.message,
|
registration_start_request: registration_start_request.message,
|
||||||
};
|
};
|
||||||
self.opaque_data = OpaqueData::Registration(registration_start_request.state);
|
self.opaque_data = OpaqueData::Registration(registration_start_request.state);
|
||||||
@@ -210,6 +205,7 @@ impl Component for ChangePasswordForm {
|
|||||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
let is_admin = ctx.props().is_admin;
|
let is_admin = ctx.props().is_admin;
|
||||||
let link = ctx.link();
|
let link = ctx.link();
|
||||||
|
type Field = yew_form::Field<FormModel>;
|
||||||
html! {
|
html! {
|
||||||
<>
|
<>
|
||||||
<div class="mb-2 mt-2">
|
<div class="mb-2 mt-2">
|
||||||
@@ -226,44 +222,90 @@ impl Component for ChangePasswordForm {
|
|||||||
}
|
}
|
||||||
} else { html! {} }
|
} else { html! {} }
|
||||||
}
|
}
|
||||||
<form class="form">
|
<form
|
||||||
|
class="form">
|
||||||
{if !is_admin { html! {
|
{if !is_admin { html! {
|
||||||
<Field<FormModel>
|
<div class="form-group row">
|
||||||
form={&self.form}
|
<label for="old_password"
|
||||||
required=true
|
class="form-label col-sm-2 col-form-label">
|
||||||
label="Current password"
|
{"Current password*:"}
|
||||||
field_name="old_password"
|
</label>
|
||||||
input_type="password"
|
<div class="col-sm-10">
|
||||||
autocomplete="current-password"
|
<Field
|
||||||
oninput={link.callback(|_| Msg::FormUpdate)} />
|
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>
|
||||||
}} else { html! {} }}
|
}} else { html! {} }}
|
||||||
<Field<FormModel>
|
<div class="form-group row mb-3">
|
||||||
form={&self.form}
|
<label for="new_password"
|
||||||
required=true
|
class="form-label col-sm-2 col-form-label">
|
||||||
label="New password"
|
{"New Password"}
|
||||||
field_name="password"
|
<span class="text-danger">{"*"}</span>
|
||||||
input_type="password"
|
{":"}
|
||||||
autocomplete="new-password"
|
</label>
|
||||||
oninput={link.callback(|_| Msg::FormUpdate)} />
|
<div class="col-sm-10">
|
||||||
<Field<FormModel>
|
<Field
|
||||||
form={&self.form}
|
form={&self.form}
|
||||||
required=true
|
field_name="password"
|
||||||
label="Confirm password"
|
input_type="password"
|
||||||
field_name="confirm_password"
|
class="form-control"
|
||||||
input_type="password"
|
class_invalid="is-invalid has-error"
|
||||||
autocomplete="new-password"
|
class_valid="has-success"
|
||||||
oninput={link.callback(|_| Msg::FormUpdate)} />
|
autocomplete="new-password"
|
||||||
<Submit
|
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||||
disabled={self.common.is_task_running()}
|
<div class="invalid-feedback">
|
||||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}
|
{&self.form.field_message("password")}
|
||||||
text="Save changes" >
|
</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>
|
||||||
<Link
|
<Link
|
||||||
classes="btn btn-secondary ms-2 col-auto col-form-label"
|
classes="btn btn-secondary ms-2 col-auto col-form-label"
|
||||||
to={AppRoute::UserDetails{user_id: ctx.props().username.clone()}}>
|
to={AppRoute::UserDetails{user_id: ctx.props().username.clone()}}>
|
||||||
<i class="bi-arrow-return-left me-2"></i>
|
<i class="bi-arrow-return-left me-2"></i>
|
||||||
{"Back"}
|
{"Back"}
|
||||||
</Link>
|
</Link>
|
||||||
</Submit>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,8 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
components::{
|
components::router::AppRoute,
|
||||||
form::{
|
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||||
attribute_input::{ListAttributeInput, SingleAttributeInput},
|
|
||||||
field::Field,
|
|
||||||
submit::Submit,
|
|
||||||
},
|
|
||||||
router::AppRoute,
|
|
||||||
},
|
|
||||||
convert_attribute_type,
|
|
||||||
infra::{
|
|
||||||
common_component::{CommonComponent, CommonComponentParts},
|
|
||||||
form_utils::{
|
|
||||||
read_all_form_attributes, AttributeValue, EmailIsRequired, GraphQlAttributeSchema,
|
|
||||||
IsAdmin,
|
|
||||||
},
|
|
||||||
schema::AttributeType,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
use anyhow::{ensure, Result};
|
use anyhow::{bail, Result};
|
||||||
use gloo_console::log;
|
use gloo_console::log;
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
use validator_derive::Validate;
|
use validator_derive::Validate;
|
||||||
@@ -25,33 +10,6 @@ use yew::prelude::*;
|
|||||||
use yew_form_derive::Model;
|
use yew_form_derive::Model;
|
||||||
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
||||||
|
|
||||||
#[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);
|
|
||||||
|
|
||||||
impl From<&Attribute> for GraphQlAttributeSchema {
|
|
||||||
fn from(attr: &Attribute) -> Self {
|
|
||||||
Self {
|
|
||||||
name: attr.name.clone(),
|
|
||||||
is_list: attr.is_list,
|
|
||||||
is_readonly: attr.is_readonly,
|
|
||||||
is_editable: false, // Need to be admin to edit it.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(GraphQLQuery)]
|
#[derive(GraphQLQuery)]
|
||||||
#[graphql(
|
#[graphql(
|
||||||
schema_path = "../schema.graphql",
|
schema_path = "../schema.graphql",
|
||||||
@@ -64,8 +22,6 @@ pub struct CreateGroup;
|
|||||||
pub struct CreateGroupForm {
|
pub struct CreateGroupForm {
|
||||||
common: CommonComponentParts<Self>,
|
common: CommonComponentParts<Self>,
|
||||||
form: yew_form::Form<CreateGroupModel>,
|
form: yew_form::Form<CreateGroupModel>,
|
||||||
attributes_schema: Option<Vec<Attribute>>,
|
|
||||||
form_ref: NodeRef,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
|
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
|
||||||
@@ -76,7 +32,6 @@ pub struct CreateGroupModel {
|
|||||||
|
|
||||||
pub enum Msg {
|
pub enum Msg {
|
||||||
Update,
|
Update,
|
||||||
ListAttributesResponse(Result<ResponseData>),
|
|
||||||
SubmitForm,
|
SubmitForm,
|
||||||
CreateGroupResponse(Result<create_group::ResponseData>),
|
CreateGroupResponse(Result<create_group::ResponseData>),
|
||||||
}
|
}
|
||||||
@@ -90,33 +45,12 @@ impl CommonComponent<CreateGroupForm> for CreateGroupForm {
|
|||||||
match msg {
|
match msg {
|
||||||
Msg::Update => Ok(true),
|
Msg::Update => Ok(true),
|
||||||
Msg::SubmitForm => {
|
Msg::SubmitForm => {
|
||||||
ensure!(self.form.validate(), "Check the form for errors");
|
if !self.form.validate() {
|
||||||
|
bail!("Check the form for errors");
|
||||||
let all_values = read_all_form_attributes(
|
}
|
||||||
self.attributes_schema.iter().flatten(),
|
|
||||||
&self.form_ref,
|
|
||||||
IsAdmin(true),
|
|
||||||
EmailIsRequired(false),
|
|
||||||
)?;
|
|
||||||
let attributes = Some(
|
|
||||||
all_values
|
|
||||||
.into_iter()
|
|
||||||
.filter(|a| !a.values.is_empty())
|
|
||||||
.map(
|
|
||||||
|AttributeValue { name, values }| create_group::AttributeValueInput {
|
|
||||||
name,
|
|
||||||
value: values,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.collect(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let model = self.form.model();
|
let model = self.form.model();
|
||||||
let req = create_group::Variables {
|
let req = create_group::Variables {
|
||||||
group: create_group::CreateGroupInput {
|
name: model.groupname,
|
||||||
displayName: model.groupname,
|
|
||||||
attributes,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
self.common.call_graphql::<CreateGroup, _>(
|
self.common.call_graphql::<CreateGroup, _>(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -129,16 +63,11 @@ impl CommonComponent<CreateGroupForm> for CreateGroupForm {
|
|||||||
Msg::CreateGroupResponse(response) => {
|
Msg::CreateGroupResponse(response) => {
|
||||||
log!(&format!(
|
log!(&format!(
|
||||||
"Created group '{}'",
|
"Created group '{}'",
|
||||||
&response?.create_group_with_details.display_name
|
&response?.create_group.display_name
|
||||||
));
|
));
|
||||||
ctx.link().history().unwrap().push(AppRoute::ListGroups);
|
ctx.link().history().unwrap().push(AppRoute::ListGroups);
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
Msg::ListAttributesResponse(schema) => {
|
|
||||||
self.attributes_schema =
|
|
||||||
Some(schema?.schema.group_schema.attributes.into_iter().collect());
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,22 +80,11 @@ impl Component for CreateGroupForm {
|
|||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = ();
|
type Properties = ();
|
||||||
|
|
||||||
fn create(ctx: &Context<Self>) -> Self {
|
fn create(_: &Context<Self>) -> Self {
|
||||||
let mut component = Self {
|
Self {
|
||||||
common: CommonComponentParts::<Self>::create(),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
form: yew_form::Form::<CreateGroupModel>::new(CreateGroupModel::default()),
|
form: yew_form::Form::<CreateGroupModel>::new(CreateGroupModel::default()),
|
||||||
attributes_schema: None,
|
}
|
||||||
form_ref: NodeRef::default(),
|
|
||||||
};
|
|
||||||
component
|
|
||||||
.common
|
|
||||||
.call_graphql::<GetGroupAttributesSchema, _>(
|
|
||||||
ctx,
|
|
||||||
get_group_attributes_schema::Variables {},
|
|
||||||
Msg::ListAttributesResponse,
|
|
||||||
"Error trying to fetch group schema",
|
|
||||||
);
|
|
||||||
component
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
@@ -175,30 +93,44 @@ impl Component for CreateGroupForm {
|
|||||||
|
|
||||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
let link = ctx.link();
|
let link = ctx.link();
|
||||||
|
type Field = yew_form::Field<CreateGroupModel>;
|
||||||
html! {
|
html! {
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
<form class="form py-3" style="max-width: 636px"
|
<form class="form py-3" style="max-width: 636px">
|
||||||
ref={self.form_ref.clone()}>
|
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<h5 class="fw-bold">{"Create a group"}</h5>
|
<h5 class="fw-bold">{"Create a group"}</h5>
|
||||||
</div>
|
</div>
|
||||||
<Field<CreateGroupModel>
|
<div class="form-group row mb-3">
|
||||||
form={&self.form}
|
<label for="groupname"
|
||||||
required=true
|
class="form-label col-4 col-form-label">
|
||||||
label="Group name"
|
{"Group name"}
|
||||||
field_name="groupname"
|
<span class="text-danger">{"*"}</span>
|
||||||
oninput={link.callback(|_| Msg::Update)} />
|
{":"}
|
||||||
{
|
</label>
|
||||||
self.attributes_schema
|
<div class="col-8">
|
||||||
.iter()
|
<Field
|
||||||
.flatten()
|
form={&self.form}
|
||||||
.filter(|a| !a.is_readonly && a.name != "display_name")
|
field_name="groupname"
|
||||||
.map(get_custom_attribute_input)
|
class="form-control"
|
||||||
.collect::<Vec<_>>()
|
class_invalid="is-invalid has-error"
|
||||||
}
|
class_valid="has-success"
|
||||||
<Submit
|
autocomplete="groupname"
|
||||||
disabled={self.common.is_task_running()}
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})} />
|
<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>
|
||||||
</form>
|
</form>
|
||||||
{ if let Some(e) = &self.common.error {
|
{ if let Some(e) = &self.common.error {
|
||||||
html! {
|
html! {
|
||||||
@@ -212,21 +144,3 @@ impl Component for CreateGroupForm {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_custom_attribute_input(attribute_schema: &Attribute) -> Html {
|
|
||||||
if attribute_schema.is_list {
|
|
||||||
html! {
|
|
||||||
<ListAttributeInput
|
|
||||||
name={attribute_schema.name.clone()}
|
|
||||||
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
html! {
|
|
||||||
<SingleAttributeInput
|
|
||||||
name={attribute_schema.name.clone()}
|
|
||||||
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,168 +0,0 @@
|
|||||||
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,24 +1,11 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
components::{
|
components::router::AppRoute,
|
||||||
form::{
|
|
||||||
attribute_input::{ListAttributeInput, SingleAttributeInput},
|
|
||||||
field::Field,
|
|
||||||
submit::Submit,
|
|
||||||
},
|
|
||||||
router::AppRoute,
|
|
||||||
},
|
|
||||||
convert_attribute_type,
|
|
||||||
infra::{
|
infra::{
|
||||||
api::HostService,
|
api::HostService,
|
||||||
common_component::{CommonComponent, CommonComponentParts},
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
form_utils::{
|
|
||||||
read_all_form_attributes, AttributeValue, EmailIsRequired, GraphQlAttributeSchema,
|
|
||||||
IsAdmin,
|
|
||||||
},
|
|
||||||
schema::AttributeType,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use anyhow::{ensure, Result};
|
use anyhow::{bail, Result};
|
||||||
use gloo_console::log;
|
use gloo_console::log;
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
use lldap_auth::{opaque, registration};
|
use lldap_auth::{opaque, registration};
|
||||||
@@ -27,32 +14,6 @@ use yew::prelude::*;
|
|||||||
use yew_form_derive::Model;
|
use yew_form_derive::Model;
|
||||||
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
||||||
|
|
||||||
#[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);
|
|
||||||
|
|
||||||
impl From<&Attribute> for GraphQlAttributeSchema {
|
|
||||||
fn from(attr: &Attribute) -> Self {
|
|
||||||
Self {
|
|
||||||
name: attr.name.clone(),
|
|
||||||
is_list: attr.is_list,
|
|
||||||
is_readonly: attr.is_readonly,
|
|
||||||
is_editable: attr.is_editable,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(GraphQLQuery)]
|
#[derive(GraphQLQuery)]
|
||||||
#[graphql(
|
#[graphql(
|
||||||
schema_path = "../schema.graphql",
|
schema_path = "../schema.graphql",
|
||||||
@@ -65,14 +26,17 @@ pub struct CreateUser;
|
|||||||
pub struct CreateUserForm {
|
pub struct CreateUserForm {
|
||||||
common: CommonComponentParts<Self>,
|
common: CommonComponentParts<Self>,
|
||||||
form: yew_form::Form<CreateUserModel>,
|
form: yew_form::Form<CreateUserModel>,
|
||||||
attributes_schema: Option<Vec<Attribute>>,
|
|
||||||
form_ref: NodeRef,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
|
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
|
||||||
pub struct CreateUserModel {
|
pub struct CreateUserModel {
|
||||||
#[validate(length(min = 1, message = "Username is required"))]
|
#[validate(length(min = 1, message = "Username is required"))]
|
||||||
username: String,
|
username: String,
|
||||||
|
#[validate(email(message = "A valid email is required"))]
|
||||||
|
email: String,
|
||||||
|
display_name: String,
|
||||||
|
first_name: String,
|
||||||
|
last_name: String,
|
||||||
#[validate(custom(
|
#[validate(custom(
|
||||||
function = "empty_or_long",
|
function = "empty_or_long",
|
||||||
message = "Password should be longer than 8 characters (or left empty)"
|
message = "Password should be longer than 8 characters (or left empty)"
|
||||||
@@ -92,7 +56,6 @@ fn empty_or_long(value: &str) -> Result<(), validator::ValidationError> {
|
|||||||
|
|
||||||
pub enum Msg {
|
pub enum Msg {
|
||||||
Update,
|
Update,
|
||||||
ListAttributesResponse(Result<ResponseData>),
|
|
||||||
SubmitForm,
|
SubmitForm,
|
||||||
CreateUserResponse(Result<create_user::ResponseData>),
|
CreateUserResponse(Result<create_user::ResponseData>),
|
||||||
SuccessfulCreation,
|
SuccessfulCreation,
|
||||||
@@ -113,43 +76,20 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
|
|||||||
) -> Result<bool> {
|
) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::Update => Ok(true),
|
Msg::Update => Ok(true),
|
||||||
Msg::ListAttributesResponse(schema) => {
|
|
||||||
self.attributes_schema =
|
|
||||||
Some(schema?.schema.user_schema.attributes.into_iter().collect());
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
Msg::SubmitForm => {
|
Msg::SubmitForm => {
|
||||||
ensure!(self.form.validate(), "Check the form for errors");
|
if !self.form.validate() {
|
||||||
|
bail!("Check the form for errors");
|
||||||
let all_values = read_all_form_attributes(
|
}
|
||||||
self.attributes_schema.iter().flatten(),
|
|
||||||
&self.form_ref,
|
|
||||||
IsAdmin(true),
|
|
||||||
EmailIsRequired(true),
|
|
||||||
)?;
|
|
||||||
let attributes = Some(
|
|
||||||
all_values
|
|
||||||
.into_iter()
|
|
||||||
.filter(|a| !a.values.is_empty())
|
|
||||||
.map(
|
|
||||||
|AttributeValue { name, values }| create_user::AttributeValueInput {
|
|
||||||
name,
|
|
||||||
value: values,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.collect(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let model = self.form.model();
|
let model = self.form.model();
|
||||||
|
let to_option = |s: String| if s.is_empty() { None } else { Some(s) };
|
||||||
let req = create_user::Variables {
|
let req = create_user::Variables {
|
||||||
user: create_user::CreateUserInput {
|
user: create_user::CreateUserInput {
|
||||||
id: model.username,
|
id: model.username,
|
||||||
email: None,
|
email: model.email,
|
||||||
displayName: None,
|
displayName: to_option(model.display_name),
|
||||||
firstName: None,
|
firstName: to_option(model.first_name),
|
||||||
lastName: None,
|
lastName: to_option(model.last_name),
|
||||||
avatar: None,
|
avatar: None,
|
||||||
attributes,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
self.common.call_graphql::<CreateUser, _>(
|
self.common.call_graphql::<CreateUser, _>(
|
||||||
@@ -177,12 +117,9 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
|
|||||||
let opaque::client::registration::ClientRegistrationStartResult {
|
let opaque::client::registration::ClientRegistrationStartResult {
|
||||||
state,
|
state,
|
||||||
message,
|
message,
|
||||||
} = opaque::client::registration::start_registration(
|
} = opaque::client::registration::start_registration(&password, &mut rng)?;
|
||||||
password.as_bytes(),
|
|
||||||
&mut rng,
|
|
||||||
)?;
|
|
||||||
let req = registration::ClientRegistrationStartRequest {
|
let req = registration::ClientRegistrationStartRequest {
|
||||||
username: user_id.into(),
|
username: user_id,
|
||||||
registration_start_request: message,
|
registration_start_request: message,
|
||||||
};
|
};
|
||||||
self.common
|
self.common
|
||||||
@@ -233,20 +170,11 @@ impl Component for CreateUserForm {
|
|||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = ();
|
type Properties = ();
|
||||||
|
|
||||||
fn create(ctx: &Context<Self>) -> Self {
|
fn create(_: &Context<Self>) -> Self {
|
||||||
let mut component = Self {
|
Self {
|
||||||
common: CommonComponentParts::<Self>::create(),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
form: yew_form::Form::<CreateUserModel>::new(CreateUserModel::default()),
|
form: yew_form::Form::<CreateUserModel>::new(CreateUserModel::default()),
|
||||||
attributes_schema: None,
|
}
|
||||||
form_ref: NodeRef::default(),
|
|
||||||
};
|
|
||||||
component.common.call_graphql::<GetUserAttributesSchema, _>(
|
|
||||||
ctx,
|
|
||||||
get_user_attributes_schema::Variables {},
|
|
||||||
Msg::ListAttributesResponse,
|
|
||||||
"Error trying to fetch user schema",
|
|
||||||
);
|
|
||||||
component
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
@@ -255,41 +183,163 @@ impl Component for CreateUserForm {
|
|||||||
|
|
||||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
let link = &ctx.link();
|
let link = &ctx.link();
|
||||||
|
type Field = yew_form::Field<CreateUserModel>;
|
||||||
html! {
|
html! {
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
<form class="form py-3"
|
<form class="form py-3" style="max-width: 636px">
|
||||||
ref={self.form_ref.clone()}>
|
<div class="row mb-3">
|
||||||
<Field<CreateUserModel>
|
<h5 class="fw-bold">{"Create a user"}</h5>
|
||||||
form={&self.form}
|
</div>
|
||||||
required=true
|
<div class="form-group row mb-3">
|
||||||
label="User name"
|
<label for="username"
|
||||||
field_name="username"
|
class="form-label col-4 col-form-label">
|
||||||
oninput={link.callback(|_| Msg::Update)} />
|
{"User name"}
|
||||||
{
|
<span class="text-danger">{"*"}</span>
|
||||||
self.attributes_schema
|
{":"}
|
||||||
.iter()
|
</label>
|
||||||
.flatten()
|
<div class="col-8">
|
||||||
.filter(|a| !a.is_readonly)
|
<Field
|
||||||
.map(get_custom_attribute_input)
|
form={&self.form}
|
||||||
.collect::<Vec<_>>()
|
field_name="username"
|
||||||
}
|
class="form-control"
|
||||||
<Field<CreateUserModel>
|
class_invalid="is-invalid has-error"
|
||||||
form={&self.form}
|
class_valid="has-success"
|
||||||
label="Password"
|
autocomplete="username"
|
||||||
field_name="password"
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
input_type="password"
|
<div class="invalid-feedback">
|
||||||
autocomplete="new-password"
|
{&self.form.field_message("username")}
|
||||||
oninput={link.callback(|_| Msg::Update)} />
|
</div>
|
||||||
<Field<CreateUserModel>
|
</div>
|
||||||
form={&self.form}
|
</div>
|
||||||
label="Confirm password"
|
<div class="form-group row mb-3">
|
||||||
field_name="confirm_password"
|
<label for="email"
|
||||||
input_type="password"
|
class="form-label col-4 col-form-label">
|
||||||
autocomplete="new-password"
|
{"Email"}
|
||||||
oninput={link.callback(|_| Msg::Update)} />
|
<span class="text-danger">{"*"}</span>
|
||||||
<Submit
|
{":"}
|
||||||
disabled={self.common.is_task_running()}
|
</label>
|
||||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})} />
|
<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>
|
||||||
</form>
|
</form>
|
||||||
{
|
{
|
||||||
if let Some(e) = &self.common.error {
|
if let Some(e) = &self.common.error {
|
||||||
@@ -304,21 +354,3 @@ impl Component for CreateUserForm {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_custom_attribute_input(attribute_schema: &Attribute) -> Html {
|
|
||||||
if attribute_schema.is_list {
|
|
||||||
html! {
|
|
||||||
<ListAttributeInput
|
|
||||||
name={attribute_schema.name.clone()}
|
|
||||||
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
html! {
|
|
||||||
<SingleAttributeInput
|
|
||||||
name={attribute_schema.name.clone()}
|
|
||||||
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,175 +0,0 @@
|
|||||||
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>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
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>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
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>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
use crate::{
|
|
||||||
components::form::{date_input::DateTimeInput, file_input::JpegFileInput},
|
|
||||||
infra::{schema::AttributeType, tooltip::Tooltip},
|
|
||||||
};
|
|
||||||
use web_sys::Element;
|
|
||||||
use yew::{
|
|
||||||
function_component, html, use_effect_with_deps, use_node_ref, virtual_dom::AttrValue,
|
|
||||||
Component, Context, Html, Properties,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[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 => {
|
|
||||||
return html! {
|
|
||||||
<DateTimeInput name={props.name.clone()} value={props.value.clone()} />
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AttributeType::Jpeg => {
|
|
||||||
return html! {
|
|
||||||
<JpegFileInput name={props.name.clone()} value={props.value.clone()} />
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
html! {
|
|
||||||
<input
|
|
||||||
type={input_type}
|
|
||||||
name={props.name.clone()}
|
|
||||||
class="form-control"
|
|
||||||
value={props.value.clone()} />
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
|
||||||
struct AttributeLabelProps {
|
|
||||||
pub name: String,
|
|
||||||
}
|
|
||||||
#[function_component(AttributeLabel)]
|
|
||||||
fn attribute_label(props: &AttributeLabelProps) -> Html {
|
|
||||||
let tooltip_ref = use_node_ref();
|
|
||||||
|
|
||||||
use_effect_with_deps(
|
|
||||||
move |tooltip_ref| {
|
|
||||||
Tooltip::new(
|
|
||||||
tooltip_ref
|
|
||||||
.cast::<Element>()
|
|
||||||
.expect("Tooltip element should exist"),
|
|
||||||
);
|
|
||||||
|| {}
|
|
||||||
},
|
|
||||||
tooltip_ref.clone(),
|
|
||||||
);
|
|
||||||
|
|
||||||
html! {
|
|
||||||
<label for={props.name.clone()}
|
|
||||||
class="form-label col-4 col-form-label"
|
|
||||||
>
|
|
||||||
{props.name[0..1].to_uppercase() + &props.name[1..].replace('_', " ")}{":"}
|
|
||||||
<button
|
|
||||||
class="btn btn-sm btn-link"
|
|
||||||
type="button"
|
|
||||||
data-bs-placement="right"
|
|
||||||
title={props.name.clone()}
|
|
||||||
ref={tooltip_ref}>
|
|
||||||
<i class="bi bi-info-circle" aria-label="Info" />
|
|
||||||
</button>
|
|
||||||
</label>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
|
||||||
pub struct SingleAttributeInputProps {
|
|
||||||
pub name: String,
|
|
||||||
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">
|
|
||||||
<AttributeLabel name={props.name.clone()} />
|
|
||||||
<div class="col-8">
|
|
||||||
<AttributeInput
|
|
||||||
attribute_type={props.attribute_type.clone()}
|
|
||||||
name={props.name.clone()}
|
|
||||||
value={props.value.clone()} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
|
||||||
pub struct ListAttributeInputProps {
|
|
||||||
pub name: String,
|
|
||||||
pub attribute_type: AttributeType,
|
|
||||||
#[prop_or(vec!())]
|
|
||||||
pub values: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub enum ListAttributeInputMsg {
|
|
||||||
Remove(usize),
|
|
||||||
Append,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ListAttributeInput {
|
|
||||||
indices: Vec<usize>,
|
|
||||||
next_index: usize,
|
|
||||||
values: Vec<String>,
|
|
||||||
}
|
|
||||||
impl Component for ListAttributeInput {
|
|
||||||
type Message = ListAttributeInputMsg;
|
|
||||||
type Properties = ListAttributeInputProps;
|
|
||||||
|
|
||||||
fn create(ctx: &Context<Self>) -> Self {
|
|
||||||
let values = ctx.props().values.clone();
|
|
||||||
Self {
|
|
||||||
indices: (0..values.len()).collect(),
|
|
||||||
next_index: values.len(),
|
|
||||||
values,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
|
|
||||||
match msg {
|
|
||||||
ListAttributeInputMsg::Remove(removed) => {
|
|
||||||
self.indices.retain_mut(|x| *x != removed);
|
|
||||||
}
|
|
||||||
ListAttributeInputMsg::Append => {
|
|
||||||
self.indices.push(self.next_index);
|
|
||||||
self.next_index += 1;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
fn changed(&mut self, ctx: &Context<Self>) -> bool {
|
|
||||||
if ctx.props().values != self.values {
|
|
||||||
self.values.clone_from(&ctx.props().values);
|
|
||||||
self.indices = (0..self.values.len()).collect();
|
|
||||||
self.next_index = self.values.len();
|
|
||||||
}
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
|
||||||
let props = &ctx.props();
|
|
||||||
let link = &ctx.link();
|
|
||||||
html! {
|
|
||||||
<div class="row mb-3">
|
|
||||||
<AttributeLabel name={props.name.clone()} />
|
|
||||||
<div class="col-8">
|
|
||||||
{self.indices.iter().map(|&i| html! {
|
|
||||||
<div class="input-group mb-2" key={i}>
|
|
||||||
<AttributeInput
|
|
||||||
attribute_type={props.attribute_type.clone()}
|
|
||||||
name={props.name.clone()}
|
|
||||||
value={props.values.get(i).cloned().unwrap_or_default()} />
|
|
||||||
<button
|
|
||||||
class="btn btn-danger"
|
|
||||||
type="button"
|
|
||||||
onclick={link.callback(move |_| ListAttributeInputMsg::Remove(i))}>
|
|
||||||
<i class="bi-x-circle-fill" aria-label="Remove value" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}).collect::<Html>()}
|
|
||||||
<button
|
|
||||||
class="btn btn-secondary"
|
|
||||||
type="button"
|
|
||||||
onclick={link.callback(|_| ListAttributeInputMsg::Append)}>
|
|
||||||
<i class="bi-plus-circle me-2"></i>
|
|
||||||
{"Add value"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
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>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
use std::str::FromStr;
|
|
||||||
|
|
||||||
use chrono::{DateTime, NaiveDateTime, Utc};
|
|
||||||
use wasm_bindgen::JsCast;
|
|
||||||
use web_sys::HtmlInputElement;
|
|
||||||
use yew::{function_component, html, use_state, virtual_dom::AttrValue, Event, Properties};
|
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
|
||||||
pub struct DateTimeInputProps {
|
|
||||||
pub name: AttrValue,
|
|
||||||
pub value: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[function_component(DateTimeInput)]
|
|
||||||
pub fn date_time_input(props: &DateTimeInputProps) -> Html {
|
|
||||||
let value = use_state(|| {
|
|
||||||
props
|
|
||||||
.value
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|x| DateTime::<Utc>::from_str(x).ok())
|
|
||||||
});
|
|
||||||
|
|
||||||
html! {
|
|
||||||
<div class="input-group">
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name={props.name.clone()}
|
|
||||||
value={value.as_ref().map(|v: &DateTime<Utc>| v.to_rfc3339())} />
|
|
||||||
<input
|
|
||||||
type="datetime-local"
|
|
||||||
step="1"
|
|
||||||
class="form-control"
|
|
||||||
value={value.as_ref().map(|v: &DateTime<Utc>| v.naive_utc().to_string())}
|
|
||||||
onchange={move |e: Event| {
|
|
||||||
let string_val =
|
|
||||||
e.target()
|
|
||||||
.expect("Event should have target")
|
|
||||||
.unchecked_into::<HtmlInputElement>()
|
|
||||||
.value();
|
|
||||||
value.set(
|
|
||||||
NaiveDateTime::from_str(&string_val)
|
|
||||||
.ok()
|
|
||||||
.map(|x| DateTime::from_naive_utc_and_offset(x, Utc))
|
|
||||||
)
|
|
||||||
}} />
|
|
||||||
<span class="input-group-text">{"UTC"}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
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>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
use std::{fmt::Display, str::FromStr};
|
|
||||||
|
|
||||||
use anyhow::{bail, Error, Ok, Result};
|
|
||||||
use gloo_file::{
|
|
||||||
callbacks::{read_as_bytes, FileReader},
|
|
||||||
File,
|
|
||||||
};
|
|
||||||
use web_sys::{FileList, HtmlInputElement, InputEvent};
|
|
||||||
use yew::Properties;
|
|
||||||
use yew::{prelude::*, virtual_dom::AttrValue};
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct JsFile {
|
|
||||||
file: Option<File>,
|
|
||||||
contents: Option<Vec<u8>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Display for JsFile {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
write!(
|
|
||||||
f,
|
|
||||||
"{}",
|
|
||||||
self.file.as_ref().map(File::name).unwrap_or_default()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FromStr for JsFile {
|
|
||||||
type Err = Error;
|
|
||||||
|
|
||||||
fn from_str(s: &str) -> Result<Self> {
|
|
||||||
if s.is_empty() {
|
|
||||||
Ok(JsFile::default())
|
|
||||||
} else {
|
|
||||||
bail!("Building file from non-empty string")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn to_base64(file: &JsFile) -> Result<String> {
|
|
||||||
match file {
|
|
||||||
JsFile {
|
|
||||||
file: None,
|
|
||||||
contents: None,
|
|
||||||
} => Ok(String::new()),
|
|
||||||
JsFile {
|
|
||||||
file: Some(_),
|
|
||||||
contents: None,
|
|
||||||
} => bail!("Image file hasn't finished loading, try again"),
|
|
||||||
JsFile {
|
|
||||||
file: Some(_),
|
|
||||||
contents: Some(data),
|
|
||||||
} => {
|
|
||||||
if !is_valid_jpeg(data.as_slice()) {
|
|
||||||
bail!("Chosen image is not a valid JPEG");
|
|
||||||
}
|
|
||||||
Ok(base64::encode(data))
|
|
||||||
}
|
|
||||||
JsFile {
|
|
||||||
file: None,
|
|
||||||
contents: Some(data),
|
|
||||||
} => Ok(base64::encode(data)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A [yew::Component] to display the user details, with a form allowing to edit them.
|
|
||||||
pub struct JpegFileInput {
|
|
||||||
// None means that the avatar hasn't changed.
|
|
||||||
avatar: Option<JsFile>,
|
|
||||||
reader: Option<FileReader>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub enum Msg {
|
|
||||||
Update,
|
|
||||||
/// A new file was selected.
|
|
||||||
FileSelected(File),
|
|
||||||
/// The "Clear" button for the avatar was clicked.
|
|
||||||
ClearClicked,
|
|
||||||
/// A picked file finished loading.
|
|
||||||
FileLoaded(String, Result<Vec<u8>>),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Properties, Clone, PartialEq, Eq)]
|
|
||||||
pub struct Props {
|
|
||||||
pub name: AttrValue,
|
|
||||||
pub value: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Component for JpegFileInput {
|
|
||||||
type Message = Msg;
|
|
||||||
type Properties = Props;
|
|
||||||
|
|
||||||
fn create(ctx: &Context<Self>) -> Self {
|
|
||||||
Self {
|
|
||||||
avatar: Some(JsFile {
|
|
||||||
file: None,
|
|
||||||
contents: ctx
|
|
||||||
.props()
|
|
||||||
.value
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|x| base64::decode(x).ok()),
|
|
||||||
}),
|
|
||||||
reader: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn changed(&mut self, ctx: &Context<Self>) -> bool {
|
|
||||||
self.avatar = Some(JsFile {
|
|
||||||
file: None,
|
|
||||||
contents: ctx
|
|
||||||
.props()
|
|
||||||
.value
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|x| base64::decode(x).ok()),
|
|
||||||
});
|
|
||||||
self.reader = None;
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
|
||||||
match msg {
|
|
||||||
Msg::Update => true,
|
|
||||||
Msg::FileSelected(new_avatar) => {
|
|
||||||
if self
|
|
||||||
.avatar
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|f| f.file.as_ref().map(|f| f.name()))
|
|
||||||
!= Some(new_avatar.name())
|
|
||||||
{
|
|
||||||
let file_name = new_avatar.name();
|
|
||||||
let link = ctx.link().clone();
|
|
||||||
self.reader = Some(read_as_bytes(&new_avatar, move |res| {
|
|
||||||
link.send_message(Msg::FileLoaded(
|
|
||||||
file_name,
|
|
||||||
res.map_err(|e| anyhow::anyhow!("{:#}", e)),
|
|
||||||
))
|
|
||||||
}));
|
|
||||||
self.avatar = Some(JsFile {
|
|
||||||
file: Some(new_avatar),
|
|
||||||
contents: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
true
|
|
||||||
}
|
|
||||||
Msg::ClearClicked => {
|
|
||||||
self.avatar = Some(JsFile::default());
|
|
||||||
true
|
|
||||||
}
|
|
||||||
Msg::FileLoaded(file_name, data) => {
|
|
||||||
if let Some(avatar) = &mut self.avatar {
|
|
||||||
if let Some(file) = &avatar.file {
|
|
||||||
if file.name() == file_name {
|
|
||||||
if let Result::Ok(data) = data {
|
|
||||||
if !is_valid_jpeg(data.as_slice()) {
|
|
||||||
// Clear the selection.
|
|
||||||
self.avatar = Some(JsFile::default());
|
|
||||||
// TODO: bail!("Chosen image is not a valid JPEG");
|
|
||||||
} else {
|
|
||||||
avatar.contents = Some(data);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.reader = None;
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
|
||||||
let link = &ctx.link();
|
|
||||||
|
|
||||||
let avatar_string = match &self.avatar {
|
|
||||||
Some(avatar) => {
|
|
||||||
let avatar_base64 = to_base64(avatar);
|
|
||||||
avatar_base64.as_deref().unwrap_or("").to_owned()
|
|
||||||
}
|
|
||||||
None => String::new(),
|
|
||||||
};
|
|
||||||
html! {
|
|
||||||
<div class="row align-items-center">
|
|
||||||
<div class="col-5">
|
|
||||||
<input type="hidden" name={ctx.props().name.clone()} value={avatar_string.clone()} />
|
|
||||||
<input
|
|
||||||
class="form-control"
|
|
||||||
id="avatarInput"
|
|
||||||
type="file"
|
|
||||||
accept="image/jpeg"
|
|
||||||
oninput={link.callback(|e: InputEvent| {
|
|
||||||
let input: HtmlInputElement = e.target_unchecked_into();
|
|
||||||
Self::upload_files(input.files())
|
|
||||||
})} />
|
|
||||||
</div>
|
|
||||||
<div class="col-3">
|
|
||||||
<button
|
|
||||||
class="btn btn-secondary col-auto"
|
|
||||||
id="avatarClear"
|
|
||||||
type="button"
|
|
||||||
onclick={link.callback(|_| {Msg::ClearClicked})}>
|
|
||||||
{"Clear"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
{
|
|
||||||
if !avatar_string.is_empty() {
|
|
||||||
html!{
|
|
||||||
<img
|
|
||||||
id="avatarDisplay"
|
|
||||||
src={format!("data:image/jpeg;base64, {}", avatar_string)}
|
|
||||||
style="max-height:128px;max-width:128px;height:auto;width:auto;"
|
|
||||||
alt="Avatar" />
|
|
||||||
}
|
|
||||||
} else { html! {} }
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl JpegFileInput {
|
|
||||||
fn upload_files(files: Option<FileList>) -> Msg {
|
|
||||||
match files {
|
|
||||||
Some(files) if files.length() > 0 => {
|
|
||||||
Msg::FileSelected(File::from(files.item(0).unwrap()))
|
|
||||||
}
|
|
||||||
Some(_) | None => Msg::Update,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_valid_jpeg(bytes: &[u8]) -> bool {
|
|
||||||
image::io::Reader::with_format(std::io::Cursor::new(bytes), image::ImageFormat::Jpeg)
|
|
||||||
.decode()
|
|
||||||
.is_ok()
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
pub mod attribute_input;
|
|
||||||
pub mod checkbox;
|
|
||||||
pub mod date_input;
|
|
||||||
pub mod field;
|
|
||||||
pub mod file_input;
|
|
||||||
pub mod select;
|
|
||||||
pub mod static_value;
|
|
||||||
pub mod submit;
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
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>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,10 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
components::{
|
components::{
|
||||||
add_group_member::{self, AddGroupMemberComponent},
|
add_group_member::{self, AddGroupMemberComponent},
|
||||||
group_details_form::GroupDetailsForm,
|
|
||||||
remove_user_from_group::RemoveUserFromGroupComponent,
|
remove_user_from_group::RemoveUserFromGroupComponent,
|
||||||
router::{AppRoute, Link},
|
router::{AppRoute, Link},
|
||||||
},
|
},
|
||||||
convert_attribute_type,
|
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||||
infra::{
|
|
||||||
common_component::{CommonComponent, CommonComponentParts},
|
|
||||||
form_utils::GraphQlAttributeSchema,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
use anyhow::{bail, Error, Result};
|
use anyhow::{bail, Error, Result};
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
@@ -27,28 +22,12 @@ pub struct GetGroupDetails;
|
|||||||
pub type Group = get_group_details::GetGroupDetailsGroup;
|
pub type Group = get_group_details::GetGroupDetailsGroup;
|
||||||
pub type User = get_group_details::GetGroupDetailsGroupUsers;
|
pub type User = get_group_details::GetGroupDetailsGroupUsers;
|
||||||
pub type AddGroupMemberUser = add_group_member::User;
|
pub type AddGroupMemberUser = add_group_member::User;
|
||||||
pub type Attribute = get_group_details::GetGroupDetailsGroupAttributes;
|
|
||||||
pub type AttributeSchema = get_group_details::GetGroupDetailsSchemaGroupSchemaAttributes;
|
|
||||||
pub type AttributeType = get_group_details::AttributeType;
|
|
||||||
|
|
||||||
convert_attribute_type!(AttributeType);
|
|
||||||
|
|
||||||
impl From<&AttributeSchema> for GraphQlAttributeSchema {
|
|
||||||
fn from(attr: &AttributeSchema) -> Self {
|
|
||||||
Self {
|
|
||||||
name: attr.name.clone(),
|
|
||||||
is_list: attr.is_list,
|
|
||||||
is_readonly: attr.is_readonly,
|
|
||||||
is_editable: attr.is_editable,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct GroupDetails {
|
pub struct GroupDetails {
|
||||||
common: CommonComponentParts<Self>,
|
common: CommonComponentParts<Self>,
|
||||||
/// The group info. If none, the error is in `error`. If `error` is None, then we haven't
|
/// The group info. If none, the error is in `error`. If `error` is None, then we haven't
|
||||||
/// received the server response yet.
|
/// received the server response yet.
|
||||||
group_and_schema: Option<(Group, Vec<AttributeSchema>)>,
|
group: Option<Group>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// State machine describing the possible transitions of the component state.
|
/// State machine describing the possible transitions of the component state.
|
||||||
@@ -59,13 +38,11 @@ pub enum Msg {
|
|||||||
OnError(Error),
|
OnError(Error),
|
||||||
OnUserAddedToGroup(AddGroupMemberUser),
|
OnUserAddedToGroup(AddGroupMemberUser),
|
||||||
OnUserRemovedFromGroup((String, i64)),
|
OnUserRemovedFromGroup((String, i64)),
|
||||||
DisplayNameUpdated,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(yew::Properties, Clone, PartialEq, Eq)]
|
#[derive(yew::Properties, Clone, PartialEq, Eq)]
|
||||||
pub struct Props {
|
pub struct Props {
|
||||||
pub group_id: i64,
|
pub group_id: i64,
|
||||||
pub is_admin: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GroupDetails {
|
impl GroupDetails {
|
||||||
@@ -92,16 +69,41 @@ impl GroupDetails {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn view_details(&self, ctx: &Context<Self>, g: &Group, schema: Vec<AttributeSchema>) -> Html {
|
fn view_details(&self, g: &Group) -> Html {
|
||||||
html! {
|
html! {
|
||||||
<>
|
<>
|
||||||
<h3>{g.display_name.to_string()}</h3>
|
<h3>{g.display_name.to_string()}</h3>
|
||||||
<GroupDetailsForm
|
<div class="py-3">
|
||||||
group={g.clone()}
|
<form class="form">
|
||||||
group_attributes_schema={schema}
|
<div class="form-group row mb-3">
|
||||||
is_admin={ctx.props().is_admin}
|
<label for="displayName"
|
||||||
on_display_name_updated={ctx.link().callback(|_| Msg::DisplayNameUpdated)}
|
class="form-label col-4 col-form-label">
|
||||||
/>
|
{"Group: "}
|
||||||
|
</label>
|
||||||
|
<div class="col-8">
|
||||||
|
<span id="groupId" class="form-constrol-static">{g.display_name.to_string()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group row mb-3">
|
||||||
|
<label for="creationDate"
|
||||||
|
class="form-label col-4 col-form-label">
|
||||||
|
{"Creation date: "}
|
||||||
|
</label>
|
||||||
|
<div class="col-8">
|
||||||
|
<span id="creationDate" class="form-constrol-static">{g.creation_date.naive_local().date()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group row mb-3">
|
||||||
|
<label for="uuid"
|
||||||
|
class="form-label col-4 col-form-label">
|
||||||
|
{"UUID: "}
|
||||||
|
</label>
|
||||||
|
<div class="col-8">
|
||||||
|
<span id="uuid" class="form-constrol-static">{g.uuid.to_string()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,38 +182,29 @@ impl GroupDetails {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<GroupDetails> for GroupDetails {
|
impl CommonComponent<GroupDetails> for GroupDetails {
|
||||||
fn handle_msg(
|
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
|
||||||
&mut self,
|
|
||||||
ctx: &Context<Self>,
|
|
||||||
msg: <Self as Component>::Message,
|
|
||||||
) -> Result<bool> {
|
|
||||||
match msg {
|
match msg {
|
||||||
Msg::GroupDetailsResponse(response) => match response {
|
Msg::GroupDetailsResponse(response) => match response {
|
||||||
Ok(group) => {
|
Ok(group) => self.group = Some(group.group),
|
||||||
self.group_and_schema =
|
|
||||||
Some((group.group, group.schema.group_schema.attributes))
|
|
||||||
}
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
self.group_and_schema = None;
|
self.group = None;
|
||||||
bail!("Error getting user details: {}", e);
|
bail!("Error getting user details: {}", e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Msg::OnError(e) => return Err(e),
|
Msg::OnError(e) => return Err(e),
|
||||||
Msg::OnUserAddedToGroup(user) => {
|
Msg::OnUserAddedToGroup(user) => {
|
||||||
self.group_and_schema.as_mut().unwrap().0.users.push(User {
|
self.group.as_mut().unwrap().users.push(User {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
display_name: user.display_name,
|
display_name: user.display_name,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Msg::OnUserRemovedFromGroup((user_id, _)) => {
|
Msg::OnUserRemovedFromGroup((user_id, _)) => {
|
||||||
self.group_and_schema
|
self.group
|
||||||
.as_mut()
|
.as_mut()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.0
|
|
||||||
.users
|
.users
|
||||||
.retain(|u| u.id != user_id);
|
.retain(|u| u.id != user_id);
|
||||||
}
|
}
|
||||||
Msg::DisplayNameUpdated => self.get_group_details(ctx),
|
|
||||||
}
|
}
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
@@ -228,7 +221,7 @@ impl Component for GroupDetails {
|
|||||||
fn create(ctx: &Context<Self>) -> Self {
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
let mut table = Self {
|
let mut table = Self {
|
||||||
common: CommonComponentParts::<Self>::create(),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
group_and_schema: None,
|
group: None,
|
||||||
};
|
};
|
||||||
table.get_group_details(ctx);
|
table.get_group_details(ctx);
|
||||||
table
|
table
|
||||||
@@ -239,15 +232,15 @@ impl Component for GroupDetails {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
match (&self.group_and_schema, &self.common.error) {
|
match (&self.group, &self.common.error) {
|
||||||
(None, None) => html! {{"Loading..."}},
|
(None, None) => html! {{"Loading..."}},
|
||||||
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
||||||
(Some((group, schema)), error) => {
|
(Some(u), error) => {
|
||||||
html! {
|
html! {
|
||||||
<div>
|
<div>
|
||||||
{self.view_details(ctx, group, schema.clone())}
|
{self.view_details(u)}
|
||||||
{self.view_user_list(ctx, group)}
|
{self.view_user_list(ctx, u)}
|
||||||
{self.view_add_user_button(ctx, group)}
|
{self.view_add_user_button(ctx, u)}
|
||||||
{self.view_messages(error)}
|
{self.view_messages(error)}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,272 +0,0 @@
|
|||||||
use crate::{
|
|
||||||
components::{
|
|
||||||
form::{
|
|
||||||
attribute_input::{ListAttributeInput, SingleAttributeInput},
|
|
||||||
static_value::StaticValue,
|
|
||||||
submit::Submit,
|
|
||||||
},
|
|
||||||
group_details::{Attribute, AttributeSchema, Group},
|
|
||||||
},
|
|
||||||
infra::{
|
|
||||||
common_component::{CommonComponent, CommonComponentParts},
|
|
||||||
form_utils::{read_all_form_attributes, AttributeValue, EmailIsRequired, IsAdmin},
|
|
||||||
schema::AttributeType,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
use anyhow::{Ok, Result};
|
|
||||||
use graphql_client::GraphQLQuery;
|
|
||||||
use yew::prelude::*;
|
|
||||||
|
|
||||||
/// The GraphQL query sent to the server to update the group details.
|
|
||||||
#[derive(GraphQLQuery)]
|
|
||||||
#[graphql(
|
|
||||||
schema_path = "../schema.graphql",
|
|
||||||
query_path = "queries/update_group.graphql",
|
|
||||||
response_derives = "Debug",
|
|
||||||
variables_derives = "Clone,PartialEq,Eq",
|
|
||||||
custom_scalars_module = "crate::infra::graphql"
|
|
||||||
)]
|
|
||||||
pub struct UpdateGroup;
|
|
||||||
|
|
||||||
/// A [yew::Component] to display the group details, with a form allowing to edit them.
|
|
||||||
pub struct GroupDetailsForm {
|
|
||||||
common: CommonComponentParts<Self>,
|
|
||||||
/// True if we just successfully updated the group, to display a success message.
|
|
||||||
just_updated: bool,
|
|
||||||
updated_group_name: bool,
|
|
||||||
group: Group,
|
|
||||||
form_ref: NodeRef,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub enum Msg {
|
|
||||||
/// A form field changed.
|
|
||||||
Update,
|
|
||||||
/// The "Submit" button was clicked.
|
|
||||||
SubmitClicked,
|
|
||||||
/// We got the response from the server about our update message.
|
|
||||||
GroupUpdated(Result<update_group::ResponseData>),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(yew::Properties, Clone, PartialEq)]
|
|
||||||
pub struct Props {
|
|
||||||
/// The current group details.
|
|
||||||
pub group: Group,
|
|
||||||
pub group_attributes_schema: Vec<AttributeSchema>,
|
|
||||||
pub is_admin: bool,
|
|
||||||
pub on_display_name_updated: Callback<()>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CommonComponent<GroupDetailsForm> for GroupDetailsForm {
|
|
||||||
fn handle_msg(
|
|
||||||
&mut self,
|
|
||||||
ctx: &Context<Self>,
|
|
||||||
msg: <Self as Component>::Message,
|
|
||||||
) -> Result<bool> {
|
|
||||||
match msg {
|
|
||||||
Msg::Update => Ok(true),
|
|
||||||
Msg::SubmitClicked => self.submit_group_update_form(ctx),
|
|
||||||
Msg::GroupUpdated(Err(e)) => Err(e),
|
|
||||||
Msg::GroupUpdated(Result::Ok(_)) => {
|
|
||||||
self.just_updated = true;
|
|
||||||
if self.updated_group_name {
|
|
||||||
self.updated_group_name = false;
|
|
||||||
ctx.props().on_display_name_updated.emit(());
|
|
||||||
}
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
|
||||||
&mut self.common
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Component for GroupDetailsForm {
|
|
||||||
type Message = Msg;
|
|
||||||
type Properties = Props;
|
|
||||||
|
|
||||||
fn create(ctx: &Context<Self>) -> Self {
|
|
||||||
Self {
|
|
||||||
common: CommonComponentParts::<Self>::create(),
|
|
||||||
just_updated: false,
|
|
||||||
updated_group_name: false,
|
|
||||||
group: ctx.props().group.clone(),
|
|
||||||
form_ref: NodeRef::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
|
||||||
self.just_updated = false;
|
|
||||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
|
||||||
let link = &ctx.link();
|
|
||||||
|
|
||||||
let can_edit =
|
|
||||||
|a: &AttributeSchema| (ctx.props().is_admin || a.is_editable) && !a.is_readonly;
|
|
||||||
let display_field = |a: &AttributeSchema| {
|
|
||||||
if can_edit(a) {
|
|
||||||
get_custom_attribute_input(a, &self.group.attributes)
|
|
||||||
} else {
|
|
||||||
get_custom_attribute_static(a, &self.group.attributes)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
html! {
|
|
||||||
<div class="py-3">
|
|
||||||
<form
|
|
||||||
class="form"
|
|
||||||
ref={self.form_ref.clone()}>
|
|
||||||
<StaticValue label="Group ID" id="groupId">
|
|
||||||
<i>{&self.group.id}</i>
|
|
||||||
</StaticValue>
|
|
||||||
{
|
|
||||||
ctx
|
|
||||||
.props()
|
|
||||||
.group_attributes_schema
|
|
||||||
.iter()
|
|
||||||
.filter(|a| a.is_hardcoded && a.name != "group_id")
|
|
||||||
.map(display_field)
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
}
|
|
||||||
{
|
|
||||||
ctx
|
|
||||||
.props()
|
|
||||||
.group_attributes_schema
|
|
||||||
.iter()
|
|
||||||
.filter(|a| !a.is_hardcoded)
|
|
||||||
.map(display_field)
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
}
|
|
||||||
<Submit
|
|
||||||
text="Save changes"
|
|
||||||
disabled={self.common.is_task_running()}
|
|
||||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitClicked})} />
|
|
||||||
</form>
|
|
||||||
{
|
|
||||||
if let Some(e) = &self.common.error {
|
|
||||||
html! {
|
|
||||||
<div class="alert alert-danger">
|
|
||||||
{e.to_string() }
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
} else { html! {} }
|
|
||||||
}
|
|
||||||
<div hidden={!self.just_updated}>
|
|
||||||
<div class="alert alert-success mt-4">{"Group successfully updated!"}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_custom_attribute_input(
|
|
||||||
attribute_schema: &AttributeSchema,
|
|
||||||
group_attributes: &[Attribute],
|
|
||||||
) -> Html {
|
|
||||||
let values = group_attributes
|
|
||||||
.iter()
|
|
||||||
.find(|a| a.name == attribute_schema.name)
|
|
||||||
.map(|attribute| attribute.value.clone())
|
|
||||||
.unwrap_or_default();
|
|
||||||
if attribute_schema.is_list {
|
|
||||||
html! {
|
|
||||||
<ListAttributeInput
|
|
||||||
name={attribute_schema.name.clone()}
|
|
||||||
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
|
||||||
values={values}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
html! {
|
|
||||||
<SingleAttributeInput
|
|
||||||
name={attribute_schema.name.clone()}
|
|
||||||
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
|
||||||
value={values.first().cloned().unwrap_or_default()}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_custom_attribute_static(
|
|
||||||
attribute_schema: &AttributeSchema,
|
|
||||||
group_attributes: &[Attribute],
|
|
||||||
) -> Html {
|
|
||||||
let values = group_attributes
|
|
||||||
.iter()
|
|
||||||
.find(|a| a.name == attribute_schema.name)
|
|
||||||
.map(|attribute| attribute.value.clone())
|
|
||||||
.unwrap_or_default();
|
|
||||||
html! {
|
|
||||||
<StaticValue label={attribute_schema.name.clone()} id={attribute_schema.name.clone()}>
|
|
||||||
{values.into_iter().map(|x| html!{<div>{x}</div>}).collect::<Vec<_>>()}
|
|
||||||
</StaticValue>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GroupDetailsForm {
|
|
||||||
fn submit_group_update_form(&mut self, ctx: &Context<Self>) -> Result<bool> {
|
|
||||||
let mut all_values = read_all_form_attributes(
|
|
||||||
ctx.props().group_attributes_schema.iter(),
|
|
||||||
&self.form_ref,
|
|
||||||
IsAdmin(ctx.props().is_admin),
|
|
||||||
EmailIsRequired(false),
|
|
||||||
)?;
|
|
||||||
let base_attributes = &self.group.attributes;
|
|
||||||
all_values.retain(|a| {
|
|
||||||
let base_val = base_attributes
|
|
||||||
.iter()
|
|
||||||
.find(|base_val| base_val.name == a.name);
|
|
||||||
base_val
|
|
||||||
.map(|v| v.value != a.values)
|
|
||||||
.unwrap_or(!a.values.is_empty())
|
|
||||||
});
|
|
||||||
if all_values.iter().any(|a| a.name == "display_name") {
|
|
||||||
self.updated_group_name = true;
|
|
||||||
}
|
|
||||||
let remove_attributes: Option<Vec<String>> = if all_values.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(all_values.iter().map(|a| a.name.clone()).collect())
|
|
||||||
};
|
|
||||||
let insert_attributes: Option<Vec<update_group::AttributeValueInput>> =
|
|
||||||
if remove_attributes.is_none() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(
|
|
||||||
all_values
|
|
||||||
.into_iter()
|
|
||||||
.filter(|a| !a.values.is_empty())
|
|
||||||
.map(
|
|
||||||
|AttributeValue { name, values }| update_group::AttributeValueInput {
|
|
||||||
name,
|
|
||||||
value: values,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.collect(),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
let mut group_input = update_group::UpdateGroupInput {
|
|
||||||
id: self.group.id,
|
|
||||||
displayName: None,
|
|
||||||
removeAttributes: None,
|
|
||||||
insertAttributes: None,
|
|
||||||
};
|
|
||||||
let default_group_input = group_input.clone();
|
|
||||||
group_input.removeAttributes = remove_attributes;
|
|
||||||
group_input.insertAttributes = insert_attributes;
|
|
||||||
// Nothing changed.
|
|
||||||
if group_input == default_group_input {
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
let req = update_group::Variables { group: group_input };
|
|
||||||
self.common.call_graphql::<UpdateGroup, _>(
|
|
||||||
ctx,
|
|
||||||
req,
|
|
||||||
Msg::GroupUpdated,
|
|
||||||
"Error trying to update group",
|
|
||||||
);
|
|
||||||
Ok(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
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,8 +1,5 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
components::{
|
components::router::{AppRoute, Link},
|
||||||
form::submit::Submit,
|
|
||||||
router::{AppRoute, Link},
|
|
||||||
},
|
|
||||||
infra::{
|
infra::{
|
||||||
api::HostService,
|
api::HostService,
|
||||||
common_component::{CommonComponent, CommonComponentParts},
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
@@ -69,7 +66,7 @@ impl CommonComponent<LoginForm> for LoginForm {
|
|||||||
opaque::client::login::start_login(&password, &mut rng)
|
opaque::client::login::start_login(&password, &mut rng)
|
||||||
.context("Could not initialize login")?;
|
.context("Could not initialize login")?;
|
||||||
let req = login::ClientLoginStartRequest {
|
let req = login::ClientLoginStartRequest {
|
||||||
username: username.into(),
|
username,
|
||||||
login_start_request: message,
|
login_start_request: message,
|
||||||
};
|
};
|
||||||
self.common
|
self.common
|
||||||
@@ -158,62 +155,68 @@ impl Component for LoginForm {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
html! {
|
html! {
|
||||||
<form class="form center-block col-sm-4 col-offset-4">
|
<form
|
||||||
<div class="input-group">
|
class="form center-block col-sm-4 col-offset-4">
|
||||||
<div class="input-group-prepend">
|
<div class="input-group">
|
||||||
<span class="input-group-text">
|
<div class="input-group-prepend">
|
||||||
<i class="bi-person-fill"/>
|
<span class="input-group-text">
|
||||||
</span>
|
<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)} />
|
||||||
</div>
|
</div>
|
||||||
<Field
|
<div class="input-group">
|
||||||
class="form-control"
|
<div class="input-group-prepend">
|
||||||
class_invalid="is-invalid has-error"
|
<span class="input-group-text">
|
||||||
class_valid="has-success"
|
<i class="bi-lock-fill"/>
|
||||||
form={&self.form}
|
</span>
|
||||||
field_name="username"
|
</div>
|
||||||
placeholder="Username"
|
<Field
|
||||||
autocomplete="username"
|
class="form-control"
|
||||||
oninput={link.callback(|_| Msg::Update)} />
|
class_invalid="is-invalid has-error"
|
||||||
</div>
|
class_valid="has-success"
|
||||||
<div class="input-group">
|
form={&self.form}
|
||||||
<div class="input-group-prepend">
|
field_name="password"
|
||||||
<span class="input-group-text">
|
input_type="password"
|
||||||
<i class="bi-lock-fill"/>
|
placeholder="Password"
|
||||||
</span>
|
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! {} }
|
||||||
|
}
|
||||||
</div>
|
</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>
|
</form>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,12 @@
|
|||||||
pub mod add_group_member;
|
pub mod add_group_member;
|
||||||
pub mod add_user_to_group;
|
pub mod add_user_to_group;
|
||||||
pub mod app;
|
pub mod app;
|
||||||
pub mod avatar;
|
|
||||||
pub mod banner;
|
|
||||||
pub mod change_password;
|
pub mod change_password;
|
||||||
pub mod create_group;
|
pub mod create_group;
|
||||||
pub mod create_group_attribute;
|
|
||||||
pub mod create_user;
|
pub mod create_user;
|
||||||
pub mod create_user_attribute;
|
|
||||||
pub mod delete_group;
|
pub mod delete_group;
|
||||||
pub mod delete_group_attribute;
|
|
||||||
pub mod delete_user;
|
pub mod delete_user;
|
||||||
pub mod delete_user_attribute;
|
|
||||||
pub mod form;
|
|
||||||
pub mod group_details;
|
pub mod group_details;
|
||||||
pub mod group_details_form;
|
|
||||||
pub mod group_schema_table;
|
|
||||||
pub mod group_table;
|
pub mod group_table;
|
||||||
pub mod login;
|
pub mod login;
|
||||||
pub mod logout;
|
pub mod logout;
|
||||||
@@ -26,5 +17,4 @@ pub mod router;
|
|||||||
pub mod select;
|
pub mod select;
|
||||||
pub mod user_details;
|
pub mod user_details;
|
||||||
pub mod user_details_form;
|
pub mod user_details_form;
|
||||||
pub mod user_schema_table;
|
|
||||||
pub mod user_table;
|
pub mod user_table;
|
||||||
|
|||||||
@@ -104,11 +104,7 @@ impl Component for ResetPasswordStep1Form {
|
|||||||
</div>
|
</div>
|
||||||
{ if self.just_succeeded {
|
{ if self.just_succeeded {
|
||||||
html! {
|
html! {
|
||||||
{"If a user with this username or email exists, a password reset email will \
|
{"A reset token has been sent to your email."}
|
||||||
be sent to the associated email address. Please check your email and \
|
|
||||||
follow the instructions. If you don't receive an email, please check \
|
|
||||||
your spam folder. If you still don't receive an email, please contact \
|
|
||||||
your administrator."}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
html! {
|
html! {
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
components::{
|
components::router::{AppRoute, Link},
|
||||||
form::{field::Field, submit::Submit},
|
|
||||||
router::{AppRoute, Link},
|
|
||||||
},
|
|
||||||
infra::{
|
infra::{
|
||||||
api::HostService,
|
api::HostService,
|
||||||
common_component::{CommonComponent, CommonComponentParts},
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
@@ -68,10 +65,10 @@ impl CommonComponent<ResetPasswordStep2Form> for ResetPasswordStep2Form {
|
|||||||
let mut rng = rand::rngs::OsRng;
|
let mut rng = rand::rngs::OsRng;
|
||||||
let new_password = self.form.model().password;
|
let new_password = self.form.model().password;
|
||||||
let registration_start_request =
|
let registration_start_request =
|
||||||
opaque_registration::start_registration(new_password.as_bytes(), &mut rng)
|
opaque_registration::start_registration(&new_password, &mut rng)
|
||||||
.context("Could not initiate password change")?;
|
.context("Could not initiate password change")?;
|
||||||
let req = registration::ClientRegistrationStartRequest {
|
let req = registration::ClientRegistrationStartRequest {
|
||||||
username: self.username.as_ref().unwrap().into(),
|
username: self.username.clone().unwrap(),
|
||||||
registration_start_request: registration_start_request.message,
|
registration_start_request: registration_start_request.message,
|
||||||
};
|
};
|
||||||
self.opaque_data = Some(registration_start_request.state);
|
self.opaque_data = Some(registration_start_request.state);
|
||||||
@@ -167,29 +164,61 @@ impl Component for ResetPasswordStep2Form {
|
|||||||
}
|
}
|
||||||
_ => (),
|
_ => (),
|
||||||
};
|
};
|
||||||
|
type Field = yew_form::Field<FormModel>;
|
||||||
html! {
|
html! {
|
||||||
<>
|
<>
|
||||||
<h2>{"Reset your password"}</h2>
|
<h2>{"Reset your password"}</h2>
|
||||||
<form class="form">
|
<form
|
||||||
<Field<FormModel>
|
class="form">
|
||||||
label="New password"
|
<div class="form-group row">
|
||||||
required=true
|
<label for="new_password"
|
||||||
form={&self.form}
|
class="form-label col-sm-2 col-form-label">
|
||||||
field_name="password"
|
{"New password*:"}
|
||||||
autocomplete="new-password"
|
</label>
|
||||||
input_type="password"
|
<div class="col-sm-10">
|
||||||
oninput={link.callback(|_| Msg::FormUpdate)} />
|
<Field
|
||||||
<Field<FormModel>
|
form={&self.form}
|
||||||
label="Confirm password"
|
field_name="password"
|
||||||
required=true
|
class="form-control"
|
||||||
form={&self.form}
|
class_invalid="is-invalid has-error"
|
||||||
field_name="confirm_password"
|
class_valid="has-success"
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
input_type="password"
|
input_type="password"
|
||||||
oninput={link.callback(|_| Msg::FormUpdate)} />
|
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||||
<Submit
|
<div class="invalid-feedback">
|
||||||
disabled={self.common.is_task_running()}
|
{&self.form.field_message("password")}
|
||||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})} />
|
</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>
|
</form>
|
||||||
{ if let Some(e) = &self.common.error {
|
{ if let Some(e) = &self.common.error {
|
||||||
html! {
|
html! {
|
||||||
|
|||||||
@@ -22,14 +22,6 @@ pub enum AppRoute {
|
|||||||
ListGroups,
|
ListGroups,
|
||||||
#[at("/group/:group_id")]
|
#[at("/group/:group_id")]
|
||||||
GroupDetails { group_id: i64 },
|
GroupDetails { group_id: i64 },
|
||||||
#[at("/user-attributes")]
|
|
||||||
ListUserSchema,
|
|
||||||
#[at("/user-attributes/create")]
|
|
||||||
CreateUserAttribute,
|
|
||||||
#[at("/group-attributes")]
|
|
||||||
ListGroupSchema,
|
|
||||||
#[at("/group-attributes/create")]
|
|
||||||
CreateGroupAttribute,
|
|
||||||
#[at("/")]
|
#[at("/")]
|
||||||
Index,
|
Index,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,7 @@ use crate::{
|
|||||||
router::{AppRoute, Link},
|
router::{AppRoute, Link},
|
||||||
user_details_form::UserDetailsForm,
|
user_details_form::UserDetailsForm,
|
||||||
},
|
},
|
||||||
convert_attribute_type,
|
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||||
infra::{
|
|
||||||
common_component::{CommonComponent, CommonComponentParts},
|
|
||||||
form_utils::GraphQlAttributeSchema,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
use anyhow::{bail, Error, Result};
|
use anyhow::{bail, Error, Result};
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
@@ -26,34 +22,12 @@ pub struct GetUserDetails;
|
|||||||
|
|
||||||
pub type User = get_user_details::GetUserDetailsUser;
|
pub type User = get_user_details::GetUserDetailsUser;
|
||||||
pub type Group = get_user_details::GetUserDetailsUserGroups;
|
pub type Group = get_user_details::GetUserDetailsUserGroups;
|
||||||
pub type Attribute = get_user_details::GetUserDetailsUserAttributes;
|
|
||||||
pub type AttributeSchema = get_user_details::GetUserDetailsSchemaUserSchemaAttributes;
|
|
||||||
pub type AttributeType = get_user_details::AttributeType;
|
|
||||||
|
|
||||||
convert_attribute_type!(AttributeType);
|
|
||||||
|
|
||||||
impl From<&AttributeSchema> for GraphQlAttributeSchema {
|
|
||||||
fn from(attr: &AttributeSchema) -> Self {
|
|
||||||
Self {
|
|
||||||
name: attr.name.clone(),
|
|
||||||
is_list: attr.is_list,
|
|
||||||
is_readonly: attr.is_readonly,
|
|
||||||
is_editable: attr.is_editable,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct UserDetails {
|
pub struct UserDetails {
|
||||||
common: CommonComponentParts<Self>,
|
common: CommonComponentParts<Self>,
|
||||||
/// The user info. If none, the error is in `error`. If `error` is None, then we haven't
|
/// The user info. If none, the error is in `error`. If `error` is None, then we haven't
|
||||||
/// received the server response yet.
|
/// received the server response yet.
|
||||||
user_and_schema: Option<(User, Vec<AttributeSchema>)>,
|
user: Option<User>,
|
||||||
}
|
|
||||||
|
|
||||||
impl UserDetails {
|
|
||||||
fn mut_groups(&mut self) -> &mut Vec<Group> {
|
|
||||||
&mut self.user_and_schema.as_mut().unwrap().0.groups
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// State machine describing the possible transitions of the component state.
|
/// State machine describing the possible transitions of the component state.
|
||||||
@@ -76,20 +50,22 @@ impl CommonComponent<UserDetails> for UserDetails {
|
|||||||
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::UserDetailsResponse(response) => match response {
|
Msg::UserDetailsResponse(response) => match response {
|
||||||
Ok(user) => {
|
Ok(user) => self.user = Some(user.user),
|
||||||
self.user_and_schema = Some((user.user, user.schema.user_schema.attributes))
|
|
||||||
}
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
self.user_and_schema = None;
|
self.user = None;
|
||||||
bail!("Error getting user details: {}", e);
|
bail!("Error getting user details: {}", e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Msg::OnError(e) => return Err(e),
|
Msg::OnError(e) => return Err(e),
|
||||||
Msg::OnUserAddedToGroup(group) => {
|
Msg::OnUserAddedToGroup(group) => {
|
||||||
self.mut_groups().push(group);
|
self.user.as_mut().unwrap().groups.push(group);
|
||||||
}
|
}
|
||||||
Msg::OnUserRemovedFromGroup((_, group_id)) => {
|
Msg::OnUserRemovedFromGroup((_, group_id)) => {
|
||||||
self.mut_groups().retain(|g| g.id != group_id);
|
self.user
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.groups
|
||||||
|
.retain(|g| g.id != group_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(true)
|
Ok(true)
|
||||||
@@ -202,7 +178,7 @@ impl Component for UserDetails {
|
|||||||
fn create(ctx: &Context<Self>) -> Self {
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
let mut table = Self {
|
let mut table = Self {
|
||||||
common: CommonComponentParts::<Self>::create(),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
user_and_schema: None,
|
user: None,
|
||||||
};
|
};
|
||||||
table.get_user_details(ctx);
|
table.get_user_details(ctx);
|
||||||
table
|
table
|
||||||
@@ -213,8 +189,10 @@ impl Component for UserDetails {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
match (&self.user_and_schema, &self.common.error) {
|
match (&self.user, &self.common.error) {
|
||||||
(Some((u, schema)), error) => {
|
(None, None) => html! {{"Loading..."}},
|
||||||
|
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
||||||
|
(Some(u), error) => {
|
||||||
html! {
|
html! {
|
||||||
<>
|
<>
|
||||||
<h3>{u.id.to_string()}</h3>
|
<h3>{u.id.to_string()}</h3>
|
||||||
@@ -229,20 +207,13 @@ impl Component for UserDetails {
|
|||||||
<div>
|
<div>
|
||||||
<h5 class="row m-3 fw-bold">{"User details"}</h5>
|
<h5 class="row m-3 fw-bold">{"User details"}</h5>
|
||||||
</div>
|
</div>
|
||||||
<UserDetailsForm
|
<UserDetailsForm user={u.clone()} />
|
||||||
user={u.clone()}
|
|
||||||
user_attributes_schema={schema.clone()}
|
|
||||||
is_admin={ctx.props().is_admin}
|
|
||||||
is_edited_user_admin={u.groups.iter().any(|g| g.display_name == "lldap_admin")}
|
|
||||||
/>
|
|
||||||
{self.view_group_memberships(ctx, u)}
|
{self.view_group_memberships(ctx, u)}
|
||||||
{self.view_add_group_button(ctx, u)}
|
{self.view_add_group_button(ctx, u)}
|
||||||
{self.view_messages(error)}
|
{self.view_messages(error)}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(None, None) => html! {{"Loading..."}},
|
|
||||||
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,56 @@
|
|||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
components::{
|
components::user_details::User,
|
||||||
form::{
|
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||||
attribute_input::{ListAttributeInput, SingleAttributeInput},
|
};
|
||||||
static_value::StaticValue,
|
use anyhow::{bail, Error, Result};
|
||||||
submit::Submit,
|
use gloo_file::{
|
||||||
},
|
callbacks::{read_as_bytes, FileReader},
|
||||||
user_details::{Attribute, AttributeSchema, User},
|
File,
|
||||||
},
|
|
||||||
infra::{
|
|
||||||
common_component::{CommonComponent, CommonComponentParts},
|
|
||||||
form_utils::{read_all_form_attributes, AttributeValue, EmailIsRequired, IsAdmin},
|
|
||||||
schema::AttributeType,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
use anyhow::{Ok, Result};
|
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
|
use validator_derive::Validate;
|
||||||
|
use web_sys::{FileList, HtmlInputElement, InputEvent};
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
|
use yew_form_derive::Model;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct JsFile {
|
||||||
|
file: Option<File>,
|
||||||
|
contents: Option<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToString for JsFile {
|
||||||
|
fn to_string(&self) -> String {
|
||||||
|
self.file
|
||||||
|
.as_ref()
|
||||||
|
.map(File::name)
|
||||||
|
.unwrap_or_else(String::new)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for JsFile {
|
||||||
|
type Err = Error;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self> {
|
||||||
|
if s.is_empty() {
|
||||||
|
Ok(JsFile::default())
|
||||||
|
} else {
|
||||||
|
bail!("Building file from non-empty string")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fields of the form, with the editable details and the constraints.
|
||||||
|
#[derive(Model, Validate, PartialEq, Eq, Clone)]
|
||||||
|
pub struct UserModel {
|
||||||
|
#[validate(email)]
|
||||||
|
email: String,
|
||||||
|
display_name: String,
|
||||||
|
first_name: String,
|
||||||
|
last_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// The GraphQL query sent to the server to update the user details.
|
/// The GraphQL query sent to the server to update the user details.
|
||||||
#[derive(GraphQLQuery)]
|
#[derive(GraphQLQuery)]
|
||||||
@@ -31,17 +66,23 @@ pub struct UpdateUser;
|
|||||||
/// A [yew::Component] to display the user details, with a form allowing to edit them.
|
/// A [yew::Component] to display the user details, with a form allowing to edit them.
|
||||||
pub struct UserDetailsForm {
|
pub struct UserDetailsForm {
|
||||||
common: CommonComponentParts<Self>,
|
common: CommonComponentParts<Self>,
|
||||||
|
form: yew_form::Form<UserModel>,
|
||||||
|
avatar: JsFile,
|
||||||
|
reader: Option<FileReader>,
|
||||||
/// True if we just successfully updated the user, to display a success message.
|
/// True if we just successfully updated the user, to display a success message.
|
||||||
just_updated: bool,
|
just_updated: bool,
|
||||||
user: User,
|
user: User,
|
||||||
form_ref: NodeRef,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum Msg {
|
pub enum Msg {
|
||||||
/// A form field changed.
|
/// A form field changed.
|
||||||
Update,
|
Update,
|
||||||
|
/// A new file was selected.
|
||||||
|
FileSelected(File),
|
||||||
/// The "Submit" button was clicked.
|
/// The "Submit" button was clicked.
|
||||||
SubmitClicked,
|
SubmitClicked,
|
||||||
|
/// A picked file finished loading.
|
||||||
|
FileLoaded(String, Result<Vec<u8>>),
|
||||||
/// We got the response from the server about our update message.
|
/// We got the response from the server about our update message.
|
||||||
UserUpdated(Result<update_user::ResponseData>),
|
UserUpdated(Result<update_user::ResponseData>),
|
||||||
}
|
}
|
||||||
@@ -50,9 +91,6 @@ pub enum Msg {
|
|||||||
pub struct Props {
|
pub struct Props {
|
||||||
/// The current user details.
|
/// The current user details.
|
||||||
pub user: User,
|
pub user: User,
|
||||||
pub user_attributes_schema: Vec<AttributeSchema>,
|
|
||||||
pub is_admin: bool,
|
|
||||||
pub is_edited_user_admin: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<UserDetailsForm> for UserDetailsForm {
|
impl CommonComponent<UserDetailsForm> for UserDetailsForm {
|
||||||
@@ -63,12 +101,42 @@ impl CommonComponent<UserDetailsForm> for UserDetailsForm {
|
|||||||
) -> Result<bool> {
|
) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::Update => Ok(true),
|
Msg::Update => Ok(true),
|
||||||
Msg::SubmitClicked => self.submit_user_update_form(ctx),
|
Msg::FileSelected(new_avatar) => {
|
||||||
Msg::UserUpdated(Err(e)) => Err(e),
|
if self.avatar.file.as_ref().map(|f| f.name()) != Some(new_avatar.name()) {
|
||||||
Msg::UserUpdated(Result::Ok(_)) => {
|
let file_name = new_avatar.name();
|
||||||
self.just_updated = true;
|
let link = ctx.link().clone();
|
||||||
|
self.reader = Some(read_as_bytes(&new_avatar, move |res| {
|
||||||
|
link.send_message(Msg::FileLoaded(
|
||||||
|
file_name,
|
||||||
|
res.map_err(|e| anyhow::anyhow!("{:#}", e)),
|
||||||
|
))
|
||||||
|
}));
|
||||||
|
self.avatar = JsFile {
|
||||||
|
file: Some(new_avatar),
|
||||||
|
contents: None,
|
||||||
|
};
|
||||||
|
}
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
Msg::SubmitClicked => self.submit_user_update_form(ctx),
|
||||||
|
Msg::UserUpdated(response) => self.user_update_finished(response),
|
||||||
|
Msg::FileLoaded(file_name, data) => {
|
||||||
|
if let Some(file) = &self.avatar.file {
|
||||||
|
if file.name() == file_name {
|
||||||
|
let data = data?;
|
||||||
|
if !is_valid_jpeg(data.as_slice()) {
|
||||||
|
// Clear the selection.
|
||||||
|
self.avatar = JsFile::default();
|
||||||
|
bail!("Chosen image is not a valid JPEG");
|
||||||
|
} else {
|
||||||
|
self.avatar.contents = Some(data);
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.reader = None;
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,11 +150,19 @@ impl Component for UserDetailsForm {
|
|||||||
type Properties = Props;
|
type Properties = Props;
|
||||||
|
|
||||||
fn create(ctx: &Context<Self>) -> Self {
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
|
let model = UserModel {
|
||||||
|
email: ctx.props().user.email.clone(),
|
||||||
|
display_name: ctx.props().user.display_name.clone(),
|
||||||
|
first_name: ctx.props().user.first_name.clone(),
|
||||||
|
last_name: ctx.props().user.last_name.clone(),
|
||||||
|
};
|
||||||
Self {
|
Self {
|
||||||
common: CommonComponentParts::<Self>::create(),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
|
form: yew_form::Form::new(model),
|
||||||
|
avatar: JsFile::default(),
|
||||||
just_updated: false,
|
just_updated: false,
|
||||||
|
reader: None,
|
||||||
user: ctx.props().user.clone(),
|
user: ctx.props().user.clone(),
|
||||||
form_ref: NodeRef::default(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,47 +172,156 @@ impl Component for UserDetailsForm {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
|
type Field = yew_form::Field<UserModel>;
|
||||||
let link = &ctx.link();
|
let link = &ctx.link();
|
||||||
|
|
||||||
let can_edit =
|
let avatar_base64 = maybe_to_base64(&self.avatar).unwrap_or_default();
|
||||||
|a: &AttributeSchema| (ctx.props().is_admin || a.is_editable) && !a.is_readonly;
|
let avatar_string = avatar_base64
|
||||||
let display_field = |a: &AttributeSchema| {
|
.as_deref()
|
||||||
if can_edit(a) {
|
.or(self.user.avatar.as_deref())
|
||||||
get_custom_attribute_input(a, &self.user.attributes)
|
.unwrap_or("");
|
||||||
} else {
|
|
||||||
get_custom_attribute_static(a, &self.user.attributes)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
html! {
|
html! {
|
||||||
<div class="py-3">
|
<div class="py-3">
|
||||||
<form
|
<form class="form">
|
||||||
class="form"
|
<div class="form-group row mb-3">
|
||||||
ref={self.form_ref.clone()}>
|
<label for="userId"
|
||||||
<StaticValue label="User ID" id="userId">
|
class="form-label col-4 col-form-label">
|
||||||
<i>{&self.user.id}</i>
|
{"User ID: "}
|
||||||
</StaticValue>
|
</label>
|
||||||
{
|
<div class="col-8">
|
||||||
ctx
|
<span id="userId" class="form-control-static"><i>{&self.user.id}</i></span>
|
||||||
.props()
|
</div>
|
||||||
.user_attributes_schema
|
</div>
|
||||||
.iter()
|
<div class="form-group row mb-3">
|
||||||
.filter(|a| a.is_hardcoded && a.name != "user_id")
|
<label for="creationDate"
|
||||||
.map(display_field)
|
class="form-label col-4 col-form-label">
|
||||||
.collect::<Vec<_>>()
|
{"Creation date: "}
|
||||||
}
|
</label>
|
||||||
{
|
<div class="col-8">
|
||||||
ctx
|
<span id="creationDate" class="form-control-static">{&self.user.creation_date.naive_local().date()}</span>
|
||||||
.props()
|
</div>
|
||||||
.user_attributes_schema
|
</div>
|
||||||
.iter()
|
<div class="form-group row mb-3">
|
||||||
.filter(|a| !a.is_hardcoded)
|
<label for="uuid"
|
||||||
.map(display_field)
|
class="form-label col-4 col-form-label">
|
||||||
.collect::<Vec<_>>()
|
{"UUID: "}
|
||||||
}
|
</label>
|
||||||
<Submit
|
<div class="col-8">
|
||||||
text="Save changes"
|
<span id="creationDate" class="form-control-static">{&self.user.uuid}</span>
|
||||||
disabled={self.common.is_task_running()}
|
</div>
|
||||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitClicked})} />
|
</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>
|
||||||
|
<div class="form-group row align-items-center mb-3">
|
||||||
|
<label for="avatar"
|
||||||
|
class="form-label col-4 col-form-label">
|
||||||
|
{"Avatar: "}
|
||||||
|
</label>
|
||||||
|
<div class="col-8">
|
||||||
|
<div class="row align-items-center">
|
||||||
|
<div class="col-8">
|
||||||
|
<input
|
||||||
|
class="form-control"
|
||||||
|
id="avatarInput"
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg"
|
||||||
|
oninput={link.callback(|e: InputEvent| {
|
||||||
|
let input: HtmlInputElement = e.target_unchecked_into();
|
||||||
|
Self::upload_files(input.files())
|
||||||
|
})} />
|
||||||
|
</div>
|
||||||
|
<div class="col-4">
|
||||||
|
<img
|
||||||
|
id="avatarDisplay"
|
||||||
|
src={format!("data:image/jpeg;base64, {}", avatar_string)}
|
||||||
|
style="max-height:128px;max-width:128px;height:auto;width:auto;"
|
||||||
|
alt="Avatar" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
</form>
|
</form>
|
||||||
{
|
{
|
||||||
if let Some(e) = &self.common.error {
|
if let Some(e) = &self.common.error {
|
||||||
@@ -155,97 +340,19 @@ impl Component for UserDetailsForm {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_custom_attribute_input(
|
|
||||||
attribute_schema: &AttributeSchema,
|
|
||||||
user_attributes: &[Attribute],
|
|
||||||
) -> Html {
|
|
||||||
let values = user_attributes
|
|
||||||
.iter()
|
|
||||||
.find(|a| a.name == attribute_schema.name)
|
|
||||||
.map(|attribute| attribute.value.clone())
|
|
||||||
.unwrap_or_default();
|
|
||||||
if attribute_schema.is_list {
|
|
||||||
html! {
|
|
||||||
<ListAttributeInput
|
|
||||||
name={attribute_schema.name.clone()}
|
|
||||||
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
|
||||||
values={values}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
html! {
|
|
||||||
<SingleAttributeInput
|
|
||||||
name={attribute_schema.name.clone()}
|
|
||||||
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
|
||||||
value={values.first().cloned().unwrap_or_default()}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_custom_attribute_static(
|
|
||||||
attribute_schema: &AttributeSchema,
|
|
||||||
user_attributes: &[Attribute],
|
|
||||||
) -> Html {
|
|
||||||
let values = user_attributes
|
|
||||||
.iter()
|
|
||||||
.find(|a| a.name == attribute_schema.name)
|
|
||||||
.map(|attribute| attribute.value.clone())
|
|
||||||
.unwrap_or_default();
|
|
||||||
html! {
|
|
||||||
<StaticValue label={attribute_schema.name.clone()} id={attribute_schema.name.clone()}>
|
|
||||||
{values.into_iter().map(|x| html!{<div>{x}</div>}).collect::<Vec<_>>()}
|
|
||||||
</StaticValue>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl UserDetailsForm {
|
impl UserDetailsForm {
|
||||||
fn submit_user_update_form(&mut self, ctx: &Context<Self>) -> Result<bool> {
|
fn submit_user_update_form(&mut self, ctx: &Context<Self>) -> Result<bool> {
|
||||||
// TODO: Handle unloaded files.
|
if !self.form.validate() {
|
||||||
// if let Some(JsFile {
|
bail!("Invalid inputs");
|
||||||
// file: Some(_),
|
}
|
||||||
// contents: None,
|
if let JsFile {
|
||||||
// }) = &self.avatar
|
file: Some(_),
|
||||||
// {
|
contents: None,
|
||||||
// bail!("Image file hasn't finished loading, try again");
|
} = &self.avatar
|
||||||
// }
|
{
|
||||||
let mut all_values = read_all_form_attributes(
|
bail!("Image file hasn't finished loading, try again");
|
||||||
ctx.props().user_attributes_schema.iter(),
|
}
|
||||||
&self.form_ref,
|
let base_user = &self.user;
|
||||||
IsAdmin(ctx.props().is_admin),
|
|
||||||
EmailIsRequired(!ctx.props().is_edited_user_admin),
|
|
||||||
)?;
|
|
||||||
let base_attributes = &self.user.attributes;
|
|
||||||
all_values.retain(|a| {
|
|
||||||
let base_val = base_attributes
|
|
||||||
.iter()
|
|
||||||
.find(|base_val| base_val.name == a.name);
|
|
||||||
base_val
|
|
||||||
.map(|v| v.value != a.values)
|
|
||||||
.unwrap_or(!a.values.is_empty())
|
|
||||||
});
|
|
||||||
let remove_attributes: Option<Vec<String>> = if all_values.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(all_values.iter().map(|a| a.name.clone()).collect())
|
|
||||||
};
|
|
||||||
let insert_attributes: Option<Vec<update_user::AttributeValueInput>> =
|
|
||||||
if remove_attributes.is_none() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(
|
|
||||||
all_values
|
|
||||||
.into_iter()
|
|
||||||
.filter(|a| !a.values.is_empty())
|
|
||||||
.map(
|
|
||||||
|AttributeValue { name, values }| update_user::AttributeValueInput {
|
|
||||||
name,
|
|
||||||
value: values,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.collect(),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
let mut user_input = update_user::UpdateUserInput {
|
let mut user_input = update_user::UpdateUserInput {
|
||||||
id: self.user.id.clone(),
|
id: self.user.id.clone(),
|
||||||
email: None,
|
email: None,
|
||||||
@@ -253,12 +360,23 @@ impl UserDetailsForm {
|
|||||||
firstName: None,
|
firstName: None,
|
||||||
lastName: None,
|
lastName: None,
|
||||||
avatar: None,
|
avatar: None,
|
||||||
removeAttributes: None,
|
|
||||||
insertAttributes: None,
|
|
||||||
};
|
};
|
||||||
let default_user_input = user_input.clone();
|
let default_user_input = user_input.clone();
|
||||||
user_input.removeAttributes = remove_attributes;
|
let model = self.form.model();
|
||||||
user_input.insertAttributes = insert_attributes;
|
let email = model.email;
|
||||||
|
if base_user.email != email {
|
||||||
|
user_input.email = Some(email);
|
||||||
|
}
|
||||||
|
if base_user.display_name != model.display_name {
|
||||||
|
user_input.displayName = Some(model.display_name);
|
||||||
|
}
|
||||||
|
if base_user.first_name != model.first_name {
|
||||||
|
user_input.firstName = Some(model.first_name);
|
||||||
|
}
|
||||||
|
if base_user.last_name != model.last_name {
|
||||||
|
user_input.lastName = Some(model.last_name);
|
||||||
|
}
|
||||||
|
user_input.avatar = maybe_to_base64(&self.avatar)?;
|
||||||
// Nothing changed.
|
// Nothing changed.
|
||||||
if user_input == default_user_input {
|
if user_input == default_user_input {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
@@ -272,4 +390,58 @@ impl UserDetailsForm {
|
|||||||
);
|
);
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn user_update_finished(&mut self, r: Result<update_user::ResponseData>) -> Result<bool> {
|
||||||
|
r?;
|
||||||
|
let model = self.form.model();
|
||||||
|
self.user.email = model.email;
|
||||||
|
self.user.display_name = model.display_name;
|
||||||
|
self.user.first_name = model.first_name;
|
||||||
|
self.user.last_name = model.last_name;
|
||||||
|
if let Some(avatar) = maybe_to_base64(&self.avatar)? {
|
||||||
|
self.user.avatar = Some(avatar);
|
||||||
|
}
|
||||||
|
self.just_updated = true;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn upload_files(files: Option<FileList>) -> Msg {
|
||||||
|
if let Some(files) = files {
|
||||||
|
if files.length() > 0 {
|
||||||
|
Msg::FileSelected(File::from(files.item(0).unwrap()))
|
||||||
|
} else {
|
||||||
|
Msg::Update
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Msg::Update
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_valid_jpeg(bytes: &[u8]) -> bool {
|
||||||
|
image::io::Reader::with_format(std::io::Cursor::new(bytes), image::ImageFormat::Jpeg)
|
||||||
|
.decode()
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn maybe_to_base64(file: &JsFile) -> Result<Option<String>> {
|
||||||
|
match file {
|
||||||
|
JsFile {
|
||||||
|
file: None,
|
||||||
|
contents: _,
|
||||||
|
} => Ok(None),
|
||||||
|
JsFile {
|
||||||
|
file: Some(_),
|
||||||
|
contents: None,
|
||||||
|
} => bail!("Image file hasn't finished loading, try again"),
|
||||||
|
JsFile {
|
||||||
|
file: Some(_),
|
||||||
|
contents: Some(data),
|
||||||
|
} => {
|
||||||
|
if !is_valid_jpeg(data.as_slice()) {
|
||||||
|
bail!("Chosen image is not a valid JPEG");
|
||||||
|
}
|
||||||
|
Ok(Some(base64::encode(data)))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,198 +0,0 @@
|
|||||||
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>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
use super::cookies::set_cookie;
|
use super::cookies::set_cookie;
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use gloo_net::http::{Method, RequestBuilder};
|
use gloo_net::http::{Method, Request};
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
use lldap_auth::{login, registration, JWTClaims};
|
use lldap_auth::{login, registration, JWTClaims};
|
||||||
|
|
||||||
@@ -16,32 +16,21 @@ fn get_claims_from_jwt(jwt: &str) -> Result<JWTClaims> {
|
|||||||
Ok(token.claims().clone())
|
Ok(token.claims().clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
enum RequestType<Body: Serialize> {
|
const NO_BODY: Option<()> = None;
|
||||||
Get,
|
|
||||||
Post(Body),
|
|
||||||
}
|
|
||||||
|
|
||||||
const GET_REQUEST: RequestType<()> = RequestType::Get;
|
async fn call_server(
|
||||||
|
|
||||||
fn base_url() -> String {
|
|
||||||
yew_router::utils::base_url().unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn call_server<Body: Serialize>(
|
|
||||||
url: &str,
|
url: &str,
|
||||||
body: RequestType<Body>,
|
body: Option<impl Serialize>,
|
||||||
error_message: &'static str,
|
error_message: &'static str,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
let request_builder = RequestBuilder::new(url)
|
let mut request = Request::new(url)
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.credentials(RequestCredentials::SameOrigin);
|
.credentials(RequestCredentials::SameOrigin);
|
||||||
let request = if let RequestType::Post(b) = body {
|
if let Some(b) = body {
|
||||||
request_builder
|
request = request
|
||||||
.method(Method::POST)
|
.body(serde_json::to_string(&b)?)
|
||||||
.body(serde_json::to_string(&b)?)?
|
.method(Method::POST);
|
||||||
} else {
|
}
|
||||||
request_builder.build()?
|
|
||||||
};
|
|
||||||
let response = request.send().await?;
|
let response = request.send().await?;
|
||||||
if response.ok() {
|
if response.ok() {
|
||||||
Ok(response.text().await?)
|
Ok(response.text().await?)
|
||||||
@@ -58,7 +47,7 @@ async fn call_server<Body: Serialize>(
|
|||||||
|
|
||||||
async fn call_server_json_with_error_message<CallbackResult, Body: Serialize>(
|
async fn call_server_json_with_error_message<CallbackResult, Body: Serialize>(
|
||||||
url: &str,
|
url: &str,
|
||||||
request: RequestType<Body>,
|
request: Option<Body>,
|
||||||
error_message: &'static str,
|
error_message: &'static str,
|
||||||
) -> Result<CallbackResult>
|
) -> Result<CallbackResult>
|
||||||
where
|
where
|
||||||
@@ -70,7 +59,7 @@ where
|
|||||||
|
|
||||||
async fn call_server_empty_response_with_error_message<Body: Serialize>(
|
async fn call_server_empty_response_with_error_message<Body: Serialize>(
|
||||||
url: &str,
|
url: &str,
|
||||||
request: RequestType<Body>,
|
request: Option<Body>,
|
||||||
error_message: &'static str,
|
error_message: &'static str,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
call_server(url, request, error_message).await.map(|_| ())
|
call_server(url, request, error_message).await.map(|_| ())
|
||||||
@@ -108,8 +97,8 @@ impl HostService {
|
|||||||
};
|
};
|
||||||
let request_body = QueryType::build_query(variables);
|
let request_body = QueryType::build_query(variables);
|
||||||
call_server_json_with_error_message::<graphql_client::Response<_>, _>(
|
call_server_json_with_error_message::<graphql_client::Response<_>, _>(
|
||||||
&(base_url() + "/api/graphql"),
|
"/api/graphql",
|
||||||
RequestType::Post(request_body),
|
Some(request_body),
|
||||||
error_message,
|
error_message,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -120,8 +109,8 @@ impl HostService {
|
|||||||
request: login::ClientLoginStartRequest,
|
request: login::ClientLoginStartRequest,
|
||||||
) -> Result<Box<login::ServerLoginStartResponse>> {
|
) -> Result<Box<login::ServerLoginStartResponse>> {
|
||||||
call_server_json_with_error_message(
|
call_server_json_with_error_message(
|
||||||
&(base_url() + "/auth/opaque/login/start"),
|
"/auth/opaque/login/start",
|
||||||
RequestType::Post(request),
|
Some(request),
|
||||||
"Could not start authentication: ",
|
"Could not start authentication: ",
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -129,8 +118,8 @@ impl HostService {
|
|||||||
|
|
||||||
pub async fn login_finish(request: login::ClientLoginFinishRequest) -> Result<(String, bool)> {
|
pub async fn login_finish(request: login::ClientLoginFinishRequest) -> Result<(String, bool)> {
|
||||||
call_server_json_with_error_message::<login::ServerLoginResponse, _>(
|
call_server_json_with_error_message::<login::ServerLoginResponse, _>(
|
||||||
&(base_url() + "/auth/opaque/login/finish"),
|
"/auth/opaque/login/finish",
|
||||||
RequestType::Post(request),
|
Some(request),
|
||||||
"Could not finish authentication",
|
"Could not finish authentication",
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -141,8 +130,8 @@ impl HostService {
|
|||||||
request: registration::ClientRegistrationStartRequest,
|
request: registration::ClientRegistrationStartRequest,
|
||||||
) -> Result<Box<registration::ServerRegistrationStartResponse>> {
|
) -> Result<Box<registration::ServerRegistrationStartResponse>> {
|
||||||
call_server_json_with_error_message(
|
call_server_json_with_error_message(
|
||||||
&(base_url() + "/auth/opaque/register/start"),
|
"/auth/opaque/register/start",
|
||||||
RequestType::Post(request),
|
Some(request),
|
||||||
"Could not start registration: ",
|
"Could not start registration: ",
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -152,8 +141,8 @@ impl HostService {
|
|||||||
request: registration::ClientRegistrationFinishRequest,
|
request: registration::ClientRegistrationFinishRequest,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
call_server_empty_response_with_error_message(
|
call_server_empty_response_with_error_message(
|
||||||
&(base_url() + "/auth/opaque/register/finish"),
|
"/auth/opaque/register/finish",
|
||||||
RequestType::Post(request),
|
Some(request),
|
||||||
"Could not finish registration",
|
"Could not finish registration",
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -161,8 +150,8 @@ impl HostService {
|
|||||||
|
|
||||||
pub async fn refresh() -> Result<(String, bool)> {
|
pub async fn refresh() -> Result<(String, bool)> {
|
||||||
call_server_json_with_error_message::<login::ServerLoginResponse, _>(
|
call_server_json_with_error_message::<login::ServerLoginResponse, _>(
|
||||||
&(base_url() + "/auth/refresh"),
|
"/auth/refresh",
|
||||||
GET_REQUEST,
|
NO_BODY,
|
||||||
"Could not start authentication: ",
|
"Could not start authentication: ",
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -171,22 +160,14 @@ impl HostService {
|
|||||||
|
|
||||||
// The `_request` parameter is to make it the same shape as the other functions.
|
// The `_request` parameter is to make it the same shape as the other functions.
|
||||||
pub async fn logout() -> Result<()> {
|
pub async fn logout() -> Result<()> {
|
||||||
call_server_empty_response_with_error_message(
|
call_server_empty_response_with_error_message("/auth/logout", NO_BODY, "Could not logout")
|
||||||
&(base_url() + "/auth/logout"),
|
.await
|
||||||
GET_REQUEST,
|
|
||||||
"Could not logout",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn reset_password_step1(username: String) -> Result<()> {
|
pub async fn reset_password_step1(username: String) -> Result<()> {
|
||||||
call_server_empty_response_with_error_message(
|
call_server_empty_response_with_error_message(
|
||||||
&format!(
|
&format!("/auth/reset/step1/{}", url_escape::encode_query(&username)),
|
||||||
"{}/auth/reset/step1/{}",
|
NO_BODY,
|
||||||
base_url(),
|
|
||||||
url_escape::encode_query(&username)
|
|
||||||
),
|
|
||||||
RequestType::Post(""),
|
|
||||||
"Could not initiate password reset",
|
"Could not initiate password reset",
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -196,21 +177,21 @@ impl HostService {
|
|||||||
token: String,
|
token: String,
|
||||||
) -> Result<lldap_auth::password_reset::ServerPasswordResetResponse> {
|
) -> Result<lldap_auth::password_reset::ServerPasswordResetResponse> {
|
||||||
call_server_json_with_error_message(
|
call_server_json_with_error_message(
|
||||||
&format!("{}/auth/reset/step2/{}", base_url(), token),
|
&format!("/auth/reset/step2/{}", token),
|
||||||
GET_REQUEST,
|
NO_BODY,
|
||||||
"Could not validate token",
|
"Could not validate token",
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn probe_password_reset() -> Result<bool> {
|
pub async fn probe_password_reset() -> Result<bool> {
|
||||||
Ok(gloo_net::http::Request::post(
|
Ok(
|
||||||
&(base_url() + "/auth/reset/step1/lldap_unlikely_very_long_user_name"),
|
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,
|
||||||
)
|
)
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.send()
|
|
||||||
.await?
|
|
||||||
.status()
|
|
||||||
!= http::StatusCode::NOT_FOUND)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,11 +22,10 @@ pub fn set_cookie(cookie_name: &str, value: &str, expiration: &DateTime<Utc>) ->
|
|||||||
.map_err(|_| anyhow!("Document is not an HTMLDocument"))
|
.map_err(|_| anyhow!("Document is not an HTMLDocument"))
|
||||||
})?;
|
})?;
|
||||||
let cookie_string = format!(
|
let cookie_string = format!(
|
||||||
"{}={}; expires={}; sameSite=Strict; path={}/",
|
"{}={}; expires={}; sameSite=Strict; path=/",
|
||||||
cookie_name,
|
cookie_name,
|
||||||
value,
|
value,
|
||||||
expiration.to_rfc2822(),
|
expiration.to_rfc2822()
|
||||||
yew_router::utils::base_url().unwrap_or_default()
|
|
||||||
);
|
);
|
||||||
doc.set_cookie(&cookie_string)
|
doc.set_cookie(&cookie_string)
|
||||||
.map_err(|_| anyhow!("Could not set cookie"))
|
.map_err(|_| anyhow!("Could not set cookie"))
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
use anyhow::{anyhow, ensure, Result};
|
|
||||||
use validator::validate_email;
|
|
||||||
use web_sys::{FormData, HtmlFormElement};
|
|
||||||
use yew::NodeRef;
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct AttributeValue {
|
|
||||||
pub name: String,
|
|
||||||
pub values: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct GraphQlAttributeSchema {
|
|
||||||
pub name: String,
|
|
||||||
pub is_list: bool,
|
|
||||||
pub is_readonly: bool,
|
|
||||||
pub is_editable: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_attributes(
|
|
||||||
all_values: &[AttributeValue],
|
|
||||||
email_is_required: EmailIsRequired,
|
|
||||||
) -> Result<()> {
|
|
||||||
let maybe_email_values = all_values.iter().find(|a| a.name == "mail");
|
|
||||||
if email_is_required.0 || maybe_email_values.is_some() {
|
|
||||||
let email_values = &maybe_email_values
|
|
||||||
.ok_or_else(|| anyhow!("Email is required"))?
|
|
||||||
.values;
|
|
||||||
ensure!(email_values.len() == 1, "Email is required");
|
|
||||||
ensure!(validate_email(&email_values[0]), "Email is not valid");
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct IsAdmin(pub bool);
|
|
||||||
pub struct EmailIsRequired(pub bool);
|
|
||||||
|
|
||||||
pub fn read_all_form_attributes(
|
|
||||||
schema: impl IntoIterator<Item = impl Into<GraphQlAttributeSchema>>,
|
|
||||||
form_ref: &NodeRef,
|
|
||||||
is_admin: IsAdmin,
|
|
||||||
email_is_required: EmailIsRequired,
|
|
||||||
) -> Result<Vec<AttributeValue>> {
|
|
||||||
let form = form_ref.cast::<HtmlFormElement>().unwrap();
|
|
||||||
let form_data = FormData::new_with_form(&form)
|
|
||||||
.map_err(|e| anyhow!("Failed to get FormData: {:#?}", e.as_string()))?;
|
|
||||||
let all_values = schema
|
|
||||||
.into_iter()
|
|
||||||
.map(Into::<GraphQlAttributeSchema>::into)
|
|
||||||
.filter(|attr| !attr.is_readonly && (is_admin.0 || attr.is_editable))
|
|
||||||
.map(|attr| -> Result<AttributeValue> {
|
|
||||||
let val = form_data
|
|
||||||
.get_all(attr.name.as_str())
|
|
||||||
.iter()
|
|
||||||
.map(|js_val| js_val.as_string().unwrap_or_default())
|
|
||||||
.filter(|val| !val.is_empty())
|
|
||||||
.collect::<Vec<String>>();
|
|
||||||
ensure!(
|
|
||||||
val.len() <= 1 || attr.is_list,
|
|
||||||
"Multiple values supplied for non-list attribute {}",
|
|
||||||
attr.name
|
|
||||||
);
|
|
||||||
Ok(AttributeValue {
|
|
||||||
name: attr.name.clone(),
|
|
||||||
values: val,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect::<Result<Vec<_>>>()?;
|
|
||||||
validate_attributes(&all_values, email_is_required)?;
|
|
||||||
Ok(all_values)
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
use crate::infra::api::HostService;
|
|
||||||
use anyhow::Result;
|
|
||||||
use graphql_client::GraphQLQuery;
|
|
||||||
use wasm_bindgen_futures::spawn_local;
|
|
||||||
use yew::{use_effect_with_deps, use_state_eq, 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>),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T: PartialEq> PartialEq for LoadableResult<T> {
|
|
||||||
fn eq(&self, other: &Self) -> bool {
|
|
||||||
match (self, other) {
|
|
||||||
(LoadableResult::Loading, LoadableResult::Loading) => true,
|
|
||||||
(LoadableResult::Loaded(Ok(d1)), LoadableResult::Loaded(Ok(d2))) => d1.eq(d2),
|
|
||||||
(LoadableResult::Loaded(Err(e1)), LoadableResult::Loaded(Err(e2))) => {
|
|
||||||
e1.to_string().eq(&e2.to_string())
|
|
||||||
}
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn use_graphql_call<QueryType>(
|
|
||||||
variables: QueryType::Variables,
|
|
||||||
) -> UseStateHandle<LoadableResult<QueryType::ResponseData>>
|
|
||||||
where
|
|
||||||
QueryType: GraphQLQuery + 'static,
|
|
||||||
<QueryType as graphql_client::GraphQLQuery>::Variables: std::cmp::PartialEq + Clone,
|
|
||||||
<QueryType as graphql_client::GraphQLQuery>::ResponseData: std::cmp::PartialEq,
|
|
||||||
{
|
|
||||||
let loadable_result: UseStateHandle<LoadableResult<QueryType::ResponseData>> =
|
|
||||||
use_state_eq(|| LoadableResult::Loading);
|
|
||||||
{
|
|
||||||
let loadable_result = loadable_result.clone();
|
|
||||||
use_effect_with_deps(
|
|
||||||
move |variables| {
|
|
||||||
let task = HostService::graphql_query::<QueryType>(
|
|
||||||
variables.clone(),
|
|
||||||
"Failed graphql query",
|
|
||||||
);
|
|
||||||
|
|
||||||
spawn_local(async move {
|
|
||||||
let response = task.await;
|
|
||||||
loadable_result.set(LoadableResult::Loaded(response));
|
|
||||||
});
|
|
||||||
|
|
||||||
|| ()
|
|
||||||
},
|
|
||||||
variables,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
loadable_result.clone()
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,5 @@
|
|||||||
pub mod api;
|
pub mod api;
|
||||||
pub mod common_component;
|
pub mod common_component;
|
||||||
pub mod cookies;
|
pub mod cookies;
|
||||||
pub mod form_utils;
|
|
||||||
pub mod functional;
|
|
||||||
pub mod graphql;
|
pub mod graphql;
|
||||||
pub mod modal;
|
pub mod modal;
|
||||||
pub mod schema;
|
|
||||||
pub mod tooltip;
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
#![allow(clippy::empty_docs)]
|
|
||||||
|
|
||||||
use wasm_bindgen::prelude::*;
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
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 $crate::infra::schema::AttributeType {
|
|
||||||
fn from(value: $source_type) -> Self {
|
|
||||||
match value {
|
|
||||||
<$source_type>::STRING => $crate::infra::schema::AttributeType::String,
|
|
||||||
<$source_type>::INTEGER => $crate::infra::schema::AttributeType::Integer,
|
|
||||||
<$source_type>::DATE_TIME => $crate::infra::schema::AttributeType::DateTime,
|
|
||||||
<$source_type>::JPEG_PHOTO => $crate::infra::schema::AttributeType::Jpeg,
|
|
||||||
_ => panic!("Unknown attribute type"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<$crate::infra::schema::AttributeType> for $source_type {
|
|
||||||
fn from(value: $crate::infra::schema::AttributeType) -> Self {
|
|
||||||
match value {
|
|
||||||
$crate::infra::schema::AttributeType::String => <$source_type>::STRING,
|
|
||||||
$crate::infra::schema::AttributeType::Integer => <$source_type>::INTEGER,
|
|
||||||
$crate::infra::schema::AttributeType::DateTime => <$source_type>::DATE_TIME,
|
|
||||||
$crate::infra::schema::AttributeType::Jpeg => <$source_type>::JPEG_PHOTO,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn validate_attribute_type(attribute_type: &str) -> Result<(), ValidationError> {
|
|
||||||
AttributeType::from_str(attribute_type)
|
|
||||||
.map_err(|_| ValidationError::new("Invalid attribute type"))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
#![allow(clippy::empty_docs)]
|
|
||||||
|
|
||||||
use wasm_bindgen::prelude::*;
|
|
||||||
|
|
||||||
#[wasm_bindgen]
|
|
||||||
extern "C" {
|
|
||||||
#[wasm_bindgen(js_namespace = bootstrap)]
|
|
||||||
pub type Tooltip;
|
|
||||||
|
|
||||||
#[wasm_bindgen(constructor, js_namespace = bootstrap)]
|
|
||||||
pub fn new(e: web_sys::Element) -> Tooltip;
|
|
||||||
}
|
|
||||||
@@ -6,14 +6,13 @@ homepage = "https://github.com/lldap/lldap"
|
|||||||
license = "GPL-3.0-only"
|
license = "GPL-3.0-only"
|
||||||
name = "lldap_auth"
|
name = "lldap_auth"
|
||||||
repository = "https://github.com/lldap/lldap"
|
repository = "https://github.com/lldap/lldap"
|
||||||
version = "0.6.0"
|
version = "0.3.0"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["opaque_server", "opaque_client"]
|
default = ["opaque_server", "opaque_client"]
|
||||||
opaque_server = []
|
opaque_server = []
|
||||||
opaque_client = []
|
opaque_client = []
|
||||||
js = []
|
js = []
|
||||||
sea_orm = ["dep:sea-orm"]
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
rust-argon2 = "0.8"
|
rust-argon2 = "0.8"
|
||||||
@@ -21,32 +20,21 @@ curve25519-dalek = "3"
|
|||||||
digest = "0.9"
|
digest = "0.9"
|
||||||
generic-array = "0.14"
|
generic-array = "0.14"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
serde = "*"
|
serde = "1"
|
||||||
sha2 = "0.9"
|
sha2 = "0.9"
|
||||||
thiserror = "*"
|
thiserror = "1"
|
||||||
|
|
||||||
[dependencies.derive_more]
|
|
||||||
features = ["debug", "display"]
|
|
||||||
default-features = false
|
|
||||||
version = "1"
|
|
||||||
|
|
||||||
[dependencies.opaque-ke]
|
[dependencies.opaque-ke]
|
||||||
version = "0.6"
|
version = "0.6"
|
||||||
|
|
||||||
[dependencies.chrono]
|
[dependencies.chrono]
|
||||||
version = "*"
|
version = "0.4"
|
||||||
features = [ "serde" ]
|
features = [ "serde" ]
|
||||||
|
|
||||||
[dependencies.sea-orm]
|
|
||||||
version= "0.12"
|
|
||||||
default-features = false
|
|
||||||
features = ["macros"]
|
|
||||||
optional = true
|
|
||||||
|
|
||||||
# For WASM targets, use the JS getrandom.
|
# For WASM targets, use the JS getrandom.
|
||||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies.getrandom]
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies.getrandom]
|
||||||
version = "0.2"
|
version = "0.2"
|
||||||
|
features = ["js"]
|
||||||
|
|
||||||
[target.'cfg(target_arch = "wasm32")'.dependencies.getrandom]
|
[target.'cfg(target_arch = "wasm32")'.dependencies.getrandom]
|
||||||
version = "0.2"
|
version = "0.2"
|
||||||
features = ["js"]
|
|
||||||
|
|||||||
117
auth/src/lib.rs
117
auth/src/lib.rs
@@ -9,17 +9,17 @@ pub mod opaque;
|
|||||||
|
|
||||||
/// The messages for the 3-step OPAQUE and simple login process.
|
/// The messages for the 3-step OPAQUE and simple login process.
|
||||||
pub mod login {
|
pub mod login {
|
||||||
use super::{types::UserId, *};
|
use super::*;
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone)]
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
pub struct ServerData {
|
pub struct ServerData {
|
||||||
pub username: UserId,
|
pub username: String,
|
||||||
pub server_login: opaque::server::login::ServerLogin,
|
pub server_login: opaque::server::login::ServerLogin,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone)]
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
pub struct ClientLoginStartRequest {
|
pub struct ClientLoginStartRequest {
|
||||||
pub username: UserId,
|
pub username: String,
|
||||||
pub login_start_request: opaque::server::login::CredentialRequest,
|
pub login_start_request: opaque::server::login::CredentialRequest,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,14 +39,14 @@ pub mod login {
|
|||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone)]
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
pub struct ClientSimpleLoginRequest {
|
pub struct ClientSimpleLoginRequest {
|
||||||
pub username: UserId,
|
pub username: String,
|
||||||
pub password: String,
|
pub password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Debug for ClientSimpleLoginRequest {
|
impl fmt::Debug for ClientSimpleLoginRequest {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
f.debug_struct("ClientSimpleLoginRequest")
|
f.debug_struct("ClientSimpleLoginRequest")
|
||||||
.field("username", &self.username.as_str())
|
.field("username", &self.username)
|
||||||
.field("password", &"***********")
|
.field("password", &"***********")
|
||||||
.finish()
|
.finish()
|
||||||
}
|
}
|
||||||
@@ -63,16 +63,16 @@ pub mod login {
|
|||||||
/// The messages for the 3-step OPAQUE registration process.
|
/// The messages for the 3-step OPAQUE registration process.
|
||||||
/// It is used to reset a user's password.
|
/// It is used to reset a user's password.
|
||||||
pub mod registration {
|
pub mod registration {
|
||||||
use super::{types::UserId, *};
|
use super::*;
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone)]
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
pub struct ServerData {
|
pub struct ServerData {
|
||||||
pub username: UserId,
|
pub username: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone)]
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
pub struct ClientRegistrationStartRequest {
|
pub struct ClientRegistrationStartRequest {
|
||||||
pub username: UserId,
|
pub username: String,
|
||||||
pub registration_start_request: opaque::server::registration::RegistrationRequest,
|
pub registration_start_request: opaque::server::registration::RegistrationRequest,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,107 +104,6 @@ pub mod password_reset {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub mod types {
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
#[cfg(feature = "sea_orm")]
|
|
||||||
use sea_orm::{DbErr, DeriveValueType, 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,
|
|
||||||
Default,
|
|
||||||
Hash,
|
|
||||||
Serialize,
|
|
||||||
Deserialize,
|
|
||||||
derive_more::Debug,
|
|
||||||
derive_more::Display,
|
|
||||||
)]
|
|
||||||
#[cfg_attr(feature = "sea_orm", derive(DeriveValueType))]
|
|
||||||
#[serde(from = "CaseInsensitiveString")]
|
|
||||||
#[debug(r#""{}""#, _0.as_str())]
|
|
||||||
#[display("{}", _0.as_str())]
|
|
||||||
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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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)]
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
pub struct JWTClaims {
|
pub struct JWTClaims {
|
||||||
pub exp: DateTime<Utc>,
|
pub exp: DateTime<Utc>,
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
use crate::types::UserId;
|
|
||||||
use opaque_ke::ciphersuite::CipherSuite;
|
use opaque_ke::ciphersuite::CipherSuite;
|
||||||
use rand::{CryptoRng, RngCore};
|
use rand::{CryptoRng, RngCore};
|
||||||
|
|
||||||
@@ -78,10 +77,10 @@ pub mod client {
|
|||||||
pub use opaque_ke::ClientRegistrationFinishParameters;
|
pub use opaque_ke::ClientRegistrationFinishParameters;
|
||||||
/// Initiate the registration negotiation.
|
/// Initiate the registration negotiation.
|
||||||
pub fn start_registration<R: RngCore + CryptoRng>(
|
pub fn start_registration<R: RngCore + CryptoRng>(
|
||||||
password: &[u8],
|
password: &str,
|
||||||
rng: &mut R,
|
rng: &mut R,
|
||||||
) -> AuthenticationResult<ClientRegistrationStartResult> {
|
) -> AuthenticationResult<ClientRegistrationStartResult> {
|
||||||
Ok(ClientRegistration::start(rng, password)?)
|
Ok(ClientRegistration::start(rng, password.as_bytes())?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Finalize the registration negotiation.
|
/// Finalize the registration negotiation.
|
||||||
@@ -146,12 +145,12 @@ pub mod server {
|
|||||||
pub fn start_registration(
|
pub fn start_registration(
|
||||||
server_setup: &ServerSetup,
|
server_setup: &ServerSetup,
|
||||||
registration_request: RegistrationRequest,
|
registration_request: RegistrationRequest,
|
||||||
username: &UserId,
|
username: &str,
|
||||||
) -> AuthenticationResult<ServerRegistrationStartResult> {
|
) -> AuthenticationResult<ServerRegistrationStartResult> {
|
||||||
Ok(ServerRegistration::start(
|
Ok(ServerRegistration::start(
|
||||||
server_setup,
|
server_setup,
|
||||||
registration_request,
|
registration_request,
|
||||||
username.as_str().as_bytes(),
|
username.as_bytes(),
|
||||||
)?)
|
)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,14 +178,14 @@ pub mod server {
|
|||||||
server_setup: &ServerSetup,
|
server_setup: &ServerSetup,
|
||||||
password_file: Option<ServerRegistration>,
|
password_file: Option<ServerRegistration>,
|
||||||
credential_request: CredentialRequest,
|
credential_request: CredentialRequest,
|
||||||
username: &UserId,
|
username: &str,
|
||||||
) -> AuthenticationResult<ServerLoginStartResult> {
|
) -> AuthenticationResult<ServerLoginStartResult> {
|
||||||
Ok(ServerLogin::start(
|
Ok(ServerLogin::start(
|
||||||
rng,
|
rng,
|
||||||
server_setup,
|
server_setup,
|
||||||
password_file,
|
password_file,
|
||||||
credential_request,
|
credential_request,
|
||||||
username.as_str().as_bytes(),
|
username.as_bytes(),
|
||||||
ServerLoginStartParameters::default(),
|
ServerLoginStartParameters::default(),
|
||||||
)?)
|
)?)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
#!/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 "$@"
|
|
||||||
@@ -20,7 +20,7 @@ LLDAP has a command that will connect to a target database and initialize the
|
|||||||
schema. If running with docker, run the following command to use your active
|
schema. If running with docker, run the following command to use your active
|
||||||
instance (this has the benefit of ensuring your container has access):
|
instance (this has the benefit of ensuring your container has access):
|
||||||
|
|
||||||
```sh
|
```
|
||||||
docker exec -it <LLDAP container name> /app/lldap create_schema -d <Target database url>
|
docker exec -it <LLDAP container name> /app/lldap create_schema -d <Target database url>
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ databases (SQLite in this example) will give an error if LLDAP is in the middle
|
|||||||
statements. There are various ways to do this, but a simple enough way is filtering a
|
statements. There are various ways to do this, but a simple enough way is filtering a
|
||||||
whole database dump. This repo contains [a script](/scripts/sqlite_dump_commands.sh) to generate SQLite commands for creating an appropriate dump:
|
whole database dump. This repo contains [a script](/scripts/sqlite_dump_commands.sh) to generate SQLite commands for creating an appropriate dump:
|
||||||
|
|
||||||
```sh
|
```
|
||||||
./sqlite_dump_commands.sh | sqlite3 /path/to/lldap/config/users.db > /path/to/dump.sql
|
./sqlite_dump_commands.sh | sqlite3 /path/to/lldap/config/users.db > /path/to/dump.sql
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -49,22 +49,20 @@ a transaction in case one of the statements fail.
|
|||||||
PostgreSQL uses a different hex string format. The command below should switch SQLite
|
PostgreSQL uses a different hex string format. The command below should switch SQLite
|
||||||
format to PostgreSQL format, and wrap it all in a transaction:
|
format to PostgreSQL format, and wrap it all in a transaction:
|
||||||
|
|
||||||
```sh
|
```
|
||||||
sed -i -r -e "s/X'([[:xdigit:]]+'[^'])/'\\\x\\1/g" \
|
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 '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
|
-e '$aCOMMIT;' /path/to/dump.sql
|
||||||
```
|
```
|
||||||
|
|
||||||
### To MySQL
|
### To MySQL
|
||||||
|
|
||||||
MySQL mostly cooperates, but it gets some errors if you don't escape the `groups` table. It also uses
|
MySQL mostly cooperates, but it gets some errors if you don't escape the `groups` table. It also uses
|
||||||
backticks to escape table name instead of quotes. Run the
|
backticks to escape table name instead of quotes. Run the
|
||||||
following command to wrap all table names in backticks for good measure, and wrap the inserts in
|
following command to wrap all table names in backticks for good measure, and wrap the inserts in
|
||||||
a transaction:
|
a transaction:
|
||||||
|
|
||||||
```sh
|
```
|
||||||
sed -i -r -e 's/^INSERT INTO "?([a-zA-Z0-9_]+)"?/INSERT INTO `\1`/' \
|
sed -i -r -e 's/^INSERT INTO "?([a-zA-Z0-9_]+)"?/INSERT INTO `\1`/' \
|
||||||
-e '1s/^/START TRANSACTION;\n/' \
|
-e '1s/^/START TRANSACTION;\n/' \
|
||||||
-e '$aCOMMIT;' \
|
-e '$aCOMMIT;' \
|
||||||
@@ -76,7 +74,7 @@ sed -i -r -e 's/^INSERT INTO "?([a-zA-Z0-9_]+)"?/INSERT INTO `\1`/' \
|
|||||||
While MariaDB is supposed to be identical to MySQL, it doesn't support timezone offsets on DATETIME
|
While MariaDB is supposed to be identical to MySQL, it doesn't support timezone offsets on DATETIME
|
||||||
strings. Use the following command to remove those and perform the additional MySQL sanitization:
|
strings. Use the following command to remove those and perform the additional MySQL sanitization:
|
||||||
|
|
||||||
```sh
|
```
|
||||||
sed -i -r -e "s/([^']'[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{9})\+00:00'([^'])/\1'\2/g" \
|
sed -i -r -e "s/([^']'[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{9})\+00:00'([^'])/\1'\2/g" \
|
||||||
-e 's/^INSERT INTO "?([a-zA-Z0-9_]+)"?/INSERT INTO `\1`/' \
|
-e 's/^INSERT INTO "?([a-zA-Z0-9_]+)"?/INSERT INTO `\1`/' \
|
||||||
-e '1s/^/START TRANSACTION;\n/' \
|
-e '1s/^/START TRANSACTION;\n/' \
|
||||||
@@ -108,4 +106,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
|
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.
|
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/lldap/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/nitnelave/lldap/main/.github/workflows/docker-build-static.yml), look for the job `lldap-database-migration-test`
|
||||||
|
|||||||
@@ -18,15 +18,6 @@ still supports basic RootDSE queries.
|
|||||||
|
|
||||||
Anonymous bind is not supported.
|
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
|
## GraphQL
|
||||||
|
|
||||||
The best way to interact with LLDAP programmatically is via the GraphQL
|
The best way to interact with LLDAP programmatically is via the GraphQL
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
# MegaRAC SP-X BMC IPMI LDAP Setup
|
|
||||||
|
|
||||||
The MegaRAC SP-X BMC is a service processor firmware stack designed by American Megatrends Inc. (AMI), aimed at providing out-of-band management for servers and computing systems.
|
|
||||||
It's part of the MegaRAC family of management solutions, offering remote server management capabilities, including monitoring, control, and maintenance functionalities, independent of the operating system or system state.
|
|
||||||
This enables administrators to manage systems remotely for tasks such as updates, troubleshooting, and recovery.
|
|
||||||
|
|
||||||
## Setting up LLDAP with MegaRAC SP-X BMC IPMI
|
|
||||||
|
|
||||||
### Pre-requisites
|
|
||||||
- Create and assign the `ipmi` group in LLDAP to a (test) user.
|
|
||||||
- Bind User: 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: password of the user specified above
|
|
||||||
|
|
||||||
### Configuration Steps
|
|
||||||
|
|
||||||
1. **Navigate**: Go to `Settings > External User Settings > LDAP/E-Directory Settings > General Settings`.
|
|
||||||
|
|
||||||
2. **General LDAP Settings**:
|
|
||||||
- **Encryption Type**: `SSL` (or No Encryption if preferred)
|
|
||||||
- **Common Name Type**: `FQDN` (or IP if you use a plain IP address to connect to lldap)
|
|
||||||
- **Server Address**: `fqdn.lldap.tld`
|
|
||||||
- **Port**: `6360` (default for SSL, adjust if necessary to default non ssl `3890`)
|
|
||||||
|
|
||||||
3. **Authentication** (use read-only bind user):
|
|
||||||
- **Bind DN**: `uid=bind_user,ou=people,dc=example,dc=com`
|
|
||||||
- **Password**: `change_bind_user_password`
|
|
||||||
|
|
||||||
4. **Search Configuration**:
|
|
||||||
- **Search Base**: `ou=people,dc=example,dc=com`
|
|
||||||
- **Attribute of User Login**: `uid`
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
5. **Navigate**: Go to `Settings > External User Settings > LDAP/E-Directory Settings > Role groups`.
|
|
||||||
|
|
||||||
6. **Click on empty role group in order to assign a new one**
|
|
||||||
|
|
||||||
7. **Role Group - Group Details**:
|
|
||||||
- **Group Name**: `ipmi`
|
|
||||||
- **Group Domain**: `cn=ipmi,ou=groups,dc=example,dc=com`
|
|
||||||
- **Group Privilege**: `Administrator`
|
|
||||||
|
|
||||||
8. **Group Permissions**:
|
|
||||||
- KVM Access: Enabled (adjust as needed)
|
|
||||||
- VMedia Access: Enabled (adjust as needed)
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# 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))
|
|
||||||
```
|
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ authentication_backend:
|
|||||||
implementation: custom
|
implementation: custom
|
||||||
# Pattern is ldap://HOSTNAME-OR-IP:PORT
|
# Pattern is ldap://HOSTNAME-OR-IP:PORT
|
||||||
# Normal ldap port is 389, standard in LLDAP is 3890
|
# Normal ldap port is 389, standard in LLDAP is 3890
|
||||||
address: ldap://lldap:3890
|
url: ldap://lldap:3890
|
||||||
# The dial timeout for LDAP.
|
# The dial timeout for LDAP.
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
# Use StartTLS with the LDAP connection, TLS not supported right now
|
# Use StartTLS with the LDAP connection, TLS not supported right now
|
||||||
@@ -25,6 +25,7 @@ authentication_backend:
|
|||||||
# minimum_version: TLS1.2
|
# minimum_version: TLS1.2
|
||||||
# Set base dn, like dc=google,dc.com
|
# Set base dn, like dc=google,dc.com
|
||||||
base_dn: dc=example,dc=com
|
base_dn: dc=example,dc=com
|
||||||
|
username_attribute: uid
|
||||||
# You need to set this to ou=people, because all users are stored in this ou!
|
# You need to set this to ou=people, because all users are stored in this ou!
|
||||||
additional_users_dn: ou=people
|
additional_users_dn: ou=people
|
||||||
# To allow sign in both with username and email, one can use a filter like
|
# To allow sign in both with username and email, one can use a filter like
|
||||||
@@ -32,20 +33,16 @@ authentication_backend:
|
|||||||
users_filter: "(&({username_attribute}={input})(objectClass=person))"
|
users_filter: "(&({username_attribute}={input})(objectClass=person))"
|
||||||
# Set this to ou=groups, because all groups are stored in this ou
|
# Set this to ou=groups, because all groups are stored in this ou
|
||||||
additional_groups_dn: ou=groups
|
additional_groups_dn: ou=groups
|
||||||
# The groups are not displayed in the UI, but this filter works.
|
# Only this filter is supported right now
|
||||||
groups_filter: "(member={dn})"
|
groups_filter: "(member={dn})"
|
||||||
# The attribute holding the name of the group.
|
# The attribute holding the name of the group.
|
||||||
attributes:
|
group_name_attribute: cn
|
||||||
display_name: displayName
|
# Email attribute
|
||||||
username: uid
|
mail_attribute: mail
|
||||||
group_name: cn
|
# The attribute holding the display name of the user. This will be used to greet an authenticated user.
|
||||||
mail: mail
|
display_name_attribute: displayName
|
||||||
# distinguished_name: distinguishedName
|
# The username and password of the admin user.
|
||||||
# member_of: memberOf
|
# "admin" should be the admin username you set in the LLDAP configuration
|
||||||
|
user: uid=admin,ou=people,dc=example,dc=com
|
||||||
# The username and password of the bind user.
|
|
||||||
# "bind_user" should be the username you created for authentication with the "lldap_strict_readonly" permission. It is not recommended to use an actual admin account here.
|
|
||||||
# If you are configuring Authelia to change user passwords, then the account used here needs the "lldap_password_manager" permission instead.
|
|
||||||
user: uid=bind_user,ou=people,dc=example,dc=com
|
|
||||||
# Password can also be set using a secret: https://www.authelia.com/docs/configuration/secrets.html
|
# Password can also be set using a secret: https://www.authelia.com/docs/configuration/secrets.html
|
||||||
password: 'REPLACE_ME'
|
password: 'REPLACE_ME'
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 51 KiB |
@@ -1,314 +0,0 @@
|
|||||||
# Bootstrapping lldap using [bootstrap.sh](/scripts/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
|
|
||||||
* create user/group user-defined attributes
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## 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` (default value: `http://localhost:17170`) - URL to your lldap instance or path to file that contains URL
|
|
||||||
- `LLDAP_ADMIN_USERNAME` or `LLDAP_ADMIN_USERNAME_FILE` (default value: `admin`) - admin username or path to file that contains username
|
|
||||||
- `LLDAP_ADMIN_PASSWORD` or `LLDAP_ADMIN_PASSWORD_FILE` (default value: `password`) - admin password or path to file that contains password
|
|
||||||
- `USER_CONFIGS_DIR` (default value: `/bootstrap/user-configs`) - directory where the user JSON configs could be found
|
|
||||||
- `GROUP_CONFIGS_DIR` (default value: `/bootstrap/group-configs`) - directory where the group JSON configs could be found
|
|
||||||
- `USER_SCHEMAS_DIR` (default value: `/bootstrap/user-schemas`) - directory where the user schema JSON configs could be found
|
|
||||||
- `GROUP_SCHEMAS_DIR` (default value: `/bootstrap/group-schemas`) - directory where the group schema 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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
### User and group schema config file example
|
|
||||||
|
|
||||||
User and group schema have the same structure.
|
|
||||||
|
|
||||||
Fields description:
|
|
||||||
|
|
||||||
* `name`: name of field, case insensitve - you should use lowercase
|
|
||||||
* `attributeType`: `STRING` / `INTEGER` / `JPEG` / `DATE_TIME`
|
|
||||||
* `isList`: single on multiple value field
|
|
||||||
* `isEditable`: self-explanatory
|
|
||||||
* `isVisible`: self-explanatory
|
|
||||||
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"name": "uid",
|
|
||||||
"attributeType": "INTEGER",
|
|
||||||
"isEditable": false,
|
|
||||||
"isList": false,
|
|
||||||
"isVisible": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "mailbox",
|
|
||||||
"attributeType": "STRING",
|
|
||||||
"isEditable": false,
|
|
||||||
"isList": false,
|
|
||||||
"isVisible": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "mail_alias",
|
|
||||||
"attributeType": "STRING",
|
|
||||||
"isEditable": false,
|
|
||||||
"isList": true,
|
|
||||||
"isVisible": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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 USER_SCHEMAS_DIR="$(realpath ./configs/user-schema)"
|
|
||||||
export GROUP_SCHEMAS_DIR="$(realpath ./configs/group-schema)"
|
|
||||||
export LLDAP_SET_PASSWORD_PATH="$(realpath ./lldap_set_password)"
|
|
||||||
export DO_CLEANUP=false
|
|
||||||
./bootstrap.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
### Manually from running docker container or service
|
|
||||||
|
|
||||||
After setting a docker container you can bootstrap users using:
|
|
||||||
|
|
||||||
```
|
|
||||||
docker exec -e LLDAP_ADMIN_PASSWORD_FILE=password -v ./bootstrap:/bootstrap -it $(docker ps --filter name=lldap -q) /app/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
|
|
||||||
└─ user-schemas
|
|
||||||
| ├─ user-attrs-1.json
|
|
||||||
| ├─ ...
|
|
||||||
| └─ user-attrs-n.json
|
|
||||||
└─ group-schemas
|
|
||||||
├─ group-attrs-1.json
|
|
||||||
├─ ...
|
|
||||||
└─ group-attrs-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
|
|
||||||
- USER_SCHEMAS_DIR=/bootstrap/user-schemas
|
|
||||||
- GROUP_SCHEMAS_DIR=/bootstrap/group-schemas
|
|
||||||
- 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
|
|
||||||
readOnly: true
|
|
||||||
subPath: bootstrap.sh
|
|
||||||
|
|
||||||
- name: user-configs
|
|
||||||
mountPath: /bootstrap/user-configs
|
|
||||||
readOnly: true
|
|
||||||
|
|
||||||
- name: group-configs
|
|
||||||
mountPath: /bootstrap/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
|
|
||||||
```
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
# Configuration for Carpal
|
|
||||||
|
|
||||||
[Carpal](https://github.com/peeley/carpal) is a small, configurable
|
|
||||||
[WebFinger](https://webfinger.net) server than can pull resource information
|
|
||||||
from LDAP directories.
|
|
||||||
|
|
||||||
There are two files used to configure Carpal for LDAP:
|
|
||||||
|
|
||||||
- The YAML configuration file for Carpal itself
|
|
||||||
- A Go template file for injecting the LDAP data into the WebFinger response
|
|
||||||
|
|
||||||
### YAML File
|
|
||||||
|
|
||||||
Replace the server URL, admin credentials, and domain for your server:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# /etc/carpal/config.yml
|
|
||||||
|
|
||||||
driver: ldap
|
|
||||||
ldap:
|
|
||||||
url: ldap://myldapserver
|
|
||||||
bind_user: uid=myadmin,ou=people,dc=foobar,dc=com
|
|
||||||
bind_pass: myadminpassword
|
|
||||||
basedn: ou=people,dc=foobar,dc=com
|
|
||||||
filter: (uid=*)
|
|
||||||
user_attr: uid
|
|
||||||
attributes:
|
|
||||||
- uid
|
|
||||||
- mail
|
|
||||||
- cn
|
|
||||||
template: /etc/carpal/ldap.gotempl
|
|
||||||
```
|
|
||||||
|
|
||||||
If you have configured any user-defined attributes on your users, you can also
|
|
||||||
add those to the `attributes` field.
|
|
||||||
|
|
||||||
### Go Template File
|
|
||||||
|
|
||||||
This is an example template; the template file is intended to be editable for
|
|
||||||
your needs. If your users, for example, don't have Mastodon profiles, you can
|
|
||||||
delete the Mastodon alias.
|
|
||||||
|
|
||||||
```gotempl
|
|
||||||
# /etc/carpal/ldap.gotempl
|
|
||||||
|
|
||||||
aliases:
|
|
||||||
- "mailto:{{ index . "mail" }}"
|
|
||||||
- "https://mastodon/{{ index . "uid" }}"
|
|
||||||
properties:
|
|
||||||
'http://webfinger.example/ns/name': '{{ index . "cn" }}'
|
|
||||||
links:
|
|
||||||
- rel: "http://webfinger.example/rel/profile-page"
|
|
||||||
href: "https://www.example.com/~{{ index . "uid" }}/"
|
|
||||||
```
|
|
||||||
|
|
||||||
This example also only contains the default attributes present on all LLDAP
|
|
||||||
users. If you have added custom user-defined attributes to your users and added
|
|
||||||
them to the `attributes` field of the YAML config file, you can use them in
|
|
||||||
this template file.
|
|
||||||
@@ -10,7 +10,8 @@ connectors:
|
|||||||
id: ldap
|
id: ldap
|
||||||
name: LDAP
|
name: LDAP
|
||||||
config:
|
config:
|
||||||
host: lldap-host:3890 # or 6360 if you have ldaps enabled, make sure it does not start with `ldap://`
|
host: lldap-host # make sure it does not start with `ldap://`
|
||||||
|
port: 3890 # or 6360 if you have ldaps enabled
|
||||||
insecureNoSSL: true # or false if you have ldaps enabled
|
insecureNoSSL: true # or false if you have ldaps enabled
|
||||||
insecureSkipVerify: true # or false if you have ldaps enabled
|
insecureSkipVerify: true # or false if you have ldaps enabled
|
||||||
bindDN: uid=admin,ou=people,dc=example,dc=com # replace admin with your admin user
|
bindDN: uid=admin,ou=people,dc=example,dc=com # replace admin with your admin user
|
||||||
|
|||||||
@@ -6,12 +6,11 @@ LDAP configuration is in ```/dokuwiki/conf/local.protected.php```:
|
|||||||
<?php
|
<?php
|
||||||
$conf['useacl'] = 1; //enable ACL
|
$conf['useacl'] = 1; //enable ACL
|
||||||
$conf['authtype'] = 'authldap'; //enable this Auth plugin
|
$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']['server'] = 'ldap://lldap_server:3890'; #IP of your lldap
|
||||||
$conf['plugin']['authldap']['usertree'] = 'ou=people,dc=example,dc=com';
|
$conf['plugin']['authldap']['usertree'] = 'ou=people,dc=example,dc=com';
|
||||||
$conf['plugin']['authldap']['grouptree'] = 'ou=groups,dc=example,dc=com';
|
$conf['plugin']['authldap']['grouptree'] = 'ou=groups, dc=example, dc=com';
|
||||||
$conf['plugin']['authldap']['userfilter'] = '(&(uid=%{user})(objectClass=person))';
|
$conf['plugin']['authldap']['userfilter'] = '(&(uid=%{user})(objectClass=person))';
|
||||||
$conf['plugin']['authldap']['groupfilter'] = '(&(member=%{dn})(objectClass=groupOfUniqueNames))';
|
$conf['plugin']['authldap']['groupfilter'] = '(objectClass=group)';
|
||||||
$conf['plugin']['authldap']['attributes'] = array('cn', 'displayname', 'mail', 'givenname', 'objectclass', 'sn', 'uid', 'memberof');
|
$conf['plugin']['authldap']['attributes'] = array('cn', 'displayname', 'mail', 'givenname', 'objectclass', 'sn', 'uid', 'memberof');
|
||||||
$conf['plugin']['authldap']['version'] = 3;
|
$conf['plugin']['authldap']['version'] = 3;
|
||||||
$conf['plugin']['authldap']['binddn'] = 'cn=admin,ou=people,dc=example,dc=com';
|
$conf['plugin']['authldap']['binddn'] = 'cn=admin,ou=people,dc=example,dc=com';
|
||||||
@@ -24,11 +23,3 @@ All you need to do is to activate the plugin. This can be done on the DokuWiki E
|
|||||||
Once the LDAP settings are defined, proceed to define the default authentication method.
|
Once the LDAP settings are defined, proceed to define the default authentication method.
|
||||||
Navigate to Table of Contents > DokuWiki > Authentication.
|
Navigate to Table of Contents > DokuWiki > Authentication.
|
||||||
On the Authentication backend, select ```authldap``` and save the changes.
|
On the Authentication backend, select ```authldap``` and save the changes.
|
||||||
|
|
||||||
## Internal (or other authentication) fallback
|
|
||||||
If you dont want to use LDAP authentication exclusively, you can install the [authchained plugin](https://www.dokuwiki.org/plugin:authchained). It tries multiple auth backends when a user logs in.
|
|
||||||
|
|
||||||
```
|
|
||||||
$conf['authtype'] = 'authchained';
|
|
||||||
$conf['plugin']['authchained']['authtypes'] = 'authldap:authplain';
|
|
||||||
```
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# Basic LDAP auth for an Ergo IRC server
|
|
||||||
|
|
||||||
[Main documentation here.](https://github.com/ergochat/ergo-ldap)
|
|
||||||
|
|
||||||
For simple user auth prepare a ldap-config.yaml with the following settings
|
|
||||||
|
|
||||||
```
|
|
||||||
host: "127.0.0.1"
|
|
||||||
port: 3890
|
|
||||||
timeout: 30s
|
|
||||||
|
|
||||||
# uncomment for TLS / LDAPS:
|
|
||||||
# use-ssl: true
|
|
||||||
|
|
||||||
bind-dn: "uid=%s,ou=people,dc=example,dc=org"
|
|
||||||
```
|
|
||||||
|
|
||||||
Then add the compiled ergo-ldap program to your Ergo folder and make sure it can be executed by the same user your Ergo IRCd runs as.
|
|
||||||
|
|
||||||
Follow the instructions in the main Ergo config file's accounts section on how to execute an external auth program.
|
|
||||||
|
|
||||||
Make sure SASL auth is enabled and then restart Ergo to enable LDAP linked SASL auth.
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
Extract lldap's [FreeBSD tar.gz](https://github.com/n-connect/rustd-hbbx/blob/main/x86_64-freebsd_lldap-0.5.1.tar.gz) under /usr/local/:
|
|
||||||
|
|
||||||
`tar -xvf x86_64-freebsd_lldap-0.5.1.tar.gz -C /usr/local/`
|
|
||||||
|
|
||||||
Move rc.d script into the right place:
|
|
||||||
`mv /usr/local/lldap_server/rc.d_lldap /usr/local/etc/rc.d/lldap`
|
|
||||||
|
|
||||||
Make your config, if your want to enable LDAPS, copy your server key and certification files, and set the owneship (currently www):
|
|
||||||
|
|
||||||
`cp /usr/local/lldap_server/lldap_config.docker_template.toml /usr/local/lldap_server/lldap_config..toml`
|
|
||||||
|
|
||||||
Enable lldap service in /etc/rc.conf:
|
|
||||||
|
|
||||||
`sysrc lldap_enable="YES"`
|
|
||||||
|
|
||||||
Start your service:
|
|
||||||
|
|
||||||
`service lldap start`
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
|
|
||||||
# PROVIDE: lldap
|
|
||||||
# REQUIRE: DAEMON NETWORKING
|
|
||||||
# KEYWORD: shutdown
|
|
||||||
|
|
||||||
# Add the following lines to /etc/rc.conf to enable lldap:
|
|
||||||
# lldap_enable : set to "YES" to enable the daemon, default is "NO"
|
|
||||||
|
|
||||||
. /etc/rc.subr
|
|
||||||
|
|
||||||
name=lldap
|
|
||||||
rcvar=lldap_enable
|
|
||||||
|
|
||||||
lldap_chdir="/usr/local/lldap_server"
|
|
||||||
|
|
||||||
load_rc_config $name
|
|
||||||
|
|
||||||
lldap_enable=${lldap_enable:-"NO"}
|
|
||||||
|
|
||||||
logfile="/var/log/${name}.log"
|
|
||||||
|
|
||||||
procname=/usr/local/lldap_server/lldap
|
|
||||||
command="/usr/sbin/daemon"
|
|
||||||
command_args="-u www -o ${logfile} -t ${name} /usr/local/lldap_server/lldap run"
|
|
||||||
|
|
||||||
run_rc_command "$1"
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
# 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'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@@ -20,7 +20,7 @@ ssl_skip_verify = false
|
|||||||
# client_key = "/path/to/client.key"
|
# client_key = "/path/to/client.key"
|
||||||
|
|
||||||
# Search user bind dn
|
# Search user bind dn
|
||||||
bind_dn = "uid=<your grafana user>,ou=people,dc=example,dc=com"
|
bind_dn = "uid=<your grafana user>,ou=people,dc=example,dc=org"
|
||||||
# Search user bind password
|
# Search user bind password
|
||||||
# If the password contains # or ; you have to wrap it with triple quotes. Ex """#password;"""
|
# If the password contains # or ; you have to wrap it with triple quotes. Ex """#password;"""
|
||||||
bind_password = "<grafana user password>"
|
bind_password = "<grafana user password>"
|
||||||
@@ -31,13 +31,13 @@ search_filter = "(uid=%s)"
|
|||||||
# search_filter = "(&(uid=%s)(memberOf=cn=<your group>,ou=groups,dc=example,dc=org))"
|
# search_filter = "(&(uid=%s)(memberOf=cn=<your group>,ou=groups,dc=example,dc=org))"
|
||||||
|
|
||||||
# An array of base dns to search through
|
# An array of base dns to search through
|
||||||
search_base_dns = ["dc=example,dc=com"]
|
search_base_dns = ["dc=example,dc=org"]
|
||||||
|
|
||||||
# Specify names of the LDAP attributes your LDAP uses
|
# Specify names of the LDAP attributes your LDAP uses
|
||||||
[servers.attributes]
|
[servers.attributes]
|
||||||
member_of = "memberOf"
|
member_of = "memberOf"
|
||||||
email = "mail"
|
email = "mail"
|
||||||
name = "displayName"
|
name = "givenName"
|
||||||
surname = "sn"
|
surname = "sn"
|
||||||
username = "uid"
|
username = "uid"
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
# 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`
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
[Harbor](https://goharbor.io) is a CNCF cloud native container registry for kubernetes.
|
|
||||||
|
|
||||||
You can pass environment variables into ``harbor-core`` for auth configuration as documented [here](https://github.com/goharbor/website/blob/release-2.10.0/docs/install-config/configure-system-settings-cli.md#harbor-configuration-items).
|
|
||||||
|
|
||||||
Configure ``ldap_url`` and ``ldap_verify_cert`` as needed for your installation.
|
|
||||||
|
|
||||||
Using the [harbor-helm](https://github.com/goharbor/harbor-helm) chart, these vars can be passed in under ``core.configureUserSettings`` as a JSON string:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
core:
|
|
||||||
configureUserSettings: |
|
|
||||||
{
|
|
||||||
"auth_mode": "ldap_auth",
|
|
||||||
"ldap_url": "ldaps://lldap.example.com",
|
|
||||||
"ldap_base_dn": "ou=people,dc=example,dc=com",
|
|
||||||
"ldap_search_dn": "uid=bind,ou=people,dc=example,dc=com",
|
|
||||||
"ldap_search_password": "very-secure-password",
|
|
||||||
"ldap_group_base_dn": "ou=groups,dc=example,dc=com",
|
|
||||||
"ldap_group_admin_dn": "cn=harbor-admin-group,ou=groups,dc=example,dc=com",
|
|
||||||
"ldap_group_search_filter": "(objectClass=groupOfUniqueNames)",
|
|
||||||
"ldap_group_attribute_name": "uid"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> [!IMPORTANT]
|
|
||||||
> ``ldap_search_dn`` needs to be able to bind and search. The ``lldap_strict_readonly`` group is sufficient.
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> Members of the ``ldap_group_admin_dn`` group will receive harbor admin privledges.
|
|
||||||
> Users outside this group will have their ldap group(s) imported into harbor (under "groups" with type "ldap").
|
|
||||||
> These groups can be used for permissions assigned to a harbor "project".
|
|
||||||
@@ -6,8 +6,8 @@ Home Assistant configures ldap auth via the [Command Line Auth Provider](https:/
|
|||||||
|
|
||||||
The [auth script](lldap-ha-auth.sh) attempts to authenticate a user against an LLDAP server, using credentials provided via `username` and `password` environment variables. The first argument must be the URL of your LLDAP server, accessible from Home Assistant. You can provide an additional optional argument to confine allowed logins to a single group. The script will output the user's display name as the `name` variable, if not empty.
|
The [auth script](lldap-ha-auth.sh) attempts to authenticate a user against an LLDAP server, using credentials provided via `username` and `password` environment variables. The first argument must be the URL of your LLDAP server, accessible from Home Assistant. You can provide an additional optional argument to confine allowed logins to a single group. The script will output the user's display name as the `name` variable, if not empty.
|
||||||
|
|
||||||
1. Copy the [auth script](lldap-ha-auth.sh) to your home assistant instance. In this example, we use `/config/lldap-ha-auth.sh`.
|
1. Copy the [auth script](lldap-ha-auth.sh) to your home assistant instance. In this example, we use `/config/lldap-auth.sh`.
|
||||||
- Set the script as executable by running `chmod +x /config/lldap-ha-auth.sh`
|
- Set the script as executable by running `chmod +x /config/lldap-auth-sh`
|
||||||
2. Add the following to your configuration.yaml in Home assistant:
|
2. Add the following to your configuration.yaml in Home assistant:
|
||||||
```yaml
|
```yaml
|
||||||
homeassistant:
|
homeassistant:
|
||||||
@@ -15,21 +15,10 @@ homeassistant:
|
|||||||
# Ensure you have the homeassistant provider enabled if you want to continue using your existing accounts
|
# Ensure you have the homeassistant provider enabled if you want to continue using your existing accounts
|
||||||
- type: homeassistant
|
- type: homeassistant
|
||||||
- type: command_line
|
- type: command_line
|
||||||
command: /config/lldap-ha-auth.sh
|
command: /config/lldap-auth.sh
|
||||||
# arguments: [<LDAP Host>, <regular user group>, <admin user group>, <local user group>]
|
# Only allow users in the 'homeassistant_user' group to login.
|
||||||
# <regular user group>: Find users that has permission to access homeassistant, anyone inside
|
# Change to ["https://lldap.example.com"] to allow all users
|
||||||
# this group will have the default 'system-users' permission in homeassistant.
|
args: ["https://lldap.example.com", "homeassistant_user"]
|
||||||
#
|
|
||||||
# <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
|
meta: true
|
||||||
```
|
```
|
||||||
3. Reload your config or restart Home Assistant
|
3. Reload your config or restart Home Assistant
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 8.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
@@ -1,12 +1,10 @@
|
|||||||
# Configuration for Jellyfin
|
# Configuration for Jellyfin
|
||||||
|
|
||||||
Replace all instances of `dc=example,dc=com` with your LLDAP configured domain.
|
Replace `dc=example,dc=com` with your LLDAP configured domain.
|
||||||
|
|
||||||
## LDAP Server Settings
|
|
||||||
### LDAP Bind User
|
### LDAP Bind User
|
||||||
Create an ldap user for Jellyfin to run search queries (and optionally reset passwords). For example `jellyfin_bind_user`
|
|
||||||
```
|
```
|
||||||
uid=jellyfin_bind_user,ou=people,dc=example,dc=com
|
uid=admin,ou=people,dc=example,dc=com
|
||||||
```
|
```
|
||||||
|
|
||||||
### LDAP Base DN for searches
|
### LDAP Base DN for searches
|
||||||
@@ -14,33 +12,34 @@ uid=jellyfin_bind_user,ou=people,dc=example,dc=com
|
|||||||
ou=people,dc=example,dc=com
|
ou=people,dc=example,dc=com
|
||||||
```
|
```
|
||||||
|
|
||||||
## LDAP User Settings
|
### LDAP Attributes
|
||||||
### LDAP Search Filter
|
|
||||||
If you have a `media` group, you can use:
|
|
||||||
```
|
|
||||||
(memberof=cn=media,ou=groups,dc=example,dc=com)
|
|
||||||
```
|
|
||||||
Otherwise, just use:
|
|
||||||
```
|
|
||||||
(uid=*)
|
|
||||||
```
|
|
||||||
### LDAP Search Attributes
|
|
||||||
```
|
```
|
||||||
uid, mail
|
uid, mail
|
||||||
```
|
```
|
||||||
### LDAP Uid Attribute
|
|
||||||
```
|
### LDAP Name Attribute
|
||||||
uid
|
|
||||||
```
|
|
||||||
### LDAP Username Attribute
|
|
||||||
```
|
```
|
||||||
uid
|
uid
|
||||||
```
|
```
|
||||||
|
|
||||||
### LDAP Admin Base DN
|
### User Filter
|
||||||
The DN to search for your admins.
|
|
||||||
|
If you have a `media` group, you can use:
|
||||||
```
|
```
|
||||||
ou=people,dc=example,dc=com
|
(memberof=cn=media,ou=groups,dc=example,dc=com)
|
||||||
|
```
|
||||||
|
|
||||||
|
Otherwise, just use:
|
||||||
|
```
|
||||||
|
(uid=*)
|
||||||
|
```
|
||||||
|
### Admin Base DN
|
||||||
|
|
||||||
|
The DN of your admin group. If you have `media_admin` as your group you would use:
|
||||||
|
```
|
||||||
|
cn=media_admin,ou=groups,dc=example,dc=com
|
||||||
```
|
```
|
||||||
|
|
||||||
### Admin Filter
|
### Admin Filter
|
||||||
@@ -50,15 +49,8 @@ that), use:
|
|||||||
```
|
```
|
||||||
(memberof=cn=media_admin,ou=groups,dc=example,dc=com)
|
(memberof=cn=media_admin,ou=groups,dc=example,dc=com)
|
||||||
```
|
```
|
||||||
Bear in mind that admins must also be a member of the users group if you use one.
|
|
||||||
|
|
||||||
Otherwise, you can use LLDAP's admin group:
|
Otherwise, you can use LLDAP's admin group:
|
||||||
```
|
```
|
||||||
(memberof=cn=lldap_admin,ou=groups,dc=example,dc=com)
|
(memberof=cn=lldap_admin,ou=groups,dc=example,dc=com)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Password change
|
|
||||||
To allow changing Passwords via Jellyfin the following things are required
|
|
||||||
- The bind user needs to have the group lldap_password_manager (changing passwords of members of the group lldap_admin does not work to prevent privilege escalation)
|
|
||||||
- Check `Allow Password Change`
|
|
||||||
- `LDAP Password Attribute` Needs to be set to `userPassword`
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user