4 Commits

Author SHA1 Message Date
Dedy Martadinata S
1b2dfbe52e Publish job using matrix
Change publish job with matrix, don't fail if another job fail.
2023-05-12 20:41:35 +07:00
Valentin Tolmer
e1aa2bfb18 cargo: depend on the published version of lldap_auth 2023-05-12 15:37:39 +02:00
Valentin Tolmer
1377d5aed9 github: create actions to publish crates 2023-05-12 15:25:59 +02:00
Valentin Tolmer
fa0185af5e cargo: set specific versions for each dependency 2023-05-12 15:25:59 +02:00
198 changed files with 4435 additions and 17260 deletions

View File

@@ -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

2
.gitattributes vendored
View File

@@ -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
View File

@@ -1,5 +0,0 @@
# These are supported funding model platforms
github: [lldap]
custom: ['https://bmc.link/nitnelave']

View File

@@ -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.

View File

@@ -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.

View File

@@ -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
View File

@@ -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"

View File

@@ -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"]

View File

@@ -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"]

View File

@@ -1,3 +0,0 @@
FROM localhost:5000/lldap/lldap:alpine-base
COPY --chown=$USER:$USER docker-entrypoint-rootless.sh /docker-entrypoint.sh
USER $USER

View File

@@ -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"]

View File

@@ -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"]

View File

@@ -1,3 +0,0 @@
FROM localhost:5000/lldap/lldap:debian-base
COPY --chown=$USER:$USER docker-entrypoint-rootless.sh /docker-entrypoint.sh
USER $USER

View File

@@ -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"]

View File

@@ -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

View File

@@ -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.

View File

@@ -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' }}

View File

@@ -5,149 +5,6 @@ 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

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -9,8 +9,6 @@ members = [
default-members = ["server"] default-members = ["server"]
resolver = "2"
[profile.release] [profile.release]
lto = true lto = true

View File

@@ -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
View File

@@ -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

View File

@@ -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']

View File

@@ -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"
@@ -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>

View File

@@ -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>

View File

@@ -1,5 +1,5 @@
mutation CreateGroup($group: CreateGroupInput!) { mutation CreateGroup($name: String!) {
createGroupWithDetails(request: $group) { createGroup(name: $name) {
id id
displayName displayName
} }

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -1,5 +0,0 @@
mutation DeleteGroupAttributeQuery($name: String!) {
deleteGroupAttribute(name: $name) {
ok
}
}

View File

@@ -1,5 +0,0 @@
mutation DeleteUserAttributeQuery($name: String!) {
deleteUserAttribute(name: $name) {
ok
}
}

View File

@@ -1,14 +0,0 @@
query GetGroupAttributesSchema {
schema {
groupSchema {
attributes {
name
attributeType
isList
isVisible
isHardcoded
isReadonly
}
}
}
}

View File

@@ -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
}
}
} }
} }

View File

@@ -1,15 +0,0 @@
query GetUserAttributesSchema {
schema {
userSchema {
attributes {
name
attributeType
isList
isVisible
isEditable
isHardcoded
isReadonly
}
}
}
}

View File

@@ -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
}
}
} }
} }

View File

@@ -1,6 +0,0 @@
mutation UpdateGroup($group: UpdateGroupInput!) {
updateGroup(group: $group) {
ok
}
}

View File

@@ -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! {

View File

@@ -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>
} }

View File

@@ -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>
}
}

View File

@@ -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>
}
}

View File

@@ -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>
</> </>
} }

View File

@@ -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())}
/>
}
}
}

View File

@@ -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>
}
}
}

View File

@@ -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())}
/>
}
}
}

View File

@@ -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>
}
}
}

View File

@@ -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>
}
}
}

View File

@@ -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>
}
}
}

View File

@@ -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>
}
}
}

View File

@@ -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>
}
}

View File

@@ -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>
}
}

View File

@@ -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>
}
}

View File

@@ -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()
}

View File

@@ -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;

View File

@@ -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>
}
}

View File

@@ -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>
}
}

View File

@@ -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>
}
}

View File

@@ -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>
} }

View File

@@ -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)
}
}

View File

@@ -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>
}
}

View File

@@ -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>
} }
} }

View File

@@ -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;

View File

@@ -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! {

View File

@@ -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! {

View File

@@ -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,
} }

View File

@@ -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>},
} }
} }
} }

View File

@@ -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)))
}
}
} }

View File

@@ -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>
}
}

View File

@@ -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)
} }
} }

View File

@@ -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"))

View File

@@ -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)
}

View File

@@ -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()
}

View File

@@ -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;

View File

@@ -1,5 +1,3 @@
#![allow(clippy::empty_docs)]
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
#[wasm_bindgen] #[wasm_bindgen]

View File

@@ -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(())
}

View File

@@ -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;
}

View File

@@ -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"]

View File

@@ -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>,

View File

@@ -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(),
)?) )?)
} }

View File

@@ -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 "$@"

View File

@@ -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`

View File

@@ -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

View File

@@ -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`
![General LDAP Settings](images/megarac_user.png)
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)
![Role Groups](images/megarac_group.png)

View File

@@ -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))
```

View File

@@ -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

View File

@@ -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
![](bootstrap-example-log-1.jpeg)
## 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
```

View File

@@ -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.

View 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

View File

@@ -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';
```

View File

@@ -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.

View File

@@ -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`

View File

@@ -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"

View File

@@ -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'
}
}
}
```

View File

@@ -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"

View File

@@ -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`

View File

@@ -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".

View File

@@ -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

View File

@@ -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